name='invoice', 41
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='billing.invoice'),
42
+        ),
43
+        migrations.AddField(
44
+            model_name='invoiceline',
45
+            name='material',
46
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='recycle_core.material'),
47
+        ),
48
+        migrations.AddField(
49
+            model_name='invoice',
50
+            name='customer',
51
+            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoices', to='recycle_core.customer'),
52
+        ),
53
+        migrations.AddField(
54
+            model_name='invoice',
55
+            name='organization',
56
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='orgs.organization'),
57
+        ),
58
+        migrations.AddField(
59
+            model_name='invoice',
60
+            name='pickup',
61
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invoices', to='recycle_core.pickuporder'),
62
+        ),
63
+    ]

+ 0 - 0
billing/migrations/__init__.py


+ 70 - 0
billing/models.py

@@ -0,0 +1,70 @@
1
+from __future__ import annotations
2
+
3
+from django.db import models
4
+from django.utils import timezone
5
+
6
+# Import domain models with light coupling
7
+from orgs.models import Organization
8
+from recycle_core.models import Customer, PickupOrder, Material
9
+
10
+
11
+class TimestampedModel(models.Model):
12
+    created_at = models.DateTimeField(auto_now_add=True)
13
+    updated_at = models.DateTimeField(auto_now=True)
14
+
15
+    class Meta:
16
+        abstract = True
17
+
18
+
19
+class Invoice(TimestampedModel):
20
+    STATUS_DRAFT = "draft"
21
+    STATUS_ISSUED = "issued"
22
+    STATUS_PAID = "paid"
23
+    STATUS_VOID = "void"
24
+    STATUS_CHOICES = (
25
+        (STATUS_DRAFT, "Draft"),
26
+        (STATUS_ISSUED, "Issued"),
27
+        (STATUS_PAID, "Paid"),
28
+        (STATUS_VOID, "Void"),
29
+    )
30
+
31
+    organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="invoices")
32
+    customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name="invoices")
33
+    pickup = models.ForeignKey(PickupOrder, on_delete=models.SET_NULL, null=True, blank=True, related_name="invoices")
34
+    currency_code = models.CharField(max_length=8, default="USD")
35
+    total_amount = models.DecimalField(max_digits=14, decimal_places=2, default=0)
36
+    status = models.CharField(max_length=8, choices=STATUS_CHOICES, default=STATUS_DRAFT, db_index=True)
37
+    issued_at = models.DateTimeField(null=True, blank=True)
38
+    due_at = models.DateTimeField(null=True, blank=True)
39
+
40
+
41
+
42
+class InvoiceLine(TimestampedModel):
43
+    invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name="lines")
44
+    description = models.CharField(max_length=255)
45
+    material = models.ForeignKey(Material, on_delete=models.SET_NULL, null=True, blank=True)
46
+    quantity = models.DecimalField(max_digits=12, decimal_places=3)
47
+    unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
48
+    unit_price = models.DecimalField(max_digits=12, decimal_places=2)
49
+    line_total = models.DecimalField(max_digits=14, decimal_places=2)
50
+
51
+
52
+
53
+class Payment(TimestampedModel):
54
+    invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name="payments")
55
+    amount = models.DecimalField(max_digits=14, decimal_places=2)
56
+    currency_code = models.CharField(max_length=8, default="USD")
57
+    received_at = models.DateTimeField(default=timezone.now)
58
+    reference = models.CharField(max_length=128, blank=True)
59
+
60
+
61
+
62
+class Payout(TimestampedModel):
63
+    organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="payouts")
64
+    customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name="payouts")
65
+    pickup = models.ForeignKey(PickupOrder, on_delete=models.SET_NULL, null=True, blank=True, related_name="payouts")
66
+    amount = models.DecimalField(max_digits=14, decimal_places=2)
67
+    currency_code = models.CharField(max_length=8, default="USD")
68
+    paid_at = models.DateTimeField(default=timezone.now)
69
+    reference = models.CharField(max_length=128, blank=True)
70
+

+ 3 - 0
billing/tests.py

@@ -0,0 +1,3 @@
1
+from django.test import TestCase
2
+
3
+# Create your tests here.

+ 12 - 0
billing/urls_admin.py

@@ -0,0 +1,12 @@
1
+from django.urls import path
2
+from . import views
3
+
4
+app_name = "billing"
5
+
6
+urlpatterns = [
7
+    path("invoices/", views.invoices_list, name="invoices_list"),
8
+    path("invoices/<int:pk>/add-payment/", views.invoice_add_payment, name="invoice_add_payment"),
9
+    path("payouts/", views.payouts_list, name="payouts_list"),
10
+    path("payouts/export/", views.payouts_export_csv, name="payouts_export_csv"),
11
+]
12
+

+ 181 - 0
billing/views.py

@@ -0,0 +1,181 @@
1
+from django.shortcuts import render, redirect, get_object_or_404
2
+from django.contrib.admin.views.decorators import staff_member_required
3
+from django.contrib import messages
4
+from django.urls import reverse
5
+from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
6
+import django_filters as filters
7
+
8
+from admin_frontend.templatetags.public_urls import public_route
9
+from admin_frontend.nav import _nav_items
10
+from cms.views import breadcrumbs
11
+
12
+from orgs.models import Organization
13
+from recycle_core.models import Customer
14
+from recycle_core.forms import PaymentForm
15
+
16
+from .models import Invoice, Payment, Payout
17
+
18
+from django.db.models import Sum, F, ExpressionWrapper, DecimalField, Value
19
+from django.db.models.functions import Coalesce
20
+from decimal import Decimal
21
+from django.http import HttpResponse
22
+import csv
23
+
24
+
25
+# Invoices UI ---------------------------------------------------------------
26
+
27
+@staff_member_required
28
+@public_route(label="Invoices", order=50, icon="receipt-percent")
29
+@breadcrumbs(label="Invoices", name="re_invoices")
30
+def invoices_list(request):
31
+    class InvoiceFilter(filters.FilterSet):
32
+        organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
33
+        customer = filters.ModelChoiceFilter(queryset=Customer.objects.all())
34
+        status = filters.ChoiceFilter(field_name="status", choices=Invoice.STATUS_CHOICES)
35
+        issued_at = filters.DateFromToRangeFilter(field_name="issued_at", label="Issued between")
36
+
37
+        class Meta:
38
+            model = Invoice
39
+            fields = ["organization", "customer", "status", "issued_at"]
40
+
41
+    base_qs = (
42
+        Invoice.objects.select_related("organization", "customer")
43
+        .annotate(
44
+            payments_total=Coalesce(
45
+                Sum("payments__amount"),
46
+                Value(Decimal("0.00")),
47
+                output_field=DecimalField(max_digits=14, decimal_places=2),
48
+            ),
49
+        )
50
+        .annotate(
51
+            balance=ExpressionWrapper(
52
+                F("total_amount") - F("payments_total"),
53
+                output_field=DecimalField(max_digits=14, decimal_places=2),
54
+            )
55
+        )
56
+        .order_by("-issued_at", "-id")
57
+    )
58
+    i_filter = InvoiceFilter(request.GET, queryset=base_qs)
59
+    qs = i_filter.qs
60
+    org = getattr(request, "org", None)
61
+    if org is not None:
62
+        qs = qs.filter(organization=org)
63
+
64
+    paginator = Paginator(qs, 10)
65
+    page = request.GET.get("page")
66
+    try:
67
+        page_obj = paginator.page(page)
68
+    except PageNotAnInteger:
69
+        page_obj = paginator.page(1)
70
+    except EmptyPage:
71
+        page_obj = paginator.page(paginator.num_pages)
72
+
73
+    pay_form = PaymentForm()
74
+    context = {
75
+        "nav": _nav_items(),
76
+        "invoices": page_obj.object_list,
77
+        "page_obj": page_obj,
78
+        "filter": i_filter,
79
+        "pay_form": pay_form,
80
+    }
81
+    return render(request, "recycle_core/invoices_list.html", context)
82
+
83
+
84
+@staff_member_required
85
+def invoice_add_payment(request, pk: int):
86
+    invoice = get_object_or_404(Invoice, pk=pk)
87
+    form = PaymentForm(request.POST)
88
+    if form.is_valid():
89
+        Payment.objects.create(
90
+            invoice=invoice,
91
+            amount=form.cleaned_data["amount"],
92
+            currency_code=invoice.currency_code,
93
+            received_at=form.cleaned_data.get("received_at") or None,
94
+            reference=form.cleaned_data.get("reference", ""),
95
+        )
96
+        messages.success(request, f"Payment recorded for invoice #{invoice.id}.")
97
+    else:
98
+        messages.error(request, "Invalid payment data.")
99
+    return redirect("billing:invoices_list")
100
+
101
+
102
+# Payouts UI + CSV ----------------------------------------------------------
103
+
104
+@staff_member_required
105
+@public_route(label="Payouts", order=52, icon="banknotes")
106
+@breadcrumbs(label="Payouts", name="re_payouts")
107
+def payouts_list(request):
108
+    class PayoutFilter(filters.FilterSet):
109
+        organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
110
+        customer = filters.ModelChoiceFilter(queryset=Customer.objects.all())
111
+        paid_at = filters.DateFromToRangeFilter(field_name="paid_at", label="Paid between")
112
+
113
+        class Meta:
114
+            model = Payout
115
+            fields = ["organization", "customer", "paid_at"]
116
+
117
+    base_qs = Payout.objects.select_related("organization", "customer").order_by("-paid_at", "-id")
118
+    p_filter = PayoutFilter(request.GET, queryset=base_qs)
119
+    qs = p_filter.qs
120
+    org = getattr(request, "org", None)
121
+    if org is not None:
122
+        qs = qs.filter(organization=org)
123
+
124
+    paginator = Paginator(qs, 10)
125
+    page = request.GET.get("page")
126
+    try:
127
+        page_obj = paginator.page(page)
128
+    except PageNotAnInteger:
129
+        page_obj = paginator.page(1)
130
+    except EmptyPage:
131
+        page_obj = paginator.page(paginator.num_pages)
132
+
133
+    context = {
134
+        "nav": _nav_items(),
135
+        "payouts": page_obj.object_list,
136
+        "page_obj": page_obj,
137
+        "filter": p_filter,
138
+    }
139
+    return render(request, "recycle_core/payouts_list.html", context)
140
+
141
+
142
+@staff_member_required
143
+def payouts_export_csv(request):
144
+    qs = Payout.objects.select_related("organization", "customer").all()
145
+    org_obj = getattr(request, "org", None)
146
+    if org_obj is not None:
147
+        qs = qs.filter(organization=org_obj)
148
+    org = request.GET.get("organization")
149
+    cust = request.GET.get("customer")
150
+    start = request.GET.get("paid_at_after") or request.GET.get("paid_at_min")
151
+    end = request.GET.get("paid_at_before") or request.GET.get("paid_at_max")
152
+    from django.utils.dateparse import parse_datetime
153
+
154
+    if org:
155
+        qs = qs.filter(organization_id=org)
156
+    if cust:
157
+        qs = qs.filter(customer_id=cust)
158
+    if start:
159
+        dt = parse_datetime(start)
160
+        if dt:
161
+            qs = qs.filter(paid_at__gte=dt)
162
+    if end:
163
+        dt = parse_datetime(end)
164
+        if dt:
165
+            qs = qs.filter(paid_at__lte=dt)
166
+
167
+    response = HttpResponse(content_type="text/csv")
168
+    response["Content-Disposition"] = "attachment; filename=payouts.csv"
169
+    writer = csv.writer(response)
170
+    writer.writerow(["organization", "customer", "amount", "currency", "paid_at", "reference", "pickup_id"]) 
171
+    for p in qs.iterator():
172
+        writer.writerow([
173
+            p.organization.code,
174
+            p.customer.name,
175
+            p.amount,
176
+            p.currency_code,
177
+            p.paid_at.isoformat(),
178
+            p.reference,
179
+            p.pickup_id or "",
180
+        ])
181
+    return response

+ 3 - 1
ecoloop/settings.py

@@ -82,7 +82,8 @@ INSTALLED_APPS = [
82 82
     'frontend',
83 83
     'api',
84 84
     'cms',
85
-    'recycle_core'
85
+    'recycle_core',
86
+    'billing'
86 87
 ]
87 88
 if DEBUG:
88 89
     # Add django_browser_reload only in DEBUG mode
@@ -116,6 +117,7 @@ NPM_FILE_PATTERNS = {
116 117
     # JS UI libs
117 118
     'flowbite': ['dist/flowbite.min.js', 'dist/flowbite.min.css'],
118 119
     'alpinejs': ['dist/cdn.min.js'],
120
+    'sortablejs': ['Sortable.min.js'],
119 121
 }
120 122
 
