e>- 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,

tum/soc - Gogs: Simplico Git Service

暫無描述

bootstrap-datetime.css 4.8KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. /*!
  2. * Datepicker for Bootstrap
  3. *
  4. * Copyright 2012 Stefan Petre
  5. * Licensed under the Apache License v2.0
  6. * http://www.apache.org/licenses/LICENSE-2.0
  7. *
  8. */
  9. .datepicker {
  10. top: 0;
  11. left: 0;
  12. padding: 4px;
  13. margin-top: 1px;
  14. -webkit-border-radius: 4px;
  15. -moz-border-radius: 4px;
  16. border-radius: 4px;
  17. /*.dow {
  18. border-top: 1px solid #ddd !important;
  19. }*/
  20. }
  21. .datepicker:before {
  22. content: '';
  23. display: inline-block;
  24. border-left: 7px solid transparent;
  25. border-right: 7px solid transparent;
  26. border-bottom: 7px solid #ccc;
  27. border-bottom-color: rgba(0, 0, 0, 0.2);
  28. position: absolute;
  29. top: -7px;
  30. left: 6px;
  31. }
  32. .datepicker:after {
  33. content: '';
  34. display: inline-block;
  35. border-left: 6px solid transparent;
  36. border-right: 6px solid transparent;
  37. border-bottom: 6px solid #ffffff;
  38. position: absolute;
  39. top: -6px;
  40. left: 7px;
  41. }
  42. .datepicker > div {
  43. display: none;
  44. }
  45. .datepicker table {
  46. width: 100%;
  47. margin: 0;
  48. }
  49. .datepicker td,
  50. .datepicker th {
  51. text-align: center;
  52. width: 20px;
  53. height: 20px;
  54. -webkit-border-radius: 4px;
  55. -moz-border-radius: 4px;
  56. border-radius: 4px;
  57. }
  58. .datepicker td.day:hover {
  59. background: #eeeeee;
  60. cursor: pointer;
  61. }
  62. .datepicker td.day.disabled {
  63. color: #eeeeee;
  64. }
  65. .datepicker td.old,
  66. .datepicker td.new {
  67. color: #999999;
  68. }
  69. .datepicker td.active,
  70. .datepicker td.active:hover {
  71. color: #ffffff;
  72. background-color: #006dcc;
  73. background-image: -moz-linear-gradient(top, #0088cc, #0044cc);
  74. background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0044cc));
  75. background-image: -webkit-linear-gradient(top, #0088cc, #0044cc);
  76. background-image: -o-linear-gradient(top, #0088cc, #0044cc);
  77. background-image: linear-gradient(to bottom, #0088cc, #0044cc);
  78. background-repeat: repeat-x;
  79. filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0088cc', endColorstr='#ff0044cc', GradientType=0);
  80. border-color: #0044cc #0044cc #002a80;
  81. border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);
  82. *background-color: #0044cc;
  83. /* Darken IE7 buttons by default so they stand out more given they won't have borders */
  84. filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
  85. color: #fff;
  86. text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
  87. }
  88. .datepicker td.active:hover,
  89. .datepicker td.active:hover:hover,
  90. .datepicker td.active:focus,
  91. .datepicker td.active:hover:focus,
  92. .datepicker td.active:active,
  93. .datepicker td.active:hover:active,
  94. .datepicker td.active.active,
  95. .datepicker td.active:hover.active,
  96. .datepicker td.active.disabled,
  97. .datepicker td.active:hover.disabled,
  98. .datepicker td.active[disabled],
  99. .datepicker td.active:hover[disabled] {
  100. color: #ffffff;
  101. background-color: #0044cc;
  102. *background-color: #003bb3;
  103. }
  104. .datepicker td.active:active,
  105. .datepicker td.active:hover:active,
  106. .datepicker td.active.active,
  107. .datepicker td.active:hover.active {
  108. background-color: #003399 \9;
  109. }
  110. .datepicker td span {
  111. display: block;
  112. width: 47px;
  113. height: 54px;
  114. line-height: 54px;
  115. float: left;
  116. margin: 2px;
  117. cursor: pointer;
  118. -webkit-border-radius: 4px;
  119. -moz-border-radius: 4px;
  120. border-radius: 4px;
  121. }
  122. .datepicker td span:hover {
  123. background: #eeeeee;
  124. }
  125. .datepicker td span.active {
  126. color: #ffffff;
  127. background-color: #006dcc;
  128. background-image: -moz-linear-gradient(top, #0088cc, #0044cc);
  129. background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0044cc));
  130. background-image: -webkit-linear-gradient(top, #0088cc, #0044cc);
  131. background-image: -o-linear-gradient(top, #0088cc, #0044cc);
  132. background-image: linear-gradient(to bottom, #0088cc, #0044cc);
  133. background-repeat: repeat-x;
  134. filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0088cc', endColorstr='#ff0044cc', GradientType=0);
  135. border-color: #0044cc #0044cc #002a80;
  136. border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);
  137. *background-color: #0044cc;
  138. /* Darken IE7 buttons by default so they stand out more given they won't have borders */
  139. filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
  140. color: #fff;
  141. text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
  142. }
  143. .datepicker td span.active:hover,
  144. .datepicker td span.active:focus,
  145. .datepicker td span.active:active,
  146. .datepicker td span.active.active,
  147. .datepicker td span.active.disabled,
  148. .datepicker td span.active[disabled] {
  149. color: #ffffff;
  150. background-color: #0044cc;
  151. *background-color: #003bb3;
  152. }
  153. .datepicker td span.active:active,
  154. .datepicker td span.active.active {
  155. background-color: #003399 \9;
  156. }
  157. .datepicker td span.old {
  158. color: #999999;
  159. }
  160. .datepicker th.switch {
  161. width: 145px;
  162. }
  163. .datepicker th.next,
  164. .datepicker th.prev {
  165. font-size: 21px;
  166. }
  167. .datepicker thead tr:first-child th {
  168. cursor: pointer;
  169. }
  170. .datepicker thead tr:first-child th:hover {
  171. background: #eeeeee;
  172. }
  173. .input-append.date .add-on i,
  174. .input-prepend.date .add-on i {
  175. display: block;
  176. cursor: pointer;
  177. width: 16px;
  178. height: 16px;
  179. }