121 123
 MIDDLEWARE = [

+ 1 - 0
ecoloop/urls.py

@@ -30,6 +30,7 @@ urlpatterns = [
30 30
     path('accounts/', include('allauth.urls')),
31 31
     path('webadmin/', include('admin_frontend.urls')),
32 32
     path('webadmin/orgs/', include('orgs.urls_admin')),
33
+    path('webadmin/billings/', include('billing.urls_admin')),
33 34
     path('webadmin/frontend/', include('public_frontend.urls_admin')),
34 35
     path('webadmin/settings/', include('settings.urls')),
35 36
     path('webadmin/cms/', include('cms.urls')),

+ 9 - 0
orgs/apps.py

@@ -6,3 +6,12 @@ class OrgsConfig(AppConfig):
6 6
     name = "orgs"
7 7
     verbose_name = "Organizations & Tenancy"
8 8
 
9
+    def ready(self) -> None:  # pragma: no cover
10
+        # Import signal handlers robustly to avoid attribute import issues
11
+        import importlib
12
+        try:
13
+            importlib.import_module("orgs.signals")
14
+        except Exception:
15
+            # Avoid startup hard-fail if migrations/apps not ready; re-raise only in debug if needed
16
+            raise
17
+        return super().ready()

+ 71 - 0
orgs/decorators.py

@@ -0,0 +1,71 @@
1
+from __future__ import annotations
2
+
3
+from functools import wraps
4
+from typing import Iterable, Callable, Optional
5
+
6
+from django.contrib import messages
7
+from django.contrib.auth.views import redirect_to_login
8
+from django.shortcuts import redirect
9
+from django.urls import reverse
10
+
11
+
12
+def permissions_required(*perms: str, any_perm: bool = False, message: Optional[str] = None, login_view: str = "admin_frontend:login"):
13
+    """Decorator to enforce one or more Django permissions on a view.
14
+
15
+    - If the user is not authenticated, redirects to the admin login, preserving next.
16
+    - If permissions fail, adds an error message and redirects back to the current URL.
17
+
18
+    Usage:
19
+        @permissions_required('recycle_core.assign_driver')
20
+        def my_view(...):
21
+            ...
22
+
23
+        @permissions_required('app.perm_a', 'app.perm_b')  # ALL required
24
+        def another(...):
25
+            ...
26
+
27
+        @permissions_required('app.perm_a', 'app.perm_b', any_perm=True)  # ANY required
28
+        def another(...):
29
+            ...
30
+    """
31
+
32
+    def decorator(view_func: Callable):
33
+        @wraps(view_func)
34
+        def _wrapped(request, *args, **kwargs):
35
+            if not request.user.is_authenticated:
36
+                return redirect_to_login(request.get_full_path(), login_url=reverse(login_view))
37
+
38
+            # Normalize permissions list
39
+            perms_list: Iterable[str] = perms or []
40
+
41
+            allowed = False
42
+            if any_perm:
43
+                allowed = any(request.user.has_perm(p) for p in perms_list) if perms_list else True
44
+            else:
45
+                # ALL required
46
+                allowed = request.user.has_perms(perms_list) if perms_list else True
47
+
48
+            if not allowed:
49
+                msg = message or (
50
+                    "You do not have the required permission." if len(perms_list) <= 1
51
+                    else "You do not have the required permissions."
52
+                )
53
+                try:
54
+                    messages.error(request, msg)
55
+                except Exception:
56
+                    pass
57
+                # Avoid redirect loops on POST-only endpoints by preferring HTTP_REFERER
58
+                referer = request.META.get("HTTP_REFERER")
59
+                if referer:
60
+                    return redirect(referer)
61
+                # Fallback to a safe dashboard
62
+                try:
63
+                    return redirect(reverse("admin_frontend:dashboard"))
64
+                except Exception:
65
+                    return redirect("/")
66
+
67
+            return view_func(request, *args, **kwargs)
68
+
69
+        return _wrapped
70
+
71
+    return decorator

+ 44 - 0
orgs/signals.py

@@ -0,0 +1,44 @@
1
+from __future__ import annotations
2
+
3
+from django.db.models.signals import post_save
4
+from django.dispatch import receiver
5
+from django.contrib.auth.models import Group
6
+
7
+from .models import UserProfile
8
+
9
+
10
+ROLE_GROUP_MAP = {
11
+    UserProfile.ROLE_OWNER: "owner",
12
+    UserProfile.ROLE_MANAGER: "manager",
13
+    UserProfile.ROLE_DRIVER: "driver",
14
+    UserProfile.ROLE_CUSTOMER: "customer",
15
+    UserProfile.ROLE_AUDITOR: "auditor",
16
+}
17
+
18
+MANAGED_GROUPS = set(ROLE_GROUP_MAP.values())
19
+
20
+
21
+@receiver(post_save, sender=UserProfile)
22
+def sync_profile_role_to_groups(sender, instance: UserProfile, created: bool, **kwargs):
23
+    """Keep Django Group membership in sync with the profile role.
24
+
25
+    - Removes the user from managed role groups (owner/manager/driver/customer/auditor)
26
+    - Adds the user to the group mapped from the current role
27
+    - Lazily creates the Group if missing
28
+    """
29
+    user = instance.user
30
+    if user is None or not getattr(user, "pk", None):
31
+        return
32
+
33
+    # Remove from managed groups first (do not touch other unrelated groups)
34
+    current_managed = user.groups.filter(name__in=MANAGED_GROUPS)
35
+    if current_managed.exists():
36
+        user.groups.remove(*current_managed)
37
+
38
+    # Add target group for role
39
+    group_name = ROLE_GROUP_MAP.get(instance.role)
40
+    if not group_name:
41
+        return
42
+    group, _ = Group.objects.get_or_create(name=group_name)
43
+    user.groups.add(group)
44
+

+ 48 - 0
orgs/templates/orgs/permissions_edit.html

@@ -0,0 +1,48 @@
1
+{% extends "admin_frontend/base.html" %}
2
+{% load breadcrumbs %}
3
+{% block title %}Edit Group Permissions{% 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">Edit Permissions — {{ group.name|title }}</h1>
8
+  <a href="{% url 'orgs_admin:permissions_overview' %}" class="btn-outline">Back</a>
9
+</div>
10
+
11
+<form method="post" class="space-y-6">
12
+  {% csrf_token %}
13
+{% for app, models in grouped_apps.items %}
14
+  <div class="bg-white rounded shadow p-4" x-data="{ all:false }" data-app="{{ app }}">
15
+    <div class="flex items-center justify-between mb-2">
16
+      <h2 class="font-medium">{{ app|title }}</h2>
17
+      <label class="flex items-center gap-2 text-sm">
18
+        <input type="checkbox" x-model="all" @change="$root.querySelectorAll('[data-app=\'{{ app }}\'] input[name=perm_ids]').forEach(cb=>cb.checked=all)">
19
+        <span>Select all</span>
20
+      </label>
21
+    </div>
22
+
23
+    {% for model in models %}
24
+      <div class="mb-3 border-t pt-2" x-data="{ all:false }" data-model="{{ app }}.{{ model.model_key }}">
25
+        <div class="flex items-center justify-between mb-1">
26
+          <h3 class="text-sm font-semibold">{{ model.model_label }}</h3>
27
+          <label class="flex items-center gap-2 text-xs text-gray-600">
28
+            <input type="checkbox" x-model="all" @change="$root.querySelectorAll('[data-model=\'{{ app }}.{{ model.model_key }}\'] input[name=perm_ids]').forEach(cb=>cb.checked=all)">
29
+            <span>Select all {{ model.model_label }}</span>
30
+          </label>
31
+        </div>
32
+        <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
33
+          {% for p in model.perms %}
34
+            <label class="flex items-center gap-2 text-sm">
35
+              <input type="checkbox" name="perm_ids" value="{{ p.id }}" {% if p.id in current_ids %}checked{% endif %}>
36
+              <span><span class="font-mono text-xs bg-gray-100 px-1 py-0.5 rounded">{{ p.content_type.app_label }}.{{ p.codename }}</span> — {{ p.name }}</span>
37
+            </label>
38
+          {% endfor %}
39
+        </div>
40
+      </div>
41
+    {% endfor %}
42
+  </div>
43
+{% endfor %}
44
+  <div>
45
+    <button class="px-4 py-2 bg-blue-600 text-white rounded">Save Changes</button>
46
+  </div>
47
+</form>
48
+{% endblock %}

+ 66 - 0
orgs/templates/orgs/permissions_group_users.html

@@ -0,0 +1,66 @@
1
+{% extends "admin_frontend/base.html" %}
2
+{% load breadcrumbs %}
3
+{% block title %}{{ group.name|title }} Users{% 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">Users in {{ group.name|title }}</h1>
8
+  <a href="{% url 'orgs_admin:permissions_overview' %}" class="btn-outline">Back</a>
9
+  </div>
10
+
11
+<div class="bg-white rounded shadow p-4 mb-4">
12
+  <form method="get" class="flex flex-wrap items-end gap-3">
13
+    <div>
14
+      <label class="block text-xs text-gray-600 mb-1">Search</label>
15
+      <input type="text" name="q" value="{{ q }}" placeholder="Username or email" class="border rounded px-3 py-2">
16
+    </div>
17
+    <div>
18
+      <label class="block text-xs text-gray-600 mb-1">Role</label>
19
+      <select name="role" class="border rounded px-3 py-2">
20
+        <option value="">All</option>
21
+        {% for val,label in role_choices %}
22
+          <option value="{{ val }}" {% if role == val %}selected{% endif %}>{{ label }}</option>
23
+        {% endfor %}
24
+      </select>
25
+    </div>
26
+    <div>
27
+      <button class="px-3 py-2 bg-blue-600 text-white rounded">Filter</button>
28
+      <a href="{% url 'orgs_admin:permissions_group_users' group.id %}" class="btn-outline">Reset</a>
29
+    </div>
30
+  </form>
31
+  {% if page_obj %}
32
+    <p class="text-sm text-gray-500 mt-2">Showing {{ page_obj.object_list|length }} of {{ page_obj.paginator.count }} users</p>
33
+  {% endif %}
34
+  
35
+</div>
36
+
37
+<div class="bg-white rounded shadow overflow-hidden">
38
+  <table class="min-w-full divide-y divide-gray-200">
39
+    <thead class="bg-gray-50">
40
+      <tr>
41
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Username</th>
42
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Email</th>
43
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Organization</th>
44
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Role</th>
45
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Actions</th>
46
+      </tr>
47
+    </thead>
48
+    <tbody class="divide-y divide-gray-200">
49
+      {% for u in users %}
50
+      <tr>
51
+        <td class="px-4 py-2">{{ u.username }}</td>
52
+        <td class="px-4 py-2 text-gray-600">{{ u.email|default:"-" }}</td>
53
+        <td class="px-4 py-2 text-gray-600">{{ u.recycle_profile.organization.name|default:"-" }}</td>
54
+        <td class="px-4 py-2 text-gray-600">{{ u.recycle_profile.role|title|default:"-" }}</td>
55
+        <td class="px-4 py-2">
56
+          <a href="{% url 'recycle_core:org_user_edit' u.id %}" class="btn-outline btn-xs">Edit</a>
57
+        </td>
58
+      </tr>
59
+      {% empty %}
60
+      <tr><td colspan="5" class="px-4 py-4 text-center text-gray-500">No users in this group.</td></tr>
61
+      {% endfor %}
62
+    </tbody>
63
+  </table>
64
+</div>
65
+{% include 'admin_frontend/_pagination.html' %}
66
+{% endblock %}

+ 40 - 0
orgs/templates/orgs/permissions_list.html

@@ -0,0 +1,40 @@
1
+{% extends "admin_frontend/base.html" %}
2
+{% load breadcrumbs %}
3
+{% block title %}Permissions{% 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">Permissions</h1>
8
+  <p class="text-gray-500">Manage role-based groups for this site.</p>
9
+  </div>
10
+
11
+<div class="bg-white rounded shadow overflow-hidden">
12
+  <table class="min-w-full divide-y divide-gray-200">
13
+    <thead class="bg-gray-50">
14
+      <tr>
15
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Group</th>
16
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Permissions</th>
17
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Users</th>
18
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Actions</th>
19
+      </tr>
20
+    </thead>
21
+    <tbody class="divide-y divide-gray-200">
22
+      {% for g in groups %}
23
+      <tr>
24
+        <td class="px-4 py-2 font-medium">{{ g.name|title }}</td>
25
+        <td class="px-4 py-2 text-gray-600">{{ g.permissions.count }} permissions</td>
26
+        <td class="px-4 py-2 text-gray-600">
27
+          {{ g.user_set.count }} users
28
+          <a href="{% url 'orgs_admin:permissions_group_users' g.id %}" class="text-blue-700 hover:underline text-xs ml-2">View</a>
29
+        </td>
30
+        <td class="px-4 py-2">
31
+          <a href="{% url 'orgs_admin:permissions_edit_group' g.id %}" class="btn-outline btn-xs">Edit</a>
32
+        </td>
33
+      </tr>
34
+      {% empty %}
35
+      <tr><td colspan="4" class="px-4 py-4 text-center text-gray-500">No groups.</td></tr>
36
+      {% endfor %}
37
+    </tbody>
38
+  </table>
39
+</div>
40
+{% endblock %}

+ 3 - 0
orgs/urls_admin.py

@@ -6,4 +6,7 @@ app_name = "orgs_admin"
6 6
 urlpatterns = [
7 7
     path("settings/", views.org_settings, name="org_settings"),
8 8
     path("sites/", views.org_sites, name="org_sites"),
9
+    path("permissions/", views.permissions_overview, name="permissions_overview"),
10
+    path("permissions/<int:pk>/", views.permissions_edit_group, name="permissions_edit_group"),
11
+    path("permissions/<int:pk>/users/", views.permissions_group_users, name="permissions_group_users"),
9 12
 ]

+ 153 - 1
orgs/views_admin.py

@@ -4,8 +4,12 @@ from django.shortcuts import render, redirect
4 4
 from django.contrib import messages
5 5
 from django.urls import reverse
6 6
 from django.views.decorators.http import require_POST
7
+from django.contrib.auth.models import Group, Permission
8
+from django.contrib.auth import get_user_model
9
+from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage
10
+from django.db import models
7 11
 
8
-from .models import Organization, OrganizationSite
12
+from .models import Organization, OrganizationSite, UserProfile
9 13
 from .forms import OrganizationForm
10 14
 
11 15
 from admin_frontend.templatetags.public_urls import public_route
@@ -69,3 +73,151 @@ def org_sites(request):
69 73
 
70 74
 
71 75
 ## ProvidedService management moved to recycle_core views
76
+
77
+
78
+# Permissions management ------------------------------------------------------
79
+
80
+
81
+ROLE_GROUPS = ["owner", "manager", "driver", "customer", "auditor"]
82
+APP_WHITELIST = {"recycle_core", "billing", "orgs"}
83
+
84
+
85
+@public_route(label="Permissions", order=14, icon="key")
86
+@breadcrumbs(label="Permissions", name="org_permissions")
87
+def permissions_overview(request):
88
+    if not request.user.is_authenticated or not _owner_required(request):
89
+        messages.error(request, "Only organization owners can manage permissions.")
90
+        return redirect("admin_frontend:dashboard")
91
+
92
+    groups = list(Group.objects.filter(name__in=ROLE_GROUPS).order_by("name"))
93
+    # Ensure missing role groups are visible
94
+    existing = {g.name for g in groups}
95
+    for name in ROLE_GROUPS:
96
+        if name not in existing:
97
+            g = Group.objects.create(name=name)
98
+            groups.append(g)
99
+    groups.sort(key=lambda g: ROLE_GROUPS.index(g.name) if g.name in ROLE_GROUPS else 999)
100
+
101
+    return render(request, "orgs/permissions_list.html", {"groups": groups})
102
+
103
+
104
+@breadcrumbs(label="Edit Group Permissions", parent="org_permissions")
105
+def permissions_edit_group(request, pk: int):
106
+    if not request.user.is_authenticated or not _owner_required(request):
107
+        messages.error(request, "Only organization owners can manage permissions.")
108
+        return redirect("admin_frontend:dashboard")
109
+
110
+    group = Group.objects.filter(pk=pk).first()
111
+    if not group:
112
+        messages.error(request, "Group not found.")
113
+        return redirect("orgs_admin:permissions_overview")
114
+
115
+    # Only allow editing for known role groups to avoid unexpected global changes
116
+    if group.name not in ROLE_GROUPS:
117
+        messages.error(request, "Only role groups can be edited here.")
118
+        return redirect("orgs_admin:permissions_overview")
119
+
120
+    perms_all = Permission.objects.select_related("content_type").all()
121
+    perms_all = [p for p in perms_all if p.content_type.app_label in APP_WHITELIST]
122
+
123
+    # Build structured grouping: app -> [ {model_key, model_label, perms:[...]}, ... ]
124
+    grouped_apps = {}
125
+
126
+    def perm_order_key(codename: str) -> tuple[int, str]:
127
+        # Standard Django perms first in this order, then custom alphabetically
128
+        prefix_order = {"view": 0, "add": 1, "change": 2, "delete": 3}
129
+        prefix = codename.split("_", 1)[0] if "_" in codename else codename
130
+        return (prefix_order.get(prefix, 9), codename)
131
+
132
+    def model_sort_key(app_label: str, model_key: str, model_label: str) -> tuple[int, str]:
133
+        if app_label == "billing":
134
+            pref = {"invoice": 0, "invoiceline": 1, "payment": 2, "payout": 3}
135
+            return (pref.get(model_key, 9), model_label)
136
+        return (0, model_label)
137
+
138
+    for p in perms_all:
139
+        app = p.content_type.app_label
140
+        model_key = p.content_type.model  # e.g., 'invoice', 'invoiceline'
141
+        # Use content_type.name for human label (e.g., 'invoice line')
142
+        model_label = p.content_type.name.title()
143
+        app_bucket = grouped_apps.setdefault(app, {})
144
+        bucket = app_bucket.setdefault(model_key, {"model_key": model_key, "model_label": model_label, "perms": []})
145
+        bucket["perms"].append(p)
146
+
147
+    # Convert inner dicts to sorted lists
148
+    for app, models_map in list(grouped_apps.items()):
149
+        models_list = list(models_map.values())
150
+        # Sort perms within model
151
+        for m in models_list:
152
+            m["perms"].sort(key=lambda pr: perm_order_key(pr.codename))
153
+        # Sort models by preference
154
+        models_list.sort(key=lambda m: model_sort_key(app, m["model_key"], m["model_label"]))
155
+        grouped_apps[app] = models_list
156
+
157
+    if request.method == "POST":
158
+        sel_ids = request.POST.getlist("perm_ids")
159
+        try:
160
+            sel_ids = [int(x) for x in sel_ids]
161
+        except Exception:
162
+            sel_ids = []
163
+        # Only set within whitelist
164
+        allowed_ids = {p.id for p in perms_all}
165
+        final_ids = [pid for pid in sel_ids if pid in allowed_ids]
166
+        group.permissions.set(Permission.objects.filter(id__in=final_ids))
167
+        messages.success(request, f"Permissions updated for group '{group.name}'.")
168
+        return redirect("orgs_admin:permissions_overview")
169
+
170
+    current_ids = set(group.permissions.values_list("id", flat=True))
171
+
172
+    return render(
173
+        request,
174
+        "orgs/permissions_edit.html",
175
+        {"group": group, "grouped_apps": grouped_apps, "current_ids": current_ids},
176
+    )
177
+
178
+
179
+@breadcrumbs(label="Group Users", parent="org_permissions")
180
+def permissions_group_users(request, pk: int):
181
+    if not request.user.is_authenticated or not _owner_required(request):
182
+        messages.error(request, "Only organization owners can view group users.")
183
+        return redirect("admin_frontend:dashboard")
184
+
185
+    group = Group.objects.filter(pk=pk).first()
186
+    if not group:
187
+        messages.error(request, "Group not found.")
188
+        return redirect("orgs_admin:permissions_overview")
189
+
190
+    User = get_user_model()
191
+    qs = User.objects.filter(groups=group).order_by("username")
192
+    # Scope to current organization if present
193
+    org = getattr(request, "org", None)
194
+    if org is not None:
195
+        qs = qs.filter(recycle_profile__organization=org)
196
+
197
+    # Filters
198
+    q = (request.GET.get("q") or "").strip()
199
+    role = (request.GET.get("role") or "").strip()
200
+    if q:
201
+        qs = qs.filter(models.Q(username__icontains=q) | models.Q(email__icontains=q))
202
+    if role:
203
+        qs = qs.filter(recycle_profile__role=role)
204
+
205
+    qs = qs.select_related("recycle_profile", "recycle_profile__organization")
206
+
207
+    # Pagination
208
+    paginator = Paginator(qs, 15)
209
+    page = request.GET.get("page")
210
+    try:
211
+        page_obj = paginator.page(page)
212
+    except PageNotAnInteger:
213
+        page_obj = paginator.page(1)
214
+    except EmptyPage:
215
+        page_obj = paginator.page(paginator.num_pages)
216
+
217
+    role_choices = list(UserProfile.ROLE_CHOICES)
218
+
219
+    return render(
220
+        request,
221
+        "orgs/permissions_group_users.html",
222
+        {"group": group, "users": page_obj.object_list, "page_obj": page_obj, "q": q, "role": role, "role_choices": role_choices},
223
+    )

+ 7 - 1
package-lock.json

@@ -10,7 +10,8 @@
10 10
       "license": "ISC",
11 11
       "dependencies": {
12 12
         "alpinejs": "^3.15.0",
13
-        "flowbite": "^3.1.2"
13
+        "flowbite": "^3.1.2",
14
+        "sortablejs": "^1.15.6"
14 15
       }
15 16
     },
16 17
     "node_modules/@popperjs/core": {
@@ -270,6 +271,11 @@
270 271
         "url": "https://github.com/sponsors/ljharb"
271 272
       }
272 273
     },
274
+    "node_modules/sortablejs": {
275
+      "version": "1.15.6",
276
+      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz",
277
+      "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A=="
278
+    },
273 279
     "node_modules/source-map-js": {
274 280
       "version": "1.2.1",
275 281
       "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

+ 2 - 1
package.json

@@ -15,6 +15,7 @@
15 15
   "license": "ISC",
16 16
   "dependencies": {
17 17
     "alpinejs": "^3.15.0",
18
-    "flowbite": "^3.1.2"
18
+    "flowbite": "^3.1.2",
19
+    "sortablejs": "^1.15.6"
19 20
   }
20 21
 }

+ 1 - 23
recycle_core/admin.py

@@ -104,29 +104,7 @@ class WeighLineAdmin(OrgScopedAdmin):
104 104
     search_fields = ("ticket__id", "material__name")
105 105
 
106 106
 
107
-@admin.register(models.Invoice)
108
-class InvoiceAdmin(OrgScopedAdmin):
109
-    list_display = ("id", "organization", "customer", "total_amount", "status", "issued_at")
110
-    list_filter = ("status",)
111
-    search_fields = ("id", "customer__name")
112
-
113
-
114
-@admin.register(models.InvoiceLine)
115
-class InvoiceLineAdmin(OrgScopedAdmin):
116
-    list_display = ("invoice", "description", "material", "quantity", "unit_price", "line_total")
117
-    search_fields = ("invoice__id", "description")
118
-
119
-
120
-@admin.register(models.Payment)
121
-class PaymentAdmin(OrgScopedAdmin):
122
-    list_display = ("invoice", "amount", "currency_code", "received_at", "reference")
123
-    search_fields = ("invoice__id", "reference")
124
-
125
-
126
-@admin.register(models.Payout)
127
-class PayoutAdmin(OrgScopedAdmin):
128
-    list_display = ("organization", "customer", "amount", "currency_code", "paid_at", "reference")
129
-    search_fields = ("customer__name", "reference")
107
+"""Billing models are registered in billing.admin."""
130 108
 
131 109
 
132 110
 @admin.register(models.Document)

+ 30 - 0
recycle_core/forms.py

@@ -144,6 +144,8 @@ class DocumentForm(forms.Form):
144 144
 class UserCreateForm(forms.Form):
145 145
     username = forms.CharField(max_length=150)
146 146
     email = forms.EmailField(required=False)
147
+    first_name = forms.CharField(max_length=150, required=False)
148
+    last_name = forms.CharField(max_length=150, required=False)
147 149
     role = forms.ChoiceField(choices=UserProfile.ROLE_CHOICES)
148 150
     password1 = forms.CharField(widget=forms.PasswordInput)
149 151
     password2 = forms.CharField(widget=forms.PasswordInput)
@@ -166,6 +168,8 @@ class UserCreateForm(forms.Form):
166 168
 
167 169
 class UserEditForm(forms.Form):
168 170
     email = forms.EmailField(required=False)
171
+    first_name = forms.CharField(max_length=150, required=False)
172
+    last_name = forms.CharField(max_length=150, required=False)
169 173
     role = forms.ChoiceField(choices=UserProfile.ROLE_CHOICES)
170 174
     password1 = forms.CharField(widget=forms.PasswordInput, required=False)
171 175
     password2 = forms.CharField(widget=forms.PasswordInput, required=False)
@@ -177,3 +181,29 @@ class UserEditForm(forms.Form):
177 181
         if (p1 or p2) and p1 != p2:
178 182
             self.add_error("password2", "Passwords do not match")
179 183
         return cleaned
184
+
185
+
186
+# Weighing -------------------------------------------------------------------
187
+
188
+
189
+class WeighTicketForm(forms.Form):
190
+    ticket_number = forms.CharField(max_length=64, required=False)
191
+    gross_weight = forms.DecimalField(max_digits=12, decimal_places=3)
192
+    tare_weight = forms.DecimalField(max_digits=12, decimal_places=3)
193
+    net_weight = forms.DecimalField(max_digits=12, decimal_places=3, required=False, help_text="Auto-calculated if left blank")
194
+    unit = forms.ChoiceField(choices=(), required=True)
195
+
196
+    def __init__(self, *args, **kwargs):
197
+        from .models import Material
198
+
199
+        super().__init__(*args, **kwargs)
200
+        self.fields["unit"].choices = Material.UNIT_CHOICES
201
+
202
+    def clean(self):
203
+        cleaned = super().clean()
204
+        gross = cleaned.get("gross_weight")
205
+        tare = cleaned.get("tare_weight")
206
+        net = cleaned.get("net_weight")
207
+        if gross is not None and tare is not None and net is None:
208
+            cleaned["net_weight"] = (Decimal(gross) - Decimal(tare)).quantize(Decimal("0.001"))
209
+        return cleaned

+ 4 - 24
recycle_core/management/commands/seed_ecoloop.py

@@ -15,6 +15,7 @@ from recycle_core.models import (
15 15
     PriceListItem,
16 16
     Customer,
17 17
     CustomerSite,
18
+    ServiceAgreement,
18 19
     PickupOrder,
19 20
     PickupItem,
20 21
     WeighTicket,
@@ -22,6 +23,8 @@ from recycle_core.models import (
22 23
     ScrapListing,
23 24
     ScrapListingItem,
24 25
     ScrapBid,
26
+    ScrapListingInvite,
27
+    ScrapAward,
25 28
 )
26 29
 from recycle_core.services.billing import generate_invoice_for_pickup
27 30
 
@@ -62,29 +65,7 @@ class Command(BaseCommand):
62 65
 
63 66
         # Optionally reset existing demo data (scoped to the selected orgs)
64 67
         if options.get("reset"):
65
-            from recycle_core.models import (
66
-                ScrapAward,
67
-                ScrapBid,
68
-                ScrapListingInvite,
69
-                ScrapListingItem,
70
-                ScrapListing,
71
-                WeighLine,
72
-                WeighTicket,
73
-                PickupItem,
74
-                PickupOrder,
75
-                InvoiceLine,
76
-                Invoice,
77
-                Payment,
78
-                Payout,
79
-                ServiceAgreement,
80
-                CustomerSite,
81
-                Customer,
82
-                PriceListItem,
83
-                PriceList,
84
-                Material,
85
-                MaterialCategory,
86
-                ProvidedService,
87
-            )
68
+            from billing.models import Invoice, InvoiceLine, Payment, Payout
88 69
 
89 70
             def _wipe_for(o: Organization):
90 71
                 # Marketplace
@@ -268,7 +249,6 @@ class Command(BaseCommand):
268 249
         ScrapListingItem.objects.create(listing=listing, material=pet, quantity_estimate=Decimal("100.0"), unit=Material.UNIT_KG)
269 250
         ScrapListingItem.objects.create(listing=listing, material=can, quantity_estimate=Decimal("50.0"), unit=Material.UNIT_KG)
270 251
         # Invite-only demo: invite bidder_org then place bid
271
-        from recycle_core.models import ScrapListingInvite
272 252
         ScrapListingInvite.objects.get_or_create(listing=listing, invited_org=bidder_org, invited_user=buyer)
273 253
         ScrapBid.objects.create(listing=listing, bidder_org=bidder_org, bidder_user=buyer, price_total=Decimal("550.00"), message="Ready to collect within 48h")
274 254
 

+ 31 - 80
recycle_core/migrations/0001_initial.py

@@ -1,9 +1,10 @@
1
-# Generated by Django 4.2.24 on 2025-09-21 09:39
1
+# Generated by Django 4.2.24 on 2025-09-24 06:05
2 2
 
3 3
 from django.conf import settings
4 4
 from django.db import migrations, models
5 5
 import django.db.models.deletion
6 6
 import django.utils.timezone
7
+import markdownfield.models
7 8
 
8 9
 
9 10
 class Migration(migrations.Migration):
@@ -11,9 +12,9 @@ class Migration(migrations.Migration):
11 12
     initial = True
12 13
 
13 14
     dependencies = [
14
-        ('contenttypes', '0002_remove_content_type_name'),
15 15
         migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16
-        ('orgs', '__first__'),
16
+        ('orgs', '0006_delete_providedservice'),
17
+        ('contenttypes', '0002_remove_content_type_name'),
17 18
     ]
18 19
 
19 20
     operations = [
@@ -51,32 +52,16 @@ class Migration(migrations.Migration):
51 52
             },
52 53
         ),
53 54
         migrations.CreateModel(
54
-            name='Invoice',
55
-            fields=[
56
-                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
57
-                ('created_at', models.DateTimeField(auto_now_add=True)),
58
-                ('updated_at', models.DateTimeField(auto_now=True)),
59
-                ('currency_code', models.CharField(default='USD', max_length=8)),
60
-                ('total_amount', models.DecimalField(decimal_places=2, default=0, max_digits=14)),
61
-                ('status', models.CharField(choices=[('draft', 'Draft'), ('issued', 'Issued'), ('paid', 'Paid'), ('void', 'Void')], db_index=True, default='draft', max_length=8)),
62
-                ('issued_at', models.DateTimeField(blank=True, null=True)),
63
-                ('due_at', models.DateTimeField(blank=True, null=True)),
64
-                ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoices', to='recycle_core.customer')),
65
-                ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='orgs.organization')),
66
-            ],
67
-            options={
68
-                'abstract': False,
69
-            },
70
-        ),
71
-        migrations.CreateModel(
72 55
             name='Material',
73 56
             fields=[
74 57
                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
75 58
                 ('created_at', models.DateTimeField(auto_now_add=True)),
76 59
                 ('updated_at', models.DateTimeField(auto_now=True)),
60
+                ('category', models.CharField(choices=[('Plastics', 'Plastics'), ('Metals', 'Metals'), ('Paper', 'Paper'), ('Glass', 'Glass'), ('Electronics', 'Electronics'), ('Wood', 'Wood'), ('Rubber', 'Rubber'), ('Textiles', 'Textiles'), ('Organic', 'Organic'), ('Mixed', 'Mixed')], max_length=64)),
77 61
                 ('name', models.CharField(max_length=255)),
78 62
                 ('code', models.CharField(blank=True, max_length=64)),
79 63
                 ('default_unit', models.CharField(choices=[('kg', 'Kilogram'), ('lb', 'Pound'), ('pcs', 'Pieces')], default='kg', max_length=8)),
64
+                ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='materials', to='orgs.organization')),
80 65
             ],
81 66
         ),
82 67
         migrations.CreateModel(
@@ -235,6 +220,24 @@ class Migration(migrations.Migration):
235 220
             },
236 221
         ),
237 222
         migrations.CreateModel(
223
+            name='ProvidedService',
224
+            fields=[
225
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
226
+                ('created_at', models.DateTimeField(auto_now_add=True)),
227
+                ('updated_at', models.DateTimeField(auto_now=True)),
228
+                ('title', models.CharField(choices=[('Pickup & Logistics', 'Pickup & Logistics'), ('Material Sorting', 'Material Sorting'), ('Weighing & Ticketing', 'Weighing & Ticketing'), ('Invoicing & Payouts', 'Invoicing & Payouts'), ('Reporting & Analytics', 'Reporting & Analytics'), ('Marketplace & Bidding', 'Marketplace & Bidding'), ('Compliance & Audits', 'Compliance & Audits'), ('Consulting & Training', 'Consulting & Training')], max_length=100)),
229
+                ('description', models.TextField()),
230
+                ('body', markdownfield.models.MarkdownField(blank=True, null=True, rendered_field='body_html')),
231
+                ('body_html', markdownfield.models.RenderedMarkdownField(null=True)),
232
+                ('display_order', models.PositiveIntegerField(default=0)),
233
+                ('is_enabled', models.BooleanField(default=True)),
234
+                ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services', to='orgs.organization')),
235
+            ],
236
+            options={
237
+                'ordering': ['display_order'],
238
+            },
239
+        ),
240
+        migrations.CreateModel(
238 241
             name='PriceListItem',
239 242
             fields=[
240 243
                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
@@ -263,37 +266,18 @@ class Migration(migrations.Migration):
263 266
             },
264 267
         ),
265 268
         migrations.CreateModel(
266
-            name='Payout',
269
+            name='MaterialImage',
267 270
             fields=[
268 271
                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
269 272
                 ('created_at', models.DateTimeField(auto_now_add=True)),
270 273
                 ('updated_at', models.DateTimeField(auto_now=True)),
271
-                ('amount', models.DecimalField(decimal_places=2, max_digits=14)),
272
-                ('currency_code', models.CharField(default='USD', max_length=8)),
273
-                ('paid_at', models.DateTimeField(default=django.utils.timezone.now)),
274
-                ('reference', models.CharField(blank=True, max_length=128)),
275
-                ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payouts', to='recycle_core.customer')),
276
-                ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payouts', to='orgs.organization')),
277
-                ('pickup', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='payouts', to='recycle_core.pickuporder')),
274
+                ('image', models.ImageField(upload_to='materials/%Y/%m/')),
275
+                ('caption', models.CharField(blank=True, max_length=255)),
276
+                ('display_order', models.PositiveIntegerField(default=0)),
277
+                ('material', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='recycle_core.material')),
278 278
             ],
279 279
             options={
280
-                'abstract': False,
281
-            },
282
-        ),
283
-        migrations.CreateModel(
284
-            name='Payment',
285
-            fields=[
286
-                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
287
-                ('created_at', models.DateTimeField(auto_now_add=True)),
288
-                ('updated_at', models.DateTimeField(auto_now=True)),
289
-                ('amount', models.DecimalField(decimal_places=2, max_digits=14)),
290
-                ('currency_code', models.CharField(default='USD', max_length=8)),
291
-                ('received_at', models.DateTimeField(default=django.utils.timezone.now)),
292
-                ('reference', models.CharField(blank=True, max_length=128)),
293
-                ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='recycle_core.invoice')),
294
-            ],
295
-            options={
296
-                'abstract': False,
280
+                'ordering': ['display_order', 'id'],
297 281
             },
298 282
         ),
299 283
         migrations.CreateModel(
@@ -302,43 +286,10 @@ class Migration(migrations.Migration):
302 286
                 ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
303 287
                 ('created_at', models.DateTimeField(auto_now_add=True)),
304 288
                 ('updated_at', models.DateTimeField(auto_now=True)),
305
-                ('name', models.CharField(max_length=255)),
289
+                ('name', models.CharField(choices=[('Plastics', 'Plastics'), ('Metals', 'Metals'), ('Paper', 'Paper'), ('Glass', 'Glass'), ('Electronics', 'Electronics'), ('Wood', 'Wood'), ('Rubber', 'Rubber'), ('Textiles', 'Textiles'), ('Organic', 'Organic'), ('Mixed', 'Mixed')], max_length=255)),
306 290
                 ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='material_categories', to='orgs.organization')),
307 291
             ],
308 292
         ),
309
-        migrations.AddField(
310
-            model_name='material',
311
-            name='category',
312
-            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='materials', to='recycle_core.materialcategory'),
313
-        ),
314
-        migrations.AddField(
315
-            model_name='material',
316
-            name='organization',
317
-            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='materials', to='orgs.organization'),
318
-        ),
319
-        migrations.CreateModel(
320
-            name='InvoiceLine',
321
-            fields=[
322
-                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
323
-                ('created_at', models.DateTimeField(auto_now_add=True)),
324
-                ('updated_at', models.DateTimeField(auto_now=True)),
325
-                ('description', models.CharField(max_length=255)),
326
-                ('quantity', models.DecimalField(decimal_places=3, max_digits=12)),
327
-                ('unit', models.CharField(choices=[('kg', 'Kilogram'), ('lb', 'Pound'), ('pcs', 'Pieces')], default='kg', max_length=8)),
328
-                ('unit_price', models.DecimalField(decimal_places=2, max_digits=12)),
329
-                ('line_total', models.DecimalField(decimal_places=2, max_digits=14)),
330
-                ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='recycle_core.invoice')),
331
-                ('material', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='recycle_core.material')),
332
-            ],
333
-            options={
334
-                'abstract': False,
335
-            },
336
-        ),
337
-        migrations.AddField(
338
-            model_name='invoice',
339
-            name='pickup',
340
-            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invoices', to='recycle_core.pickuporder'),
341
-        ),
342 293
         migrations.CreateModel(
343 294
             name='Document',
344 295
             fields=[

+ 25 - 0
recycle_core/migrations/0002_alter_pickuporder_options_alter_scraplisting_options_and_more.py

@@ -0,0 +1,25 @@
1
+# Generated by Django 4.2.24 on 2025-09-24 14:44
2
+
3
+from django.db import migrations
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('recycle_core', '0001_initial'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.AlterModelOptions(
14
+            name='pickuporder',
15
+            options={'permissions': (('assign_driver', 'Can assign driver to pickup'), ('set_pickup_status', 'Can set pickup status'), ('create_weigh_ticket', 'Can create weigh ticket for pickup'), ('generate_invoice', 'Can generate invoice for pickup'))},
16
+        ),
17
+        migrations.AlterModelOptions(
18
+            name='scraplisting',
19
+            options={'permissions': (('open_listing', 'Can open scrap listing'), ('close_listing', 'Can close scrap listing'), ('award_listing', 'Can award scrap listing'))},
20
+        ),
21
+        migrations.AlterModelOptions(
22
+            name='weighticket',
23
+            options={'permissions': (('generate_invoice', 'Can generate invoice from weigh ticket'),)},
24
+        ),
25
+    ]

+ 0 - 30
recycle_core/migrations/0002_providedservice.py

@@ -1,30 +0,0 @@
1
-# Generated by Django 4.2.24 on 2025-09-22 08:26
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
-        ('orgs', '0006_delete_providedservice'),
11
-        ('recycle_core', '0001_initial'),
12
-    ]
13
-
14
-    operations = [
15
-        migrations.CreateModel(
16
-            name='ProvidedService',
17
-            fields=[
18
-                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19
-                ('created_at', models.DateTimeField(auto_now_add=True)),
20
-                ('updated_at', models.DateTimeField(auto_now=True)),
21
-                ('title', models.CharField(choices=[('Pickup & Logistics', 'Pickup & Logistics'), ('Material Sorting', 'Material Sorting'), ('Weighing & Ticketing', 'Weighing & Ticketing'), ('Invoicing & Payouts', 'Invoicing & Payouts'), ('Reporting & Analytics', 'Reporting & Analytics'), ('Marketplace & Bidding', 'Marketplace & Bidding'), ('Compliance & Audits', 'Compliance & Audits'), ('Consulting & Training', 'Consulting & Training')], max_length=100)),
22
-                ('description', models.TextField()),
23
-                ('display_order', models.PositiveIntegerField(default=0)),
24
-                ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services', to='orgs.organization')),
25
-            ],
26
-            options={
27
-                'ordering': ['display_order'],
28
-            },
29
-        ),
30
-    ]

+ 0 - 24
recycle_core/migrations/0003_providedservice_description_html_and_more.py

@@ -1,24 +0,0 @@
1
-# Generated by Django 4.2.24 on 2025-09-22 08:30
2
-
3
-from django.db import migrations
4
-import markdownfield.models
5
-
6
-
7
-class Migration(migrations.Migration):
8
-
9
-    dependencies = [
10
-        ('recycle_core', '0002_providedservice'),
11
-    ]
12
-
13
-    operations = [
14
-        migrations.AddField(
15
-            model_name='providedservice',
16
-            name='description_html',
17
-            field=markdownfield.models.RenderedMarkdownField(null=True),
18
-        ),
19
-        migrations.AlterField(
20
-            model_name='providedservice',
21
-            name='description',
22
-            field=markdownfield.models.MarkdownField(rendered_field='description_html'),
23
-        ),
24
-    ]

+ 0 - 29
recycle_core/migrations/0004_rename_description_html_providedservice_body_html_and_more.py

@@ -1,29 +0,0 @@
1
-# Generated by Django 4.2.24 on 2025-09-22 08:34
2
-
3
-from django.db import migrations, models
4
-import markdownfield.models
5
-
6
-
7
-class Migration(migrations.Migration):
8
-
9
-    dependencies = [
10
-        ('recycle_core', '0003_providedservice_description_html_and_more'),
11
-    ]
12
-
13
-    operations = [
14
-        migrations.RenameField(
15
-            model_name='providedservice',
16
-            old_name='description_html',
17
-            new_name='body_html',
18
-        ),
19
-        migrations.AddField(
20
-            model_name='providedservice',
21
-            name='body',
22
-            field=markdownfield.models.MarkdownField(blank=True, null=True, rendered_field='body_html'),
23
-        ),
24
-        migrations.AlterField(
25
-            model_name='providedservice',
26
-            name='description',
27
-            field=models.TextField(),
28
-        ),
29
-    ]

+ 0 - 18
recycle_core/migrations/0005_providedservice_is_enabled.py

@@ -1,18 +0,0 @@
1
-# Generated by Django 4.2.24 on 2025-09-22 08:43
2
-
3
-from django.db import migrations, models
4
-
5
-
6
-class Migration(migrations.Migration):
7
-
8
-    dependencies = [
9
-        ('recycle_core', '0004_rename_description_html_providedservice_body_html_and_more'),
10
-    ]
11
-
12
-    operations = [
13
-        migrations.AddField(
14
-            model_name='providedservice',
15
-            name='is_enabled',
16
-            field=models.BooleanField(default=True),
17
-        ),
18
-    ]

+ 0 - 18
recycle_core/migrations/0006_alter_materialcategory_name.py

@@ -1,18 +0,0 @@
1
-# Generated by Django 4.2.24 on 2025-09-22 09:17
2
-
3
-from django.db import migrations, models
4
-
5
-
6
-class Migration(migrations.Migration):
7
-
8
-    dependencies = [
9
-        ('recycle_core', '0005_providedservice_is_enabled'),
10
-    ]
11
-
12
-    operations = [
13
-        migrations.AlterField(
14
-            model_name='materialcategory',
15
-            name='name',
16
-            field=models.CharField(choices=[('Plastics', 'Plastics'), ('Metals', 'Metals'), ('Paper', 'Paper'), ('Glass', 'Glass'), ('Electronics', 'Electronics'), ('Wood', 'Wood'), ('Rubber', 'Rubber'), ('Textiles', 'Textiles'), ('Organic', 'Organic'), ('Mixed', 'Mixed')], max_length=255),
17
-        ),
18
-    ]

+ 0 - 18
recycle_core/migrations/0007_alter_material_category.py

@@ -1,18 +0,0 @@
1
-# Generated by Django 4.2.24 on 2025-09-22 09:23
2
-
3
-from django.db import migrations, models
4
-
5
-
6
-class Migration(migrations.Migration):
7
-
8
-    dependencies = [
9
-        ('recycle_core', '0006_alter_materialcategory_name'),
10
-    ]
11
-
12
-    operations = [
13
-        migrations.AlterField(
14
-            model_name='material',
15
-            name='category',
16
-            field=models.CharField(choices=[('Plastics', 'Plastics'), ('Metals', 'Metals'), ('Paper', 'Paper'), ('Glass', 'Glass'), ('Electronics', 'Electronics'), ('Wood', 'Wood'), ('Rubber', 'Rubber'), ('Textiles', 'Textiles'), ('Organic', 'Organic'), ('Mixed', 'Mixed')], max_length=64),
17
-        ),
18
-    ]

+ 0 - 29
recycle_core/migrations/0008_materialimage.py

@@ -1,29 +0,0 @@
1
-# Generated by Django 4.2.24 on 2025-09-22 09:28
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', '0007_alter_material_category'),
11
-    ]
12
-
13
-    operations = [
14
-        migrations.CreateModel(
15
-            name='MaterialImage',
16
-            fields=[
17
-                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18
-                ('created_at', models.DateTimeField(auto_now_add=True)),
19
-                ('updated_at', models.DateTimeField(auto_now=True)),
20
-                ('image', models.ImageField(upload_to='materials/%Y/%m/')),
21
-                ('caption', models.CharField(blank=True, max_length=255)),
22
-                ('display_order', models.PositiveIntegerField(default=0)),
23
-                ('material', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='recycle_core.material')),
24
-            ],
25
-            options={
26
-                'ordering': ['display_order', 'id'],
27
-            },
28
-        ),
29
-    ]

+ 21 - 48
recycle_core/models.py

@@ -226,6 +226,14 @@ class PickupOrder(TimestampedModel):
226 226
     def __str__(self):
227 227
         return f"Pickup #{self.id} - {self.customer.name}"
228 228
 
229
+    class Meta:
230
+        permissions = (
231
+            ("assign_driver", "Can assign driver to pickup"),
232
+            ("set_pickup_status", "Can set pickup status"),
233
+            ("create_weigh_ticket", "Can create weigh ticket for pickup"),
234
+            ("generate_invoice", "Can generate invoice for pickup"),
235
+        )
236
+
229 237
 
230 238
 class PickupItem(TimestampedModel):
231 239
     pickup = models.ForeignKey(PickupOrder, on_delete=models.CASCADE, related_name="items")
@@ -244,6 +252,11 @@ class WeighTicket(TimestampedModel):
244 252
     recorded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="weigh_tickets")
245 253
     recorded_at = models.DateTimeField(default=timezone.now)
246 254
 
255
+    class Meta:
256
+        permissions = (
257
+            ("generate_invoice", "Can generate invoice from weigh ticket"),
258
+        )
259
+
247 260
 
248 261
 class WeighLine(TimestampedModel):
249 262
     ticket = models.ForeignKey(WeighTicket, on_delete=models.CASCADE, related_name="lines")
@@ -252,54 +265,7 @@ class WeighLine(TimestampedModel):
252 265
     unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
253 266
 
254 267
 
255
-class Invoice(TimestampedModel):
256
-    STATUS_DRAFT = "draft"
257
-    STATUS_ISSUED = "issued"
258
-    STATUS_PAID = "paid"
259
-    STATUS_VOID = "void"
260
-    STATUS_CHOICES = (
261
-        (STATUS_DRAFT, "Draft"),
262
-        (STATUS_ISSUED, "Issued"),
263
-        (STATUS_PAID, "Paid"),
264
-        (STATUS_VOID, "Void"),
265
-    )
266
-
267
-    organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="invoices")
268
-    customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name="invoices")
269
-    pickup = models.ForeignKey(PickupOrder, on_delete=models.SET_NULL, null=True, blank=True, related_name="invoices")
270
-    currency_code = models.CharField(max_length=8, default="USD")
271
-    total_amount = models.DecimalField(max_digits=14, decimal_places=2, default=0)
272
-    status = models.CharField(max_length=8, choices=STATUS_CHOICES, default=STATUS_DRAFT, db_index=True)
273
-    issued_at = models.DateTimeField(null=True, blank=True)
274
-    due_at = models.DateTimeField(null=True, blank=True)
275
-
276
-
277
-class InvoiceLine(TimestampedModel):
278
-    invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name="lines")
279
-    description = models.CharField(max_length=255)
280
-    material = models.ForeignKey(Material, on_delete=models.SET_NULL, null=True, blank=True)
281
-    quantity = models.DecimalField(max_digits=12, decimal_places=3)
282
-    unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
283
-    unit_price = models.DecimalField(max_digits=12, decimal_places=2)
284
-    line_total = models.DecimalField(max_digits=14, decimal_places=2)
285
-
286
-
287
-class Payment(TimestampedModel):
288
-    invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name="payments")
289
-    amount = models.DecimalField(max_digits=14, decimal_places=2)
290
-    currency_code = models.CharField(max_length=8, default="USD")
291
-    received_at = models.DateTimeField(default=timezone.now)
292
-    reference = models.CharField(max_length=128, blank=True)
293
-
294
-
295
-class Payout(TimestampedModel):
296
-    organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="payouts")
297
-    customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name="payouts")
298
-    pickup = models.ForeignKey(PickupOrder, on_delete=models.SET_NULL, null=True, blank=True, related_name="payouts")
299
-    amount = models.DecimalField(max_digits=14, decimal_places=2)
300
-    currency_code = models.CharField(max_length=8, default="USD")
301
-    paid_at = models.DateTimeField(default=timezone.now)
302
-    reference = models.CharField(max_length=128, blank=True)
268
+# Billing models have moved to the `billing` app
303 269
 
304 270
 
305 271
 class Document(TimestampedModel):
@@ -364,6 +330,13 @@ class ScrapListing(TimestampedModel):
364 330
     def __str__(self):
365 331
         return f"Listing #{self.id} - {self.title}"
366 332
 
333
+    class Meta:
334
+        permissions = (
335
+            ("open_listing", "Can open scrap listing"),
336
+            ("close_listing", "Can close scrap listing"),
337
+            ("award_listing", "Can award scrap listing"),
338
+        )
339
+
367 340
 
368 341
 class ScrapListingItem(TimestampedModel):
369 342
     listing = models.ForeignKey(ScrapListing, on_delete=models.CASCADE, related_name="items")

+ 1 - 4
recycle_core/serializers.py

@@ -16,10 +16,6 @@ from .models import (
16 16
     PickupItem,
17 17
     WeighTicket,
18 18
     WeighLine,
19
-    Invoice,
20
-    InvoiceLine,
21
-    Payment,
22
-    Payout,
23 19
     ScrapListing,
24 20
     ScrapListingItem,
25 21
     ScrapBid,
@@ -28,6 +24,7 @@ from .models import (
28 24
     Document,
29 25
     AuditLog,
30 26
 )
27
+from billing.models import Invoice, InvoiceLine, Payment, Payout
31 28
 
32 29
 
33 30
 User = get_user_model()

+ 1 - 4
recycle_core/services/billing.py

@@ -10,10 +10,8 @@ from ..models import (
10 10
     PriceList,
11 11
     PriceListItem,
12 12
     WeighTicket,
13
-    Invoice,
14
-    InvoiceLine,
15
-    Payout,
16 13
 )
14
+from billing.models import Invoice, InvoiceLine, Payout
17 15
 
18 16
 
19 17
 def _get_price(price_list: PriceList, material_id: int, unit: str, direction: str) -> Optional[Decimal]:
@@ -96,4 +94,3 @@ def generate_invoice_for_pickup(pickup: PickupOrder) -> Invoice:
96 94
     pickup.save(update_fields=["status"]) 
97 95
 
98 96
     return inv
99
-

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

@@ -12,7 +12,7 @@
12 12
     {{ filter.form|crispy }}
13 13
     <div class="flex items-center gap-2">
14 14
       <button class="px-3 py-2 bg-blue-600 text-white rounded">Filter</button>
15
-      <a href="{% url 'recycle_core:invoices_list' %}" class="btn-outline">Reset</a>
15
+      <a href="{% url 'billing:invoices_list' %}" class="btn-outline">Reset</a>
16 16
     </div>
17 17
   </form>
18 18
   {% if filter.qs.count != page_obj.paginator.count %}
@@ -47,7 +47,7 @@
47 47
             <details class="relative">
48 48
               <summary class="cursor-pointer px-3 py-2 border rounded text-gray-700 hover:bg-gray-50">Add Payment</summary>
49 49
               <div class="absolute right-0 mt-2 bg-white shadow rounded p-4 w-80" style="z-index: 1000;">
50
-                <form method="post" action="{% url 'recycle_core:invoice_add_payment' inv.id %}" class="space-y-3">
50
+                <form method="post" action="{% url 'billing:invoice_add_payment' inv.id %}" class="space-y-3">
51 51
                   {% csrf_token %}
52 52
                   {{ pay_form|crispy }}
53 53
                   <div class="flex justify-end">

+ 68 - 3
recycle_core/templates/recycle_core/material_form.html

@@ -9,11 +9,22 @@
9 9
   {% if item %}
10 10
   {% with imgs=item.images.all %}
11 11
   {% if imgs %}
12
-  <div class="mb-4">
12
+  <div class="mb-4"
13
+       x-data="imagesSorter()">
13 14
     <h2 class="text-lg font-medium mb-2">Existing Images</h2>
14
-    <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
15
+    <div class="text-xs text-gray-500 mb-2 flex items-center gap-3">
16
+      <span>Drag the handle to reorder. Saves automatically.</span>
17
+      <template x-if="saving"><span class="text-blue-600">Saving…</span></template>
18
+      <template x-if="saved"><span class="text-green-600">Saved.</span></template>
19
+      <template x-if="error"><span class="text-red-600" x-text="error"></span></template>
20
+    </div>
21
+    <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3" x-ref="grid">
15 22
       {% for im in imgs %}
16
-        <div class="border rounded p-2 bg-white flex flex-col items-center gap-2">
23
+        <div class="border rounded p-2 bg-white flex flex-col items-center gap-2" data-id="{{ im.id }}">
24
+          <div class="w-full flex items-center justify-between text-xs text-gray-500">
25
+            <span class="drag-handle cursor-grab" title="Drag to reorder">⋮⋮</span>
26
+            <span class="order-index">{{ im.display_order|add:1 }}</span>
27
+          </div>
17 28
           <img src="{{ im.image.url }}" alt="{{ im.caption|default:'Material image' }}" class="w-28 h-28 object-cover rounded" />
18 29
           {% if im.caption %}
19 30
             <div class="text-xs text-gray-600 text-center">{{ im.caption }}</div>
@@ -21,7 +32,61 @@
21 32
         </div>
22 33
       {% endfor %}
23 34
     </div>
35
+    <style>.drag-handle{font-weight:700;line-height:1}</style>
24 36
   </div>
37
+  <script>
38
+    function imagesSorter(){
39
+      return {
40
+        saving: false,
41
+        saved: false,
42
+        error: '',
43
+        init(){
44
+          const wait = () => {
45
+            if (!window.Sortable) { setTimeout(wait, 50); return; }
46
+            new Sortable(this.$refs.grid, {
47
+              handle: '.drag-handle',
48
+              animation: 150,
49
+              ghostClass: 'bg-yellow-50',
50
+              onEnd: () => this.persistOrder(),
51
+            });
52
+          };
53
+          wait();
54
+        },
55
+        persistOrder(){
56
+          this.saving = true; this.saved = false; this.error = '';
57
+          const ids = Array.from(this.$refs.grid.children).map(el => parseInt(el.dataset.id));
58
+          // Update visible order indices (1-based)
59
+          Array.from(this.$refs.grid.children).forEach((el, idx) => {
60
+            const label = el.querySelector('.order-index');
61
+            if (label) label.textContent = idx + 1;
62
+          });
63
+          fetch("{% url 'recycle_core:material_images_reorder' item.pk %}", {
64
+            method: 'POST',
65
+            headers: {
66
+              'Content-Type': 'application/json',
67
+              'X-CSRFToken': getCookie('csrftoken'),
68
+            },
69
+            body: JSON.stringify({ ids })
70
+          }).then(r => {
71
+            if(!r.ok) throw new Error('Save failed');
72
+            return r.json();
73
+          }).then(() => {
74
+            this.saved = true;
75
+          }).catch(err => {
76
+            this.error = err.message || 'Error saving order';
77
+          }).finally(() => {
78
+            this.saving = false;
79
+            setTimeout(() => { this.saved = false }, 1500);
80
+          });
81
+        }
82
+      }
83
+    }
84
+    function getCookie(name) {
85
+      const value = `; ${document.cookie}`;
86
+      const parts = value.split(`; ${name}=`);
87
+      if (parts.length === 2) return parts.pop().split(';').shift();
88
+    }
89
+  </script>
25 90
   {% endif %}
26 91
   {% endwith %}
27 92
   {% endif %}

+ 2 - 2
recycle_core/templates/recycle_core/payouts_list.html

@@ -12,8 +12,8 @@
12 12
     {{ filter.form|crispy }}
13 13
     <div class="flex items-center gap-2">
14 14
       <button class="px-3 py-2 bg-blue-600 text-white rounded">Filter</button>
15
-      <a href="{% url 'recycle_core:payouts_list' %}" class="btn-outline">Reset</a>
16
-      <a href="{% url 'recycle_core:payouts_export_csv' %}?{{ request.GET.urlencode }}" class="btn-outline">Export CSV</a>
15
+      <a href="{% url 'billing:payouts_list' %}" class="btn-outline">Reset</a>
16
+      <a href="{% url 'billing:payouts_export_csv' %}?{{ request.GET.urlencode }}" class="btn-outline">Export CSV</a>
17 17
     </div>
18 18
   </form>
19 19
   {% if filter.qs.count != page_obj.paginator.count %}

+ 107 - 0
recycle_core/templates/recycle_core/pickup_detail.html

@@ -0,0 +1,107 @@
1
+{% extends "admin_frontend/base.html" %}
2
+{% load breadcrumbs %}
3
+{% block title %}Pickup #{{ pickup.id }}{% endblock %}
4
+{% block content %}
5
+{% render_breadcrumbs breadcrumbs %}
6
+
7
+<div class="flex items-center justify-between mb-4">
8
+  <h1 class="text-2xl font-semibold">Pickup #{{ pickup.id }}</h1>
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 %}
13
+      <form method="post" action="{% url 'recycle_core:pickup_generate_invoice' pickup.id %}">
14
+        {% csrf_token %}
15
+        <button class="px-3 py-2 rounded bg-blue-600 text-white">Generate Invoice</button>
16
+      </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
+    {% endif %}
23
+  </div>
24
+  
25
+</div>
26
+
27
+<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
28
+  <div class="bg-white rounded shadow p-4">
29
+    <h2 class="font-medium mb-2">Details</h2>
30
+    <dl class="grid grid-cols-3 gap-2 text-sm">
31
+      <dt class="text-gray-500">Customer</dt><dd class="col-span-2">{{ pickup.customer.name }}</dd>
32
+      <dt class="text-gray-500">Site</dt><dd class="col-span-2">{{ pickup.site.name }}</dd>
33
+      <dt class="text-gray-500">Status</dt><dd class="col-span-2">{{ pickup.status }}</dd>
34
+      <dt class="text-gray-500">Scheduled</dt><dd class="col-span-2">{{ pickup.scheduled_at|default:"-" }}</dd>
35
+      <dt class="text-gray-500">Driver</dt><dd class="col-span-2">{{ pickup.assigned_driver.username|default:"-" }}</dd>
36
+      <dt class="text-gray-500">Notes</dt><dd class="col-span-2 whitespace-pre-wrap">{{ pickup.notes|default:"-" }}</dd>
37
+    </dl>
38
+  </div>
39
+  <div class="bg-white rounded shadow p-4">
40
+    <h2 class="font-medium mb-2">Items</h2>
41
+    <table class="min-w-full">
42
+      <thead>
43
+        <tr class="text-left text-sm text-gray-600">
44
+          <th class="px-2 py-1">Material</th>
45
+          <th class="px-2 py-1">Est. Qty</th>
46
+          <th class="px-2 py-1">Unit</th>
47
+        </tr>
48
+      </thead>
49
+      <tbody class="divide-y">
50
+        {% for it in items %}
51
+          <tr>
52
+            <td class="px-2 py-1">{{ it.material.name }}</td>
53
+            <td class="px-2 py-1">{{ it.estimated_qty|default:"-" }}</td>
54
+            <td class="px-2 py-1">{{ it.unit }}</td>
55
+          </tr>
56
+        {% empty %}
57
+          <tr>
58
+            <td colspan="3" class="px-2 py-2 text-gray-500">No items.</td>
59
+          </tr>
60
+        {% endfor %}
61
+      </tbody>
62
+    </table>
63
+  </div>
64
+</div>
65
+
66
+<div class="mt-6 bg-white rounded shadow p-4">
67
+  <h2 class="font-medium mb-2">Weigh Tickets</h2>
68
+  <table class="min-w-full">
69
+    <thead>
70
+      <tr class="text-left text-sm text-gray-600">
71
+        <th class="px-2 py-1">Ticket</th>
72
+        <th class="px-2 py-1">Recorded</th>
73
+        <th class="px-2 py-1">Net</th>
74
+        <th class="px-2 py-1">Actions</th>
75
+      </tr>
76
+    </thead>
77
+    <tbody class="divide-y">
78
+      {% if pickup.weigh_ticket %}
79
+        <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>
83
+          <td class="px-2 py-1">
84
+            {% if perms.recycle_core.generate_invoice %}
85
+            <form method="post" action="{% url 'recycle_core:weigh_ticket_generate_invoice' pickup.weigh_ticket.id %}">
86
+              {% csrf_token %}
87
+              <button class="btn-outline btn-xs">Generate Invoice</button>
88
+            </form>
89
+            {% endif %}
90
+          </td>
91
+        </tr>
92
+      {% else %}
93
+        <tr>
94
+          <td colspan="4" class="px-2 py-2 text-gray-500">No weigh tickets yet.</td>
95
+        </tr>
96
+      {% endif %}
97
+    </tbody>
98
+  </table>
99
+
100
+  {% if not pickup.weigh_ticket %}
101
+    <div class="mt-3">
102
+      <a class="btn" href="{% url 'recycle_core:pickup_create_weigh_ticket' pickup.id %}">Create Weigh Ticket</a>
103
+    </div>
104
+  {% endif %}
105
+</div>
106
+
107
+{% endblock %}

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

@@ -42,21 +42,33 @@
42 42
         <td class="px-4 py-2 text-gray-600">{{ p.assigned_driver.username|default:"-" }}</td>
43 43
         <td class="px-4 py-2">
44 44
           <div class="flex flex-wrap gap-2 justify-end">
45
+            <a class="btn-outline btn-xs" href="{% url 'recycle_core:pickup_detail' p.id %}">View</a>
46
+            {% if perms.recycle_core.assign_driver %}
45 47
             <form method="post" action="{% url 'recycle_core:pickup_assign' p.id %}" class="flex items-center gap-2">
46 48
               {% csrf_token %}
47 49
               {{ assign_form.driver }}
48 50
               <button class="btn btn-xs">Assign</button>
49 51
             </form>
52
+            {% endif %}
53
+            {% if perms.recycle_core.set_pickup_status %}
50 54
             <form method="post" action="{% url 'recycle_core:pickup_set_status' p.id %}" class="flex items-center gap-2">
51 55
               {% csrf_token %}
52 56
               {{ status_form.status }}
53 57
               <button class="btn btn-xs">Update</button>
54 58
             </form>
59
+            {% endif %}
55 60
             {% if p.weigh_ticket %}
61
+              {% if perms.recycle_core.generate_invoice %}
56 62
               <form method="post" action="{% url 'recycle_core:pickup_generate_invoice' p.id %}">
57 63
                 {% csrf_token %}
58 64
                 <button class="btn btn-xs">Generate Invoice</button>
59 65
               </form>
66
+              {% endif %}
67
+              <a class="btn-outline btn-xs" href="{% url 'recycle_core:weigh_ticket_detail' p.weigh_ticket.id %}">View Ticket</a>
68
+            {% else %}
69
+              {% if perms.recycle_core.create_weigh_ticket %}
70
+              <a class="btn btn-xs" href="{% url 'recycle_core:pickup_create_weigh_ticket' p.id %}">Create Weigh Ticket</a>
71
+              {% endif %}
60 72
             {% endif %}
61 73
           </div>
62 74
         </td>

+ 81 - 4
recycle_core/templates/recycle_core/services_list.html

@@ -9,7 +9,22 @@
9 9
   <style>.btn-outline{border:1px solid #cbd5e1;padding:.375rem .5rem;border-radius:.375rem}</style>
10 10
 </div>
11 11
 
12
-<div class="bg-white rounded shadow overflow-hidden">
12
+<!-- SortableJS CDN -->
13
+
14
+<div class="bg-white rounded shadow overflow-hidden"
15
+     x-data="servicesSorter()">
16
+  <div class="px-4 py-2 text-xs text-gray-500 border-b flex items-center gap-3">
17
+    <span>Drag the handle to reorder. Changes save automatically.</span>
18
+    <template x-if="saving">
19
+      <span class="text-blue-600">Saving…</span>
20
+    </template>
21
+    <template x-if="saved">
22
+      <span class="text-green-600">Saved.</span>
23
+    </template>
24
+    <template x-if="error">
25
+      <span class="text-red-600" x-text="error"></span>
26
+    </template>
27
+  </div>
13 28
   <table class="min-w-full text-sm">
14 29
     <thead class="bg-gray-50 text-left">
15 30
       <tr>
@@ -20,10 +35,15 @@
20 35
         <th class="px-4 py-2 w-48">Actions</th>
21 36
       </tr>
22 37
     </thead>
23
-    <tbody>
38
+    <tbody x-ref="tbody">
24 39
       {% for s in services %}
25
-      <tr class="border-t">
26
-        <td class="px-4 py-2 text-gray-600">{{ s.display_order }}</td>
40
+      <tr class="border-t" :data-id="{{ s.id }}" data-id="{{ s.id }}">
41
+        <td class="px-4 py-2 text-gray-600">
42
+          <div class="flex items-center gap-2">
43
+            <span class="drag-handle cursor-grab select-none" title="Drag to reorder">⋮⋮</span>
44
+            <span class="order-index">{{ s.display_order|add:1 }}</span>
45
+          </div>
46
+        </td>
27 47
         <td class="px-4 py-2 font-medium">{{ s.title }}</td>
28 48
         <td class="px-4 py-2 text-gray-700">{{ s.description|truncatechars:140 }}</td>
29 49
         <td class="px-4 py-2">
@@ -57,6 +77,63 @@
57 77
   <div class="px-4 py-3 text-xs text-gray-500 border-t">Sorted by display order ascending.</div>
58 78
   <style>
59 79
     .btn{background:#1d4ed8;color:#fff;padding:.5rem .75rem;border-radius:.375rem}
80
+    .drag-handle{font-weight:700;line-height:1}
60 81
   </style>
61 82
 </div>
83
+
84
+<script>
85
+  function servicesSorter(){
86
+    return {
87
+      saving: false,
88
+      saved: false,
89
+      error: '',
90
+      init(){
91
+        // Wait for SortableJS to be present
92
+        const initSortable = () => {
93
+          if (!window.Sortable) { setTimeout(initSortable, 50); return; }
94
+          const el = this.$refs.tbody;
95
+          new Sortable(el, {
96
+            handle: '.drag-handle',
97
+            animation: 150,
98
+            ghostClass: 'bg-yellow-50',
99
+            onEnd: () => this.persistOrder()
100
+          });
101
+        };
102
+        initSortable();
103
+      },
104
+      persistOrder(){
105
+        this.saving = true; this.saved = false; this.error='';
106
+        const ids = Array.from(this.$refs.tbody.children).map(tr => parseInt(tr.dataset.id));
107
+        // Update visible order indices immediately (1-based display)
108
+        Array.from(this.$refs.tbody.children).forEach((tr, idx) => {
109
+          const el = tr.querySelector('.order-index');
110
+          if (el) el.textContent = idx + 1;
111
+        });
112
+        fetch("{% url 'recycle_core:services_reorder' %}", {
113
+          method: 'POST',
114
+          headers: {
115
+            'Content-Type': 'application/json',
116
+            'X-CSRFToken': getCookie('csrftoken'),
117
+          },
118
+          body: JSON.stringify({ ids })
119
+        }).then(r => {
120
+          if(!r.ok) throw new Error('Save failed');
121
+          return r.json();
122
+        }).then(() => {
123
+          this.saved = true;
124
+        }).catch(err => {
125
+          this.error = err.message || 'Error saving order';
126
+        }).finally(() => {
127
+          this.saving = false;
128
+          setTimeout(() => { this.saved = false }, 1500);
129
+        });
130
+      }
131
+    }
132
+  }
133
+  function getCookie(name) {
134
+    const value = `; ${document.cookie}`;
135
+    const parts = value.split(`; ${name}=`);
136
+    if (parts.length === 2) return parts.pop().split(';').shift();
137
+  }
138
+</script>
62 139
 {% endblock %}

+ 17 - 42
recycle_core/templates/recycle_core/user_form.html

@@ -1,4 +1,5 @@
1 1
 {% extends "admin_frontend/base.html" %}
2
+{% load crispy_forms_tags %}
2 3
 {% block title %}{% if creating %}Create User{% else %}Edit User{% endif %}{% endblock %}
3 4
 
4 5
 {% block content %}
@@ -10,58 +11,33 @@
10 11
 
11 12
 <form method="post" class="bg-white rounded shadow p-4 space-y-4">
12 13
   {% csrf_token %}
14
+  <input type="hidden" name="next" value="{{ request.get_full_path }}">
13 15
   {% if creating %}
14
-    <div>
15
-      <label class="block text-sm font-medium mb-1">Username</label>
16
-      <input type="text" name="username" value="{{ form.username.value|default:'' }}" class="w-full border rounded px-3 py-2" required>
17
-      {% if form.username.errors %}<p class="text-red-600 text-sm">{{ form.username.errors.0 }}</p>{% endif %}
18
-    </div>
19
-    <div>
20
-      <label class="block text-sm font-medium mb-1">Email</label>
21
-      <input type="email" name="email" value="{{ form.email.value|default:'' }}" class="w-full border rounded px-3 py-2">
22
-      {% if form.email.errors %}<p class="text-red-600 text-sm">{{ form.email.errors.0 }}</p>{% endif %}
23
-    </div>
24
-    <div>
25
-      <label class="block text-sm font-medium mb-1">Role</label>
26
-      {{ form.role }}
27
-      {% if form.role.errors %}<p class="text-red-600 text-sm">{{ form.role.errors.0 }}</p>{% endif %}
16
+    {% if form.username %}{{ form.username|as_crispy_field }}{% endif %}
17
+    {% if form.email %}{{ form.email|as_crispy_field }}{% endif %}
18
+    <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
19
+      <div>{% if form.first_name %}{{ form.first_name|as_crispy_field }}{% endif %}</div>
20
+      <div>{% if form.last_name %}{{ form.last_name|as_crispy_field }}{% endif %}</div>
28 21
     </div>
22
+    {% if form.role %}{{ form.role|as_crispy_field }}{% endif %}
29 23
     <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
30
-      <div>
31
-        <label class="block text-sm font-medium mb-1">Password</label>
32
-        <input type="password" name="password1" class="w-full border rounded px-3 py-2" required>
33
-        {% if form.password1.errors %}<p class="text-red-600 text-sm">{{ form.password1.errors.0 }}</p>{% endif %}
34
-      </div>
35
-      <div>
36
-        <label class="block text-sm font-medium mb-1">Confirm Password</label>
37
-        <input type="password" name="password2" class="w-full border rounded px-3 py-2" required>
38
-        {% if form.password2.errors %}<p class="text-red-600 text-sm">{{ form.password2.errors.0 }}</p>{% endif %}
39
-      </div>
24
+      <div>{% if form.password1 %}{{ form.password1|as_crispy_field }}{% endif %}</div>
25
+      <div>{% if form.password2 %}{{ form.password2|as_crispy_field }}{% endif %}</div>
40 26
     </div>
41 27
   {% else %}
42 28
     <div>
43 29
       <label class="block text-sm font-medium mb-1">Username</label>
44 30
       <input type="text" value="{{ item.username }}" class="w-full border rounded px-3 py-2 bg-gray-50" disabled>
45 31
     </div>
46
-    <div>
47
-      <label class="block text-sm font-medium mb-1">Email</label>
48
-      <input type="email" name="email" value="{{ form.email.value|default:'' }}" class="w-full border rounded px-3 py-2">
49
-      {% if form.email.errors %}<p class="text-red-600 text-sm">{{ form.email.errors.0 }}</p>{% endif %}
50
-    </div>
51
-    <div>
52
-      <label class="block text-sm font-medium mb-1">Role</label>
53
-      {{ form.role }}
54
-      {% if form.role.errors %}<p class="text-red-600 text-sm">{{ form.role.errors.0 }}</p>{% endif %}
32
+    {% if form.email %}{{ form.email|as_crispy_field }}{% endif %}
33
+    <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
34
+      <div>{% if form.first_name %}{{ form.first_name|as_crispy_field }}{% endif %}</div>
35
+      <div>{% if form.last_name %}{{ form.last_name|as_crispy_field }}{% endif %}</div>
55 36
     </div>
37
+    {% if form.role %}{{ form.role|as_crispy_field }}{% endif %}
56 38
     <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
57
-      <div>
58
-        <label class="block text-sm font-medium mb-1">New Password (optional)</label>
59
-        <input type="password" name="password1" class="w-full border rounded px-3 py-2">
60
-      </div>
61
-      <div>
62
-        <label class="block text-sm font-medium mb-1">Confirm New Password</label>
63
-        <input type="password" name="password2" class="w-full border rounded px-3 py-2">
64
-      </div>
39
+      <div>{% if form.password1 %}{{ form.password1|as_crispy_field }}{% endif %}</div>
40
+      <div>{% if form.password2 %}{{ form.password2|as_crispy_field }}{% endif %}</div>
65 41
     </div>
66 42
   {% endif %}
67 43
 
@@ -71,4 +47,3 @@
71 47
   </div>
72 48
 </form>
73 49
 {% endblock %}
74
-

+ 10 - 4
recycle_core/templates/recycle_core/weigh_ticket_detail.html

@@ -5,16 +5,22 @@
5 5
 {% render_breadcrumbs breadcrumbs %}
6 6
 <div class="flex items-center justify-between mb-4">
7 7
   <h1 class="text-2xl font-semibold">Weigh Ticket {{ ticket.ticket_number|default:ticket.id }}</h1>
8
-  <form method="post" action="{{ generate_url }}">
9
-    {% csrf_token %}
10
-    <button class="px-3 py-2 rounded bg-blue-600 text-white">Generate Invoice</button>
11
-  </form>
8
+  <div class="flex items-center gap-2">
9
+    <a href="{% url 'recycle_core:pickup_detail' ticket.pickup.id %}" class="btn-outline">View Pickup</a>
10
+    {% if perms.recycle_core.generate_invoice %}
11
+    <form method="post" action="{{ generate_url }}">
12
+      {% csrf_token %}
13
+      <button class="px-3 py-2 rounded bg-blue-600 text-white">Generate Invoice</button>
14
+    </form>
15
+    {% endif %}
16
+  </div>
12 17
 </div>
13 18
 
14 19
 <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
15 20
   <div class="bg-white rounded shadow p-4">
16 21
     <h2 class="font-medium mb-2">Details</h2>
17 22
     <dl class="grid grid-cols-3 gap-2 text-sm">
23
+      <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>
18 24
       <dt class="text-gray-500">Customer</dt><dd class="col-span-2">{{ ticket.pickup.customer.name }}</dd>
19 25
       <dt class="text-gray-500">Recorded</dt><dd class="col-span-2">{{ ticket.recorded_at }}</dd>
20 26
       <dt class="text-gray-500">Gross</dt><dd class="col-span-2">{{ ticket.gross_weight }} {{ ticket.unit }}</dd>

+ 17 - 0
recycle_core/templates/recycle_core/weigh_ticket_form.html

@@ -0,0 +1,17 @@
1
+{% extends "admin_frontend/base.html" %}
2
+{% load crispy_forms_tags %}
3
+{% block title %}New Weigh Ticket{% endblock %}
4
+{% block content %}
5
+  <h1 class="text-2xl font-semibold mb-4">Create Weigh Ticket</h1>
6
+  <div class="bg-white rounded shadow p-4 max-w-2xl">
7
+    <form method="post">
8
+      {% csrf_token %}
9
+      {{ form|crispy }}
10
+      <div class="mt-4 flex items-center gap-2">
11
+        <button class="px-4 py-2 bg-blue-600 text-white rounded">Save</button>
12
+        <a class="btn-outline" href="{% url 'recycle_core:pickups_list' %}">Cancel</a>
13
+      </div>
14
+    </form>
15
+  </div>
16
+{% endblock %}
17
+

+ 2 - 0
recycle_core/templates/recycle_core/weigh_tickets_list.html

@@ -39,10 +39,12 @@
39 39
         <td class="px-4 py-2">{{ t.recorded_at }}</td>
40 40
         <td class="px-4 py-2">{{ t.net_weight }} {{ t.unit }}</td>
41 41
         <td class="px-4 py-2">
42
+          {% if perms.recycle_core.generate_invoice %}
42 43
           <form method="post" action="{% url 'recycle_core:weigh_ticket_generate_invoice' t.id %}">
43 44
             {% csrf_token %}
44 45
             <button class="btn-outline">Generate Invoice</button>
45 46
           </form>
47
+          {% endif %}
46 48
         </td>
47 49
       </tr>
48 50
       {% empty %}

+ 4 - 4
recycle_core/urls.py

@@ -11,6 +11,7 @@ urlpatterns = [
11 11
     path("org-users/<int:pk>/delete/", views.org_user_delete, name="org_user_delete"),
12 12
     path("materials/", views.materials_list, name="materials_list"),
13 13
     path("materials/<int:pk>/edit/", views.material_edit, name="material_edit"),
14
+    path("materials/<int:pk>/images/reorder/", views.material_images_reorder, name="material_images_reorder"),
14 15
     path("materials/<int:pk>/delete/", views.material_delete, name="material_delete"),
15 16
 
16 17
     path("customers/", views.customers_list, name="customers_list"),
@@ -21,6 +22,8 @@ urlpatterns = [
21 22
     path("pickups/<int:pk>/assign/", views.pickup_assign, name="pickup_assign"),
22 23
     path("pickups/<int:pk>/set-status/", views.pickup_set_status, name="pickup_set_status"),
23 24
     path("pickups/<int:pk>/generate-invoice/", views.pickup_generate_invoice, name="pickup_generate_invoice"),
25
+    path("pickups/<int:pk>/create-weigh-ticket/", views.pickup_create_weigh_ticket, name="pickup_create_weigh_ticket"),
26
+    path("pickups/<int:pk>/", views.pickup_detail, name="pickup_detail"),
24 27
 
25 28
     path("scrap-listings/", views.scrap_listings_list, name="scrap_listings_list"),
26 29
     path("scrap-listings/<int:pk>/open/", views.scrap_listing_open, name="scrap_listing_open"),
@@ -31,18 +34,15 @@ urlpatterns = [
31 34
     path("weigh-tickets/<int:pk>/", views.weigh_ticket_detail, name="weigh_ticket_detail"),
32 35
     path("weigh-tickets/<int:pk>/generate-invoice/", views.weigh_ticket_generate_invoice, name="weigh_ticket_generate_invoice"),
33 36
 
34
-    path("invoices/", views.invoices_list, name="invoices_list"),
35
-    path("invoices/<int:pk>/add-payment/", views.invoice_add_payment, name="invoice_add_payment"),
36 37
 
37 38
     path("documents/", views.documents_list, name="documents_list"),
38 39
     path("audit-logs/", views.audit_logs_list, name="audit_logs_list"),
39 40
     path("audit-logs/export/", views.audit_logs_export_csv, name="audit_logs_export_csv"),
40
-    path("payouts/", views.payouts_list, name="payouts_list"),
41
-    path("payouts/export/", views.payouts_export_csv, name="payouts_export_csv"),
42 41
     # Services
43 42
     path("services/", views.services_list, name="services_list"),
44 43
     path("services/new/", views.service_create, name="service_create"),
45 44
     path("services/<int:pk>/edit/", views.service_edit, name="service_edit"),
46 45
     path("services/<int:pk>/delete/", views.service_delete, name="service_delete"),
47 46
     path("services/<int:pk>/toggle-enabled/", views.service_toggle_enabled, name="service_toggle_enabled"),
47
+    path("services/reorder/", views.services_reorder, name="services_reorder"),
48 48
 ]

+ 141 - 164
recycle_core/views.py

@@ -9,11 +9,13 @@ import django_filters as filters
9 9
 from admin_frontend.templatetags.public_urls import public_route
10 10
 from admin_frontend.nav import _nav_items
11 11
 from cms.views import breadcrumbs
12
+from orgs.decorators import permissions_required
12 13
 
13 14
 from orgs.models import Organization, UserProfile
14 15
 from .models import (
15 16
     MaterialCategory,
16 17
     Material,
18
+    MaterialImage,
17 19
     ProvidedService,
18 20
     Customer,
19 21
     CustomerSite,
@@ -22,11 +24,8 @@ from .models import (
22 24
     WeighLine,
23 25
     ScrapListing,
24 26
     ScrapBid,
25
-    Invoice,
26
-    Payment,
27 27
     Document,
28 28
     AuditLog,
29
-    Payout,
30 29
 )
31 30
 from .forms import (
32 31
     MaterialForm,
@@ -34,14 +33,16 @@ from .forms import (
34 33
     CustomerForm,
35 34
     PickupAssignForm,
36 35
     PickupStatusForm,
37
-    PaymentForm,
38 36
     DocumentForm,
39 37
     ProvidedServiceForm,
38
+    WeighTicketForm,
40 39
 )
41 40
 from .services.billing import generate_invoice_for_pickup
42 41
 from django.contrib.contenttypes.models import ContentType
43 42
 from django.contrib.auth import get_user_model
44
-from django.http import HttpResponse
43
+from django.http import HttpResponse, JsonResponse
44
+from django.db import transaction
45
+import json
45 46
 import csv
46 47
 from django.db.models import Sum, F, ExpressionWrapper, DecimalField, Value
47 48
 from django.db.models.functions import Coalesce
@@ -186,12 +187,20 @@ def org_user_create(request):
186 187
     if request.method == "POST":
187 188
         if form.is_valid():
188 189
             User = get_user_model()
189
-            user = User.objects.create(username=form.cleaned_data["username"], email=form.cleaned_data.get("email") or "")
190
+            user = User.objects.create(
191
+                username=form.cleaned_data["username"],
192
+                email=form.cleaned_data.get("email") or "",
193
+                first_name=form.cleaned_data.get("first_name") or "",
194
+                last_name=form.cleaned_data.get("last_name") or "",
195
+            )
190 196
             user.set_password(form.cleaned_data["password1"])
191 197
             user.save()
192 198
             org = getattr(request, "org", None)
193 199
             UserProfile.objects.create(user=user, organization=org, role=form.cleaned_data["role"])  # type: ignore[arg-type]
194 200
             messages.success(request, f"User '{user.username}' created.")
201
+            next_url = request.POST.get("next") or request.GET.get("next")
202
+            if next_url:
203
+                return redirect(next_url)
195 204
             return redirect("recycle_core:org_users_list")
196 205
         messages.error(request, "Please correct the errors below.")
197 206
     return render(request, "recycle_core/user_form.html", {"nav": _nav_items(), "form": form, "creating": True, "cancel_url": reverse("recycle_core:org_users_list")})
@@ -210,11 +219,15 @@ def org_user_edit(request, pk: int):
210 219
     profile = getattr(user, "recycle_profile", None)
211 220
     form = UserEditForm(request.POST or None, initial={
212 221
         "email": user.email,
222
+        "first_name": user.first_name,
223
+        "last_name": user.last_name,
213 224
         "role": getattr(profile, "role", UserProfile.ROLE_MANAGER),
214 225
     })
215 226
     if request.method == "POST":
216 227
         if form.is_valid():
217 228
             user.email = form.cleaned_data.get("email") or ""
229
+            user.first_name = form.cleaned_data.get("first_name") or ""
230
+            user.last_name = form.cleaned_data.get("last_name") or ""
218 231
             p1 = form.cleaned_data.get("password1")
219 232
             if p1:
220 233
                 user.set_password(p1)
@@ -226,6 +239,9 @@ def org_user_edit(request, pk: int):
226 239
                 profile.organization = org
227 240
             profile.save()
228 241
             messages.success(request, f"User '{user.username}' updated.")
242
+            next_url = request.POST.get("next") or request.GET.get("next")
243
+            if next_url:
244
+                return redirect(next_url)
229 245
             return redirect("recycle_core:org_users_list")
230 246
         messages.error(request, "Please correct the errors below.")
231 247
     return render(request, "recycle_core/user_form.html", {"nav": _nav_items(), "form": form, "item": user, "creating": False, "cancel_url": reverse("recycle_core:org_users_list")})
@@ -277,6 +293,36 @@ def material_delete(request, pk: int):
277 293
 
278 294
 
279 295
 @staff_member_required
296
+@require_POST
297
+def material_images_reorder(request, pk: int):
298
+    """Reorder MaterialImage.display_order for a material.
299
+
300
+    Expects JSON {"ids": [image_id, ...]} in the new order.
301
+    """
302
+    material = get_object_or_404(Material, pk=pk)
303
+    try:
304
+        payload = json.loads(request.body.decode("utf-8"))
305
+        ids = payload.get("ids", [])
306
+        if not isinstance(ids, list):
307
+            return JsonResponse({"ok": False, "error": "Invalid payload"}, status=400)
308
+    except Exception:
309
+        return JsonResponse({"ok": False, "error": "Malformed JSON"}, status=400)
310
+
311
+    imgs = MaterialImage.objects.filter(material=material, id__in=ids)
312
+    img_map = {im.id: im for im in imgs}
313
+    with transaction.atomic():
314
+        for idx, iid in enumerate(ids):
315
+            im = img_map.get(iid)
316
+            if not im:
317
+                continue
318
+            if im.display_order != idx:
319
+                im.display_order = idx
320
+                im.save(update_fields=["display_order"])
321
+
322
+    return JsonResponse({"ok": True})
323
+
324
+
325
+@staff_member_required
280 326
 @public_route(label="Customers", order=20, icon="building-storefront")
281 327
 @breadcrumbs(label="Customers", name="re_customers")
282 328
 def customers_list(request):
@@ -416,6 +462,7 @@ def pickups_list(request):
416 462
 
417 463
 
418 464
 @staff_member_required
465
+@permissions_required('recycle_core.assign_driver', message="You are not allowed to assign drivers for this pickup.")
419 466
 @require_POST
420 467
 def pickup_assign(request, pk: int):
421 468
     pickup = get_object_or_404(PickupOrder, pk=pk)
@@ -431,6 +478,7 @@ def pickup_assign(request, pk: int):
431 478
 
432 479
 
433 480
 @staff_member_required
481
+@permissions_required('recycle_core.set_pickup_status', message="You are not allowed to change status for this pickup.")
434 482
 @require_POST
435 483
 def pickup_set_status(request, pk: int):
436 484
     pickup = get_object_or_404(PickupOrder, pk=pk)
@@ -445,6 +493,58 @@ def pickup_set_status(request, pk: int):
445 493
 
446 494
 
447 495
 @staff_member_required
496
+@breadcrumbs(label="Pickup Detail", parent="re_pickups")
497
+def pickup_detail(request, pk: int):
498
+    pickup = get_object_or_404(
499
+        PickupOrder.objects.select_related("organization", "customer", "site", "assigned_driver"), pk=pk
500
+    )
501
+    items = pickup.items.select_related("material").all()
502
+    context = {
503
+        "nav": _nav_items(),
504
+        "pickup": pickup,
505
+        "items": items,
506
+    }
507
+    return render(request, "recycle_core/pickup_detail.html", context)
508
+
509
+
510
+@staff_member_required
511
+@permissions_required('recycle_core.create_weigh_ticket', message="You are not allowed to create weigh tickets.")
512
+def pickup_create_weigh_ticket(request, pk: int):
513
+    pickup = get_object_or_404(PickupOrder.objects.select_related("customer", "organization"), pk=pk)
514
+    if hasattr(pickup, "weigh_ticket"):
515
+        messages.info(request, "Weigh ticket already exists for this pickup.")
516
+        return redirect("recycle_core:weigh_ticket_detail", pk=pickup.weigh_ticket.id)
517
+
518
+    if request.method == "POST":
519
+        form = WeighTicketForm(request.POST)
520
+        if form.is_valid():
521
+            wt = WeighTicket.objects.create(
522
+                pickup=pickup,
523
+                ticket_number=form.cleaned_data.get("ticket_number") or "",
524
+                gross_weight=form.cleaned_data["gross_weight"],
525
+                tare_weight=form.cleaned_data["tare_weight"],
526
+                net_weight=form.cleaned_data["net_weight"],
527
+                unit=form.cleaned_data["unit"],
528
+                recorded_by=request.user,
529
+            )
530
+            pickup.status = PickupOrder.STATUS_WEIGHED
531
+            pickup.save(update_fields=["status"])
532
+            messages.success(request, f"Weigh ticket {wt.ticket_number or wt.id} created.")
533
+            return redirect("recycle_core:weigh_ticket_detail", pk=wt.id)
534
+        messages.error(request, "Please correct the form errors.")
535
+    else:
536
+        form = WeighTicketForm()
537
+
538
+    context = {
539
+        "nav": _nav_items(),
540
+        "pickup": pickup,
541
+        "form": form,
542
+    }
543
+    return render(request, "recycle_core/weigh_ticket_form.html", context)
544
+
545
+
546
+@staff_member_required
547
+@permissions_required('recycle_core.generate_invoice', message="You are not allowed to generate invoices.")
448 548
 @require_POST
449 549
 def pickup_generate_invoice(request, pk: int):
450 550
     pickup = get_object_or_404(PickupOrder, pk=pk)
@@ -619,6 +719,7 @@ def weigh_ticket_detail(request, pk: int):
619 719
 
620 720
 
621 721
 @staff_member_required
722
+@permissions_required('recycle_core.generate_invoice', message="You are not allowed to generate invoices.")
622 723
 @require_POST
623 724
 def weigh_ticket_generate_invoice(request, pk: int):
624 725
     ticket = get_object_or_404(WeighTicket, pk=pk)
@@ -630,164 +731,7 @@ def weigh_ticket_generate_invoice(request, pk: int):
630 731
     return redirect("recycle_core:weigh_tickets_list")
631 732
 
632 733
 
633
-# Invoices UI ---------------------------------------------------------------
634
-
635
-@staff_member_required
636
-@public_route(label="Invoices", order=50, icon="receipt-percent")
637
-@breadcrumbs(label="Invoices", name="re_invoices")
638
-def invoices_list(request):
639
-    class InvoiceFilter(filters.FilterSet):
640
-        organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
641
-        customer = filters.ModelChoiceFilter(queryset=Customer.objects.all())
642
-        status = filters.ChoiceFilter(field_name="status", choices=Invoice.STATUS_CHOICES)
643
-        issued_at = filters.DateFromToRangeFilter(field_name="issued_at", label="Issued between")
644
-
645
-        class Meta:
646
-            model = Invoice
647
-            fields = ["organization", "customer", "status", "issued_at"]
648
-
649
-    base_qs = (
650
-        Invoice.objects.select_related("organization", "customer")
651
-        .annotate(
652
-            payments_total=Coalesce(
653
-                Sum("payments__amount"),
654
-                Value(Decimal("0.00")),
655
-                output_field=DecimalField(max_digits=14, decimal_places=2),
656
-            ),
657
-        )
658
-        .annotate(
659
-            balance=ExpressionWrapper(
660
-                F("total_amount") - F("payments_total"),
661
-                output_field=DecimalField(max_digits=14, decimal_places=2),
662
-            )
663
-        )
664
-        .order_by("-issued_at", "-id")
665
-    )
666
-    i_filter = InvoiceFilter(request.GET, queryset=base_qs)
667
-    qs = i_filter.qs
668
-    org = getattr(request, "org", None)
669
-    if org is not None:
670
-        qs = qs.filter(organization=org)
671
-
672
-    paginator = Paginator(qs, 10)
673
-    page = request.GET.get("page")
674
-    try:
675
-        page_obj = paginator.page(page)
676
-    except PageNotAnInteger:
677
-        page_obj = paginator.page(1)
678
-    except EmptyPage:
679
-        page_obj = paginator.page(paginator.num_pages)
680
-
681
-    pay_form = PaymentForm()
682
-    context = {
683
-        "nav": _nav_items(),
684
-        "invoices": page_obj.object_list,
685
-        "page_obj": page_obj,
686
-        "filter": i_filter,
687
-        "pay_form": pay_form,
688
-    }
689
-    return render(request, "recycle_core/invoices_list.html", context)
690
-
691
-
692
-@staff_member_required
693
-@require_POST
694
-def invoice_add_payment(request, pk: int):
695
-    invoice = get_object_or_404(Invoice, pk=pk)
696
-    form = PaymentForm(request.POST)
697
-    if form.is_valid():
698
-        Payment.objects.create(
699
-            invoice=invoice,
700
-            amount=form.cleaned_data["amount"],
701
-            currency_code=invoice.currency_code,
702
-            received_at=form.cleaned_data.get("received_at") or None,
703
-            reference=form.cleaned_data.get("reference", ""),
704
-        )
705
-        messages.success(request, f"Payment recorded for invoice #{invoice.id}.")
706
-    else:
707
-        messages.error(request, "Invalid payment data.")
708
-    return redirect("recycle_core:invoices_list")
709
-
710
-
711
-# Payouts UI + CSV ----------------------------------------------------------
712
-
713
-@staff_member_required
714
-@public_route(label="Payouts", order=52, icon="banknotes")
715
-@breadcrumbs(label="Payouts", name="re_payouts")
716
-def payouts_list(request):
717
-    class PayoutFilter(filters.FilterSet):
718
-        organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
719
-        customer = filters.ModelChoiceFilter(queryset=Customer.objects.all())
720
-        paid_at = filters.DateFromToRangeFilter(field_name="paid_at", label="Paid between")
721
-
722
-        class Meta:
723
-            model = Payout
724
-            fields = ["organization", "customer", "paid_at"]
725
-
726
-    base_qs = Payout.objects.select_related("organization", "customer").order_by("-paid_at", "-id")
727
-    p_filter = PayoutFilter(request.GET, queryset=base_qs)
728
-    qs = p_filter.qs
729
-    org = getattr(request, "org", None)
730
-    if org is not None:
731
-        qs = qs.filter(organization=org)
732
-
733
-    paginator = Paginator(qs, 10)
734
-    page = request.GET.get("page")
735
-    try:
736
-        page_obj = paginator.page(page)
737
-    except PageNotAnInteger:
738
-        page_obj = paginator.page(1)
739
-    except EmptyPage:
740
-        page_obj = paginator.page(paginator.num_pages)
741
-
742
-    context = {
743
-        "nav": _nav_items(),
744
-        "payouts": page_obj.object_list,
745
-        "page_obj": page_obj,
746
-        "filter": p_filter,
747
-    }
748
-    return render(request, "recycle_core/payouts_list.html", context)
749
-
750
-
751
-@staff_member_required
752
-def payouts_export_csv(request):
753
-    qs = Payout.objects.select_related("organization", "customer").all()
754
-    org_obj = getattr(request, "org", None)
755
-    if org_obj is not None:
756
-        qs = qs.filter(organization=org_obj)
757
-    org = request.GET.get("organization")
758
-    cust = request.GET.get("customer")
759
-    start = request.GET.get("paid_at_after") or request.GET.get("paid_at_min")
760
-    end = request.GET.get("paid_at_before") or request.GET.get("paid_at_max")
761
-    from django.utils.dateparse import parse_datetime
762
-
763
-    if org:
764
-        qs = qs.filter(organization_id=org)
765
-    if cust:
766
-        qs = qs.filter(customer_id=cust)
767
-    if start:
768
-        dt = parse_datetime(start)
769
-        if dt:
770
-            qs = qs.filter(paid_at__gte=dt)
771
-    if end:
772
-        dt = parse_datetime(end)
773
-        if dt:
774
-            qs = qs.filter(paid_at__lte=dt)
775
-
776
-    response = HttpResponse(content_type="text/csv")
777
-    response["Content-Disposition"] = "attachment; filename=payouts.csv"
778
-    writer = csv.writer(response)
779
-    writer.writerow(["organization", "customer", "amount", "currency", "paid_at", "reference", "pickup_id"]) 
780
-    for p in qs.iterator():
781
-        writer.writerow([
782
-            p.organization.code,
783
-            p.customer.name,
784
-            p.amount,
785
-            p.currency_code,
786
-            p.paid_at.isoformat(),
787
-            p.reference,
788
-            p.pickup_id or "",
789
-        ])
790
-    return response
734
+# Billing-related views moved to billing/views.py
791 735
 
792 736
 
793 737
 # Documents UI --------------------------------------------------------------
@@ -1024,3 +968,36 @@ def service_toggle_enabled(request, pk: int):
1024 968
         item.save(update_fields=["is_enabled"])
1025 969
         messages.success(request, f"Service '{item.title}' {'enabled' if item.is_enabled else 'disabled'}.")
1026 970
     return redirect("recycle_core:services_list")
971
+
972
+
973
+@require_POST
974
+@owner_required
975
+def services_reorder(request):
976
+    """Reorder ProvidedService.display_order for the current organization.
977
+
978
+    Expects JSON body with {"ids": [<service_id>, ...]} in the new order (top→bottom).
979
+    """
980
+    try:
981
+        data = json.loads(request.body.decode("utf-8"))
982
+        ids = data.get("ids", [])
983
+        if not isinstance(ids, list):
984
+            return JsonResponse({"ok": False, "error": "Invalid payload."}, status=400)
985
+    except Exception:
986
+        return JsonResponse({"ok": False, "error": "Malformed JSON."}, status=400)
987
+
988
+    org = getattr(request, "org", None)
989
+    # Fetch only services belonging to this org and requested ids
990
+    qs = ProvidedService.objects.filter(organization=org, id__in=ids)
991
+    existing = {obj.id: obj for obj in qs}
992
+
993
+    # Enforce order based on the incoming list; skip unknown ids
994
+    with transaction.atomic():
995
+        for idx, sid in enumerate(ids):
996
+            obj = existing.get(sid)
997
+            if not obj:
998
+                continue
999
+            if obj.display_order != idx:
1000
+                obj.display_order = idx
1001
+                obj.save(update_fields=["display_order"])
1002
+
1003
+    return JsonResponse({"ok": True})

+ 1 - 4
recycle_core/views_api.py

@@ -22,10 +22,6 @@ from .models import (
22 22
     PickupItem,
23 23
     WeighTicket,
24 24
     WeighLine,
25
-    Invoice,
26
-    InvoiceLine,
27
-    Payment,
28
-    Payout,
29 25
     ScrapListing,
30 26
     ScrapListingItem,
31 27
     ScrapBid,
@@ -34,6 +30,7 @@ from .models import (
34 30
     Document,
35 31
     AuditLog,
36 32
 )
33
+from billing.models import Invoice, InvoiceLine, Payment, Payout
37 34
 from .serializers import (
38 35
     OrganizationSerializer,
39 36
     MaterialCategorySerializer,

progress update · 0359e841fb - Gogs: Simplico Git Service
tum преди 1 месец
родител
ревизия
0359e841fb
променени са 2 файла, в които са добавени 62 реда и са изтрити 4 реда
  1. 62 4
      progress-update.md
  2. BIN
      progress-update.pdf

+ 62 - 4
progress-update.md

@@ -77,7 +77,66 @@ Endpoint checks:
77 77
 - PagerDuty Stub: OK
78 78
 - soc-integrator `/health`: OK
79 79
 
80
-## 4) In Progress / Remaining for Customer UAT
80
+## 4) System Architecture Diagram (PlantUML)
81
+
82
+```plantuml
83
+@startuml
84
+title FoodProject SOC Platform - System Architecture (MVP)
85
+skinparam componentStyle rectangle
86
+
87
+actor "Analyst" as analyst
88
+cloud "External Log Sources" as logs
89
+
90
+rectangle "SOC Shared Docker Network" {
91
+  node "Wazuh Stack" as wazuh {
92
+    component "Wazuh Manager" as wazuh_mgr
93
+    component "Wazuh Indexer" as wazuh_idx
94
+    component "Wazuh Dashboard" as wazuh_dash
95
+  }
96
+
97
+  node "Shuffle Stack" as shuffle {
98
+    component "Shuffle Frontend" as shuf_fe
99
+    component "Shuffle Backend" as shuf_be
100
+    component "Shuffle Orborus" as shuf_orb
101
+    component "Shuffle OpenSearch" as shuf_os
102
+  }
103
+
104
+  node "IRIS-web Stack" as iris {
105
+    component "IRIS Web App" as iris_app
106
+    database "IRIS DB" as iris_db
107
+    component "IRIS RabbitMQ" as iris_mq
108
+  }
109
+
110
+  node "SOC Integrator Stack" as integ {
111
+    component "soc-integrator API" as soc_api
112
+    database "soc-integrator-db" as soc_db
113
+  }
114
+
115
+  component "PagerDuty Stub" as pd_stub
116
+}
117
+
118
+logs --> wazuh_mgr : Security events
119
+wazuh_mgr --> wazuh_idx : Index alerts
120
+analyst --> wazuh_dash : Investigate alerts
121
+wazuh_dash --> wazuh_idx : Query data
122
+
123
+wazuh_mgr --> soc_api : Alert/incident input
124
+soc_api --> soc_db : Persist incidents\npolicies\naudit
125
+soc_api --> iris_app : Create/update cases
126
+soc_api --> pd_stub : Escalation (MVP)
127
+soc_api --> shuf_be : Trigger automation
128
+
129
+shuf_fe --> shuf_be : UI/API
130
+shuf_be --> shuf_os : Read/write workflow data
131
+shuf_orb --> shuf_be : Execution queue polling
132
+shuf_orb --> shuf_os : Workflow state interactions
133
+
134
+iris_app --> iris_db : Case data
135
+iris_app --> iris_mq : Async jobs
136
+@enduml
137
+```
138
+
139
+## 5) In Progress / Remaining for Customer UAT
81 140
 
82 141
 1. Detection content tuning
83 142
 - Fine-tune Wazuh rules/decoders for customer log patterns and false-positive reduction
@@ -97,13 +156,13 @@ Endpoint checks:
97 156
 - Rotate default/local secrets used in lab config
98 157
 - Lock down internal API keys and access boundaries
99 158
 
100
-## 5) Risks / Notes
159
+## 6) Risks / Notes
101 160
 
102 161
 - Current escalation target is **PagerDuty Stub** by design for MVP.
103 162
   Real PagerDuty production integration is the next stage.
104 163
 - Some Wazuh config certificate directories are root-owned in the local lab clone, which may affect local git add operations if not excluded/fixed.
105 164
 
106
-## 6) Next Milestone (Proposed)
165
+## 7) Next Milestone (Proposed)
107 166
 
108 167
 Next milestone: **MVP UAT Completion**
109 168
 
@@ -112,4 +171,3 @@ Target outputs:
112 171
 - Tuned policy thresholds for customer environment
113 172
 - Signed-off incident lifecycle flow:
114 173
   Wazuh event -> soc-integrator decision -> IRIS case -> PagerDuty Stub escalation
115
-

BIN
progress-update.pdf