tum %!s(int64=5) %!d(string=před) měsíci
rodič
revize
0a495327f3

+ 21 - 0
admin_frontend/templates/admin_frontend/base.html

@@ -102,6 +102,27 @@
102 102
                       </ul>
103 103
                   </div>
104 104
               </div>
105
+
106
+              <div x-data="{ open: {% if '/webadmin/frontend/' in request.path %}true{% else %}false{% endif %} }">
107
+                  <div @click="open = !open" class="flex items-center justify-between rounded px-3 py-2 hover:bg-gray-50 cursor-pointer">
108
+                      <span class="truncate">Frontend</span>
109
+                      <svg :class="{'rotate-180': open}" class="w-4 h-4 transform transition-transform" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
110
+                          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
111
+                      </svg>
112
+                  </div>
113
+                  <div x-show="open" x-cloak class="pl-4">
114
+                      {% app_menu 'public_frontend_admin' as pf_menu_items %}
115
+                      <ul class="space-y-1">
116
+                        {% for it in pf_menu_items %}
117
+                          <li>
118
+                            <a href="{{ it.url }}" class="flex items-center justify-between rounded px-3 py-2 hover:bg-gray-50 {% if request.path == it.url %}bg-blue-50 text-blue-700{% else %}text-gray-700{% endif %}">
119
+                              <span class="truncate">{{ it.label }}</span>
120
+                            </a>
121
+                          </li>
122
+                        {% endfor %}
123
+                      </ul>
124
+                  </div>
125
+              </div>
105 126
               <div x-data="{ open: {% if '/webadmin/orgs/' in request.path %}true{% else %}false{% endif %} }">
106 127
                   <div @click="open = !open" class="flex items-center justify-between rounded px-3 py-2 hover:bg-gray-50 cursor-pointer">
107 128
                       <span class="truncate">Organization</span>

+ 103 - 0
admin_frontend/templates/admin_frontend/lead_detail.html

@@ -0,0 +1,103 @@
1
+{% extends 'admin_frontend/base.html' %}
2
+{% block title %}Lead #{{ lead.id }}{% endblock %}
3
+{% block content %}
4
+  <div class="flex items-center justify-between mb-4">
5
+    <h1 class="text-2xl font-semibold">Lead Details</h1>
6
+    <a href="{% url 'public_frontend_admin:leads_list' %}" class="btn-outline">Back</a>
7
+  </div>
8
+
9
+  <div class="bg-white rounded shadow p-6 space-y-4">
10
+    <div>
11
+      <div class="text-sm text-gray-500">Created</div>
12
+      <div class="font-medium">{{ lead.created_at }}</div>
13
+    </div>
14
+    <div>
15
+      <div class="text-sm text-gray-500">Organization</div>
16
+      <div class="font-medium">{{ lead.organization.name }} <span class="text-gray-400">({{ lead.organization.code }})</span></div>
17
+    </div>
18
+    <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
19
+      <div>
20
+        <div class="text-sm text-gray-500">Name</div>
21
+        <div class="font-medium">{{ lead.name }}</div>
22
+      </div>
23
+      <div>
24
+        <div class="text-sm text-gray-500">Source</div>
25
+        <div class="font-medium">{{ lead.get_source_display|default:'-' }}</div>
26
+      </div>
27
+      <div>
28
+        <div class="text-sm text-gray-500">Email</div>
29
+        <div class="font-medium">{{ lead.email|default:'-' }}</div>
30
+      </div>
31
+      <div>
32
+        <div class="text-sm text-gray-500">Phone</div>
33
+        <div class="font-medium">{{ lead.phone|default:'-' }}</div>
34
+      </div>
35
+    </div>
36
+    {% if lead.subject %}
37
+    <div>
38
+      <div class="text-sm text-gray-500">Subject</div>
39
+      <div class="font-medium">{{ lead.subject }}</div>
40
+    </div>
41
+    {% endif %}
42
+    {% if lead.message %}
43
+    <div>
44
+      <div class="text-sm text-gray-500">Message</div>
45
+      <div class="whitespace-pre-line">{{ lead.message }}</div>
46
+    </div>
47
+    {% endif %}
48
+  </div>
49
+
50
+  <div class="bg-white rounded shadow p-6 space-y-4 mt-6">
51
+    <h2 class="text-xl font-semibold">Uploaded Files</h2>
52
+    {% if documents %}
53
+      <ul class="divide-y divide-gray-100">
54
+        {% for d in documents %}
55
+          <li class="py-3 flex items-center justify-between">
56
+            <div>
57
+              <div class="font-medium">
58
+                <a href="{{ d.file.url }}" target="_blank" class="text-blue-700 hover:underline">
59
+                  {{ d.file.name|cut:"media/"|cut:"/" }}
60
+                </a>
61
+              </div>
62
+              <div class="text-sm text-gray-500">{{ d.kind|default:'file' }} • {{ d.created_at }}</div>
63
+            </div>
64
+            <div>
65
+              <a href="{{ d.file.url }}" target="_blank" class="btn-outline">View</a>
66
+            </div>
67
+          </li>
68
+        {% endfor %}
69
+      </ul>
70
+    {% else %}
71
+      <p class="text-gray-500">No files uploaded for this lead.</p>
72
+    {% endif %}
73
+  </div>
74
+
75
+  <div class="bg-white rounded shadow p-6 space-y-4 mt-6">
76
+    <h2 class="text-xl font-semibold">Create Pickup</h2>
77
+    <form method="post" action="{% url 'public_frontend_admin:lead_convert_to_pickup' lead.pk %}">
78
+      {% csrf_token %}
79
+      <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
80
+        <div class="md:col-span-2">
81
+          <label class="block text-sm text-gray-600 mb-1">Address</label>
82
+          <textarea name="address" rows="3" class="w-full border rounded px-3 py-2" placeholder="Pickup address" required>{{ suggested.address }}</textarea>
83
+        </div>
84
+        <div>
85
+          <label class="block text-sm text-gray-600 mb-1">Scheduled At (optional)</label>
86
+          <input type="datetime-local" name="scheduled_at" class="w-full border rounded px-3 py-2" value="">
87
+        </div>
88
+        <div>
89
+          <label class="block text-sm text-gray-600 mb-1">Assign Driver (optional)</label>
90
+          <select name="driver" class="w-full border rounded px-3 py-2">
91
+            <option value="">-- Select driver --</option>
92
+            {% for u in drivers %}
93
+              <option value="{{ u.id }}">{{ u.username }}</option>
94
+            {% endfor %}
95
+          </select>
96
+        </div>
97
+      </div>
98
+      <div class="mt-4 flex justify-end">
99
+        <button class="px-4 py-2 bg-blue-600 text-white rounded">Create Pickup</button>
100
+      </div>
101
+    </form>
102
+  </div>
103
+{% endblock %}

+ 56 - 0
admin_frontend/templates/admin_frontend/leads_list.html

@@ -0,0 +1,56 @@
1
+{% extends 'admin_frontend/base.html' %}
2
+{% block title %}Leads{% endblock %}
3
+{% block content %}
4
+<div class="flex items-center justify-between mb-4">
5
+  <h1 class="text-2xl font-semibold">Leads</h1>
6
+</div>
7
+
8
+<div class="bg-white rounded shadow p-4 mb-4">
9
+  {% load crispy_forms_tags %}
10
+  <form method="get" class="space-y-3 flex gap-2 items-end flex-wrap">
11
+    {{ filter.form|crispy }}
12
+    <div class="flex items-center gap-2">
13
+      <button class="px-3 py-2 bg-blue-600 text-white rounded">Filter</button>
14
+      <a href="{% url 'public_frontend_admin:leads_list' %}" class="btn-outline">Reset</a>
15
+    </div>
16
+  </form>
17
+  {% if filter.qs.count != page_obj.paginator.count %}
18
+    <p class="text-sm text-gray-500 mt-2">Filtered results: {{ page_obj.paginator.count }} items</p>
19
+  {% endif %}
20
+  </div>
21
+
22
+<div class="bg-white rounded shadow overflow-hidden">
23
+  <table class="min-w-full divide-y divide-gray-200">
24
+    <thead class="bg-gray-50">
25
+      <tr>
26
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Created</th>
27
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Organization</th>
28
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Name</th>
29
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Contact</th>
30
+        <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Source</th>
31
+        <th class="px-4 py-2"></th>
32
+      </tr>
33
+    </thead>
34
+    <tbody class="divide-y divide-gray-200">
35
+      {% for l in leads %}
36
+      <tr>
37
+        <td class="px-4 py-2 text-gray-500 whitespace-nowrap">{{ l.created_at }}</td>
38
+        <td class="px-4 py-2">{{ l.organization.name }} <span class="text-gray-400">({{ l.organization.code }})</span></td>
39
+        <td class="px-4 py-2 font-medium">{{ l.name }}</td>
40
+        <td class="px-4 py-2">
41
+          {% if l.email %}<div class="text-sm">{{ l.email }}</div>{% endif %}
42
+          {% if l.phone %}<div class="text-sm">{{ l.phone }}</div>{% endif %}
43
+        </td>
44
+        <td class="px-4 py-2">{{ l.get_source_display|default:'-' }}</td>
45
+        <td class="px-4 py-2 text-right">
46
+          <a class="btn-outline" href="{% url 'public_frontend_admin:lead_detail' l.pk %}">View</a>
47
+        </td>
48
+      </tr>
49
+      {% empty %}
50
+      <tr><td class="px-4 py-4 text-center text-gray-500" colspan="6">No leads found.</td></tr>
51
+      {% endfor %}
52
+    </tbody>
53
+  </table>
54
+  {% include 'admin_frontend/_pagination.html' %}
55
+  </div>
56
+{% endblock %}

+ 1 - 0
admin_frontend/urls.py

@@ -21,6 +21,7 @@ urlpatterns = [
21 21
     path("introduction-requests/<int:pk>/edit/", views.intro_request_edit, name="intro_request_edit"),
22 22
     path("introduction-requests/<int:pk>/delete/", views.intro_request_delete, name="intro_request_delete"),
23 23
 
24
+    # Leads (from public_frontend)
24 25
     path("leaderboard/", views.leaderboard, name="leaderboard"),
25 26
     path("profiles/<int:pk>/toggle-verify/", views.profile_toggle_verify, name="profile_toggle_verify"),
26 27
     path("introduction-requests/<int:pk>/accept/", views.intro_request_accept, name="intro_request_accept"),

+ 3 - 0
admin_frontend/views.py

@@ -390,3 +390,6 @@ def intro_request_delete(request, pk: int):
390 390
 
391 391
 
392 392
 ## Recycle Core admin UI moved to recycle_core app
393
+
394
+
395
+## Leads UI moved to public_frontend/views_admin.py

+ 3 - 1
ecoloop/settings.py

@@ -73,6 +73,7 @@ INSTALLED_APPS = [
73 73
     'allauth.socialaccount.providers.google',
74 74
     'allauth.socialaccount.providers.facebook',
75 75
     'allauth.socialaccount.providers.line',
76
+    'widget_tweaks',
76 77
     'orgs',
77 78
     'public_frontend',
78 79
     'theme',
@@ -198,7 +199,8 @@ AUTH_PASSWORD_VALIDATORS = [
198 199
 
199 200
 LANGUAGE_CODE = 'en-us'
200 201
 
201
-TIME_ZONE = 'UTC'
202
+# Set application timezone to Bangkok (Asia/Bangkok)
203
+TIME_ZONE = 'Asia/Bangkok'
202 204
 
203 205
 USE_I18N = True
204 206
 

+ 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/frontend/', include('public_frontend.urls_admin')),
33 34
     path('webadmin/settings/', include('settings.urls')),
34 35
     path('webadmin/cms/', include('cms.urls')),
35 36
     path('webadmin/recycle/', include('recycle_core.urls')),

+ 22 - 3
public_frontend/forms.py

@@ -3,9 +3,28 @@ from __future__ import annotations
3 3
 from django import forms
4 4
 
5 5
 
6
+class MultiFileInput(forms.ClearableFileInput):
7
+    allow_multiple_selected = True
8
+
9
+
10
+class MultiFileField(forms.Field):
11
+    widget = MultiFileInput
12
+
13
+    def __init__(self, *args, **kwargs):
14
+        kwargs.setdefault("required", False)
15
+        super().__init__(*args, **kwargs)
16
+
17
+    def to_python(self, data):
18
+        # Accept a list of UploadedFile or an empty list/None
19
+        return data
20
+
21
+    def validate(self, value):
22
+        # Only enforce presence if required=True
23
+        if self.required and not value:
24
+            raise forms.ValidationError("This field is required.")
25
+
26
+
6 27
 class PickupRequestForm(forms.Form):
7
-    class MultiFileInput(forms.ClearableFileInput):
8
-        allow_multiple_selected = True
9 28
 
10 29
     name = forms.CharField(max_length=255)
11 30
     email = forms.EmailField(required=False)
@@ -13,7 +32,7 @@ class PickupRequestForm(forms.Form):
13 32
     address = forms.CharField(widget=forms.Textarea)
14 33
     preferred_at = forms.DateTimeField(required=False, widget=forms.DateTimeInput(attrs={"type": "datetime-local"}))
15 34
     materials = forms.CharField(label="Materials/Notes", widget=forms.Textarea, required=False)
16
-    photos = forms.FileField(required=False, widget=MultiFileInput, help_text="Optional: upload photos of scrap")
35
+    photos = MultiFileField(widget=MultiFileInput, help_text="Optional: upload photos of scrap")
17 36
 
18 37
 
19 38
 class ContactForm(forms.Form):

+ 18 - 0
public_frontend/migrations/0002_alter_lead_source.py

@@ -0,0 +1,18 @@
1
+# Generated by Django 4.2.24 on 2025-09-23 09:30
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('public_frontend', '0001_initial'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.AlterField(
14
+            model_name='lead',
15
+            name='source',
16
+            field=models.CharField(blank=True, choices=[('contact', 'Contact'), ('pickup_request', 'Pickup Request'), ('listing_request', 'Listing Request'), ('other', 'Other')], max_length=64),
17
+        ),
18
+    ]

+ 6 - 2
public_frontend/models.py

@@ -5,15 +5,19 @@ from orgs.models import Organization
5 5
 
6 6
 
7 7
 class Lead(models.Model):
8
+    class LeadSource(models.TextChoices):
9
+        CONTACT = "contact", "Contact"
10
+        PICKUP_REQUEST = "pickup_request", "Pickup Request"
11
+        LISTING_REQUEST = "listing_request", "Listing Request"
12
+        OTHER = "other", "Other"
8 13
     organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="leads")
9 14
     name = models.CharField(max_length=255)
10 15
     email = models.EmailField(blank=True)
11 16
     phone = models.CharField(max_length=64, blank=True)
12 17
     subject = models.CharField(max_length=255, blank=True)
13 18
     message = models.TextField(blank=True)
14
-    source = models.CharField(max_length=64, blank=True)
19
+    source = models.CharField(max_length=64, blank=True, choices=LeadSource.choices)
15 20
     created_at = models.DateTimeField(auto_now_add=True)
16 21
 
17 22
     def __str__(self) -> str:  # pragma: no cover
18 23
         return f"Lead {self.name} ({self.organization.code})"
19
-

+ 11 - 1
public_frontend/templates/public_frontend/base.html

@@ -1,4 +1,4 @@
1
-{% load tailwind_tags %}
1
+{% load tailwind_tags static %}
2 2
 <!DOCTYPE html>
3 3
 <html lang="en">
4 4
 <head>
@@ -6,6 +6,7 @@
6 6
   <meta name="viewport" content="width=device-width, initial-scale=1" />
7 7
   <title>{% if current_site %}{{ current_site.name }}{% else %}Ecoloop{% endif %} — {% block title %}{% endblock %}</title>
8 8
   {% tailwind_css %}
9
+  <script defer src="{% static 'alpinejs/dist/cdn.min.js' %}"></script>
9 10
 </head>
10 11
 <body class="bg-gray-50 text-gray-900">
11 12
   <header class="bg-white shadow">
@@ -21,6 +22,15 @@
21 22
     </div>
22 23
   </header>
23 24
   <main class="mx-auto max-w-7xl p-4 sm:p-6 lg:p-8 min-h-[80vh]">
25
+    {% if messages %}
26
+      <div id="messages" class="max-w-3xl mx-auto mb-4 space-y-2">
27
+        {% for message in messages %}
28
+          <div class="rounded border px-4 py-3 shadow {% if 'success' in message.tags %}bg-emerald-50 border-emerald-200 text-emerald-800{% elif 'error' in message.tags %}bg-rose-50 border-rose-200 text-rose-800{% elif 'warning' in message.tags %}bg-amber-50 border-amber-200 text-amber-900{% else %}bg-sky-50 border-sky-200 text-sky-900{% endif %}">
29
+            {{ message }}
30
+          </div>
31
+        {% endfor %}
32
+      </div>
33
+    {% endif %}
24 34
     {% block content %}{% endblock %}
25 35
   </main>
26 36
   <footer class="bg-white border-t">

+ 2 - 37
public_frontend/templates/public_frontend/home.html

@@ -9,7 +9,7 @@
9 9
     <h1 class="text-3xl md:text-4xl font-bold mb-2">Streamline Your Factory's Recycling</h1>
10 10
     <p class="text-gray-600 max-w-2xl mx-auto mb-6">We partner with businesses like yours to manage scrap materials efficiently. Browse listings, request a pickup, and turn your waste into a resource.</p>
11 11
     <div class="flex gap-3 justify-center">
12
-      <a href="#pickup-request" class="btn-primary">Request a Pickup</a>
12
+      <a href="{% url 'public_frontend:pickup_request' %}" class="btn-primary">Request a Pickup</a>
13 13
       <a href="#listings" class="btn-outline">Browse Listings</a>
14 14
     </div>
15 15
   </section>
@@ -73,42 +73,7 @@
73 73
     </div>
74 74
   </section>
75 75
 
76
-  {# Pickup Request Section #}
77
-  <section id="pickup-request">
78
-    <h2 class="text-2xl font-semibold mb-3">Request a Pickup</h2>
79
-    <form method="post" enctype="multipart/form-data" action="{% url 'public_frontend:pickup_request' %}" class="bg-white rounded-lg shadow-md p-4 grid gap-4">
80
-      {% csrf_token %}
81
-      <div>
82
-        <label class="block text-sm font-medium mb-1">Name</label>
83
-        <input type="text" name="name" value="{{ pickup_form.name.value|default:'' }}" required class="w-full border rounded px-3 py-2">
84
-      </div>
85
-      <div class="grid md:grid-cols-2 gap-4">
86
-        <div>
87
-          <label class="block text-sm font-medium mb-1">Email</label>
88
-          <input type="email" name="email" value="{{ pickup_form.email.value|default:'' }}" class="w-full border rounded px-3 py-2">
89
-        </div>
90
-        <div>
91
-          <label class="block text-sm font-medium mb-1">Phone</label>
92
-          <input type="text" name="phone" value="{{ pickup_form.phone.value|default:'' }}" class="w-full border rounded px-3 py-2">
93
-        </div>
94
-      </div>
95
-      <div>
96
-        <label class="block text-sm font-medium mb-1">Pickup Address</label>
97
-        <textarea name="address" required class="w-full border rounded px-3 py-2" rows="3">{{ pickup_form.address.value|default:'' }}</textarea>
98
-      </div>
99
-      <div>
100
-        <label class="block text-sm font-medium mb-1">Materials / Notes</label>
101
-        <textarea name="materials" class="w-full border rounded px-3 py-2" rows="4">{{ pickup_form.materials.value|default:'' }}</textarea>
102
-      </div>
103
-      <div>
104
-        <label class="block text-sm font-medium mb-1">Photos (optional)</label>
105
-        <input type="file" name="photos" multiple accept="image/*" class="w-full border rounded px-3 py-2">
106
-      </div>
107
-      <div>
108
-        <button class="btn-primary" type="submit">Submit Request</button>
109
-      </div>
110
-    </form>
111
-  </section>
76
+  {# Pickup Request Section removed; use dedicated page instead #}
112 77
 
113 78
   {# Blog Section #}
114 79
   <section id="blog">

+ 82 - 37
public_frontend/templates/public_frontend/pickup_request.html

@@ -1,46 +1,91 @@
1 1
 {% extends 'public_frontend/base.html' %}
2 2
 {% block title %}Request Pickup{% endblock %}
3
+{% load crispy_forms_tags widget_tweaks %}
3 4
 {% block content %}
4 5
 <h1 class="text-xl font-semibold mb-4">Request a Pickup</h1>
5
-<form method="post" enctype="multipart/form-data" class="bg-white rounded shadow p-4 grid gap-4">
6
-  {% csrf_token %}
7
-  <div>
8
-    <label class="block text-sm font-medium mb-1">Name</label>
9
-    <input type="text" name="name" value="{{ form.name.value|default:'' }}" required class="w-full border rounded px-3 py-2">
10
-    {% if form.name.errors %}<p class="text-red-600 text-sm">{{ form.name.errors.0 }}</p>{% endif %}
11
-  </div>
12
-  <div class="grid md:grid-cols-2 gap-4">
13
-    <div>
14
-      <label class="block text-sm font-medium mb-1">Email</label>
15
-      <input type="email" name="email" value="{{ form.email.value|default:'' }}" class="w-full border rounded px-3 py-2">
16
-    </div>
6
+<div x-data="pickupForm()">
7
+  <div class="bg-white rounded shadow p-4" >
8
+    <form x-ref="form" id="pickup-form" method="post" enctype="multipart/form-data" class="space-y-4">
9
+      {% csrf_token %}
10
+      {{ form.name|attr:"x-ref:name"|as_crispy_field }}
11
+      <div class="grid md:grid-cols-2 gap-4">
12
+        <div>
13
+          {{ form.email|attr:"x-ref:email"|as_crispy_field }}
14
+        </div>
17 15
     <div>
18
-      <label class="block text-sm font-medium mb-1">Phone</label>
19
-      <input type="text" name="phone" value="{{ form.phone.value|default:'' }}" class="w-full border rounded px-3 py-2">
16
+      {{ form.phone|attr:"x-ref:phone"|as_crispy_field }}
20 17
     </div>
18
+      </div>
19
+      {{ form.address|attr:"x-ref:address"|as_crispy_field }}
20
+      <div class="grid md:grid-cols-2 gap-4">
21
+        <div>
22
+          {{ form.preferred_at|attr:"x-ref:preferred_at"|as_crispy_field }}
23
+        </div>
24
+      </div>
25
+      {{ form.materials|attr:"x-ref:materials"|as_crispy_field }}
26
+      {{ form.photos|attr:"x-ref:photos"|as_crispy_field }}
27
+      <div>
28
+        <button id="open-review" class="btn btn-primary" type="button" @click="openReview">Review &amp; Submit</button>
29
+      </div>
30
+    </form>
21 31
   </div>
22
-  <div>
23
-    <label class="block text-sm font-medium mb-1">Pickup Address</label>
24
-    <textarea name="address" required class="w-full border rounded px-3 py-2" rows="3">{{ form.address.value|default:'' }}</textarea>
25
-    {% if form.address.errors %}<p class="text-red-600 text-sm">{{ form.address.errors.0 }}</p>{% endif %}
26
-  </div>
27
-  <div class="grid md:grid-cols-2 gap-4">
28
-    <div>
29
-      <label class="block text-sm font-medium mb-1">Preferred Date/Time</label>
30
-      <input type="datetime-local" name="preferred_at" value="{{ form.preferred_at.value|default:'' }}" class="w-full border rounded px-3 py-2">
32
+
33
+  <dialog x-ref="dialog" id="review_modal" class="modal dark:bg-black/40">
34
+    <div class="modal-box dark:bg-gray-900 dark:text-gray-100 dark:border dark:border-gray-700">
35
+      <h3 class="font-bold text-lg">Review Your Pickup Request</h3>
36
+      <div class="py-4 space-y-3">
37
+        <div><div class="text-sm text-gray-600 dark:text-gray-300">Name</div><div class="font-medium" x-text="rv.name || '—'"></div></div>
38
+        <div class="grid md:grid-cols-2 gap-4">
39
+          <div><div class="text-sm text-gray-600 dark:text-gray-300">Email</div><div class="font-medium" x-text="rv.email || '—'"></div></div>
40
+          <div><div class="text-sm text-gray-600 dark:text-gray-300">Phone</div><div class="font-medium" x-text="rv.phone || '—'"></div></div>
41
+        </div>
42
+        <div><div class="text-sm text-gray-600 dark:text-gray-300">Pickup Address</div><div class="whitespace-pre-line" x-text="rv.address || '—'"></div></div>
43
+        <div class="grid md:grid-cols-2 gap-4">
44
+          <div><div class="text-sm text-gray-600 dark:text-gray-300">Preferred Date/Time</div><div class="font-medium" x-text="rv.preferred_at || '—'"></div></div>
45
+        </div>
46
+        <div><div class="text-sm text-gray-600 dark:text-gray-300">Materials / Notes</div><div class="whitespace-pre-line" x-text="rv.materials || '—'"></div></div>
47
+        <div>
48
+          <div class="text-sm text-gray-600 dark:text-gray-300">Photos</div>
49
+          <ul class="list-disc list-inside text-sm text-gray-700 dark:text-gray-300">
50
+            <template x-if="(rv.photos||[]).length === 0"><li>No files attached.</li></template>
51
+            <template x-for="fname in rv.photos" :key="fname"><li x-text="fname"></li></template>
52
+          </ul>
53
+        </div>
54
+      </div>
55
+      <div class="modal-action">
56
+        <button class="btn" @click="$refs.dialog.close()">Edit</button>
57
+        <button class="btn btn-primary" @click.prevent="confirm">Confirm &amp; Send</button>
58
+      </div>
31 59
     </div>
32
-  </div>
33
-  <div>
34
-    <label class="block text-sm font-medium mb-1">Materials / Notes</label>
35
-    <textarea name="materials" class="w-full border rounded px-3 py-2" rows="4">{{ form.materials.value|default:'' }}</textarea>
36
-  </div>
37
-  <div>
38
-    <label class="block text-sm font-medium mb-1">Photos (optional)</label>
39
-    <input type="file" name="photos" multiple accept="image/*" class="w-full border rounded px-3 py-2">
40
-  </div>
41
-  <div>
42
-    <button class="btn-primary" type="submit">Submit Request</button>
43
-  </div>
44
-</form>
45
-<style>.btn-primary{background:#1d4ed8;color:white;padding:.5rem .75rem;border-radius:.375rem}</style>
60
+    <form method="dialog" class="modal-backdrop dark:bg-black/60">
61
+      <button class="dark:text-gray-200">close</button>
62
+    </form>
63
+  </dialog>
64
+</div>
65
+<script>
66
+  function pickupForm(){
67
+    return {
68
+      rv: {name:'', email:'', phone:'', address:'', preferred_at:'', materials:'', photos: []},
69
+      openReview(){
70
+        console.log(this.$refs.form)
71
+        const fd = new FormData(this.$refs.form);
72
+        this.rv.name = fd.get('name') || '';
73
+        this.rv.email = fd.get('email') || '';
74
+        this.rv.phone = fd.get('phone') || '';
75
+        this.rv.address = fd.get('address') || '';
76
+        this.rv.preferred_at = fd.get('preferred_at') || '';
77
+        this.rv.materials = fd.get('materials') || '';
78
+        const input = this.$refs.photos;
79
+        this.rv.photos = input && input.files ? Array.from(input.files).map(f => f.name) : [];
80
+        console.log(this.$refs)
81
+        if(this.$refs.dialog.showModal){ this.$refs.dialog.showModal(); }
82
+        else { this.$refs.dialog.setAttribute('open','open'); }
83
+      },
84
+      confirm(){
85
+        if(this.$refs.dialog.close){ this.$refs.dialog.close(); } else { this.$refs.dialog.removeAttribute('open'); }
86
+        this.$refs.form.submit();
87
+      }
88
+    }
89
+  }
90
+</script>
46 91
 {% endblock %}

+ 11 - 0
public_frontend/urls_admin.py

@@ -0,0 +1,11 @@
1
+from django.urls import path
2
+
3
+from . import views_admin as views
4
+
5
+app_name = "public_frontend_admin"
6
+
7
+urlpatterns = [
8
+    path("leads/", views.leads_list, name="leads_list"),
9
+    path("leads/<int:pk>/", views.lead_detail, name="lead_detail"),
10
+    path("leads/<int:pk>/convert-to-pickup/", views.lead_convert_to_pickup, name="lead_convert_to_pickup"),
11
+]

+ 2 - 1
public_frontend/views.py

@@ -125,7 +125,8 @@ def pickup_request(request):
125 125
                 return redirect("public_frontend:home")
126 126
             else:
127 127
                 messages.error(request, result.error or "Unable to submit your request.")
128
-        messages.error(request, "Please correct the errors below.")
128
+        else:
129
+            messages.error(request, "Please correct the errors below.")
129 130
     return render(request, "public_frontend/pickup_request.html", {"form": form, "org": org})
130 131
 
131 132
 

+ 189 - 0
public_frontend/views_admin.py

@@ -0,0 +1,189 @@
1
+from __future__ import annotations
2
+
3
+from django.shortcuts import render, get_object_or_404, redirect
4
+from django.contrib.admin.views.decorators import staff_member_required
5
+from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
6
+import django_filters as filters
7
+from django.contrib import messages
8
+from django.urls import reverse
9
+from django.contrib.auth import get_user_model
10
+from datetime import datetime
11
+from django.contrib.contenttypes.models import ContentType
12
+
13
+from admin_frontend.templatetags.public_urls import public_route
14
+from admin_frontend.nav import _nav_items
15
+from .models import Lead
16
+
17
+
18
+@staff_member_required
19
+@public_route(label="Leads", order=22, icon="envelope")
20
+def leads_list(request):
21
+    """List Leads captured from the public site with filtering and pagination."""
22
+
23
+    class LeadFilter(filters.FilterSet):
24
+        name = filters.CharFilter(field_name="name", lookup_expr="icontains", label="Name")
25
+        email = filters.CharFilter(field_name="email", lookup_expr="icontains", label="Email")
26
+        phone = filters.CharFilter(field_name="phone", lookup_expr="icontains", label="Phone")
27
+        org = filters.CharFilter(field_name="organization__code", lookup_expr="icontains", label="Org Code")
28
+        source = filters.ChoiceFilter(field_name="source", label="Source", choices=Lead.LeadSource.choices)
29
+        created_at = filters.DateTimeFromToRangeFilter(
30
+            field_name="created_at",
31
+            label="Created between",
32
+            widget=filters.widgets.RangeWidget(
33
+                attrs={
34
+                    "type": "datetime-local",
35
+                    "style": "color-scheme: light;",
36
+                    "class": "border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
37
+                }
38
+            ),
39
+        )
40
+
41
+        class Meta:
42
+            model = Lead
43
+            fields = ["name", "email", "phone", "org", "source", "created_at"]
44
+
45
+        def __init__(self, *args, **kwargs):
46
+            super().__init__(*args, **kwargs)
47
+            try:
48
+                from crispy_forms.helper import FormHelper
49
+                from crispy_forms.layout import Layout, Field
50
+                helper = FormHelper()
51
+                helper.form_tag = False
52
+                helper.layout = Layout(
53
+                    Field("name", template="crispy/field_nowrap.html"),
54
+                    Field("email", template="crispy/field_nowrap.html"),
55
+                    Field("phone", template="crispy/field_nowrap.html"),
56
+                    Field("org", template="crispy/field_nowrap.html"),
57
+                    Field("source", template="crispy/field_nowrap.html"),
58
+                    Field("created_at", template="crispy/field_nowrap.html"),
59
+                )
60
+                self.form.helper = helper
61
+            except Exception:
62
+                pass
63
+
64
+    qs = Lead.objects.select_related("organization").order_by("-created_at")
65
+    lead_filter = LeadFilter(request.GET, queryset=qs)
66
+
67
+    paginator = Paginator(lead_filter.qs, 10)
68
+    page = request.GET.get("page")
69
+    try:
70
+        page_obj = paginator.page(page)
71
+    except PageNotAnInteger:
72
+        page_obj = paginator.page(1)
73
+    except EmptyPage:
74
+        page_obj = paginator.page(paginator.num_pages)
75
+
76
+    context = {
77
+        "nav": _nav_items(),
78
+        "leads": page_obj.object_list,
79
+        "page_obj": page_obj,
80
+        "filter": lead_filter,
81
+    }
82
+    return render(request, "admin_frontend/leads_list.html", context)
83
+
84
+
85
+@staff_member_required
86
+def lead_detail(request, pk: int):
87
+    lead = get_object_or_404(Lead.objects.select_related("organization"), pk=pk)
88
+
89
+    def _parse_pickup_details(message: str):
90
+        data = {"address": "", "preferred": "", "materials": ""}
91
+        try:
92
+            for line in (message or "").splitlines():
93
+                if line.lower().startswith("address:"):
94
+                    data["address"] = line.split(":", 1)[1].strip()
95
+                elif line.lower().startswith("preferred:"):
96
+                    data["preferred"] = line.split(":", 1)[1].strip()
97
+                elif line.lower().startswith("materials:"):
98
+                    data["materials"] = line.split(":", 1)[1].strip()
99
+        except Exception:
100
+            pass
101
+        return data
102
+
103
+    suggested = _parse_pickup_details(getattr(lead, "message", "") or "")
104
+    # Fetch any uploaded Documents attached to this Lead
105
+    try:
106
+        from recycle_core.models import Document
107
+        ct = ContentType.objects.get_for_model(Lead)
108
+        documents = Document.objects.filter(content_type=ct, object_id=lead.id).order_by("-created_at")
109
+    except Exception:
110
+        documents = []
111
+
112
+    User = get_user_model()
113
+    drivers = User.objects.all().order_by("username")
114
+    context = {"nav": _nav_items(), "lead": lead, "drivers": drivers, "suggested": suggested, "documents": documents}
115
+    return render(request, "admin_frontend/lead_detail.html", context)
116
+
117
+
118
+@staff_member_required
119
+def lead_convert_to_pickup(request, pk: int):
120
+    lead = get_object_or_404(Lead.objects.select_related("organization"), pk=pk)
121
+    if request.method != "POST":
122
+        return redirect("public_frontend_admin:lead_detail", pk=pk)
123
+
124
+    from recycle_core.models import Customer, CustomerSite, PickupOrder
125
+
126
+    address = (request.POST.get("address") or "").strip()
127
+    scheduled_at_raw = (request.POST.get("scheduled_at") or "").strip()
128
+    driver_id = (request.POST.get("driver") or "").strip()
129
+
130
+    if not address:
131
+        messages.error(request, "Address is required to create a pickup.")
132
+        return redirect("public_frontend_admin:lead_detail", pk=pk)
133
+
134
+    scheduled_at = None
135
+    if scheduled_at_raw:
136
+        try:
137
+            scheduled_at = datetime.fromisoformat(scheduled_at_raw)
138
+        except Exception:
139
+            messages.error(request, "Invalid date/time format for schedule.")
140
+            return redirect("public_frontend_admin:lead_detail", pk=pk)
141
+
142
+    # Find or create customer for this lead under the same organization
143
+    org = lead.organization
144
+    customer = None
145
+    if lead.email:
146
+        customer = Customer.objects.filter(organization=org, email__iexact=lead.email).first()
147
+    if customer is None:
148
+        customer = Customer.objects.filter(organization=org, name__iexact=lead.name).first()
149
+    if customer is None:
150
+        customer = Customer.objects.create(
151
+            organization=org,
152
+            name=lead.name,
153
+            email=lead.email,
154
+            phone=lead.phone,
155
+            billing_address="",
156
+        )
157
+
158
+    # Create a site for the pickup
159
+    site = CustomerSite.objects.create(
160
+        customer=customer,
161
+        name="",
162
+        address=address,
163
+        contact_name=lead.name,
164
+        contact_phone=lead.phone,
165
+        contact_email=lead.email,
166
+    )
167
+
168
+    pickup = PickupOrder.objects.create(
169
+        organization=org,
170
+        customer=customer,
171
+        site=site,
172
+        status=PickupOrder.STATUS_REQUESTED,
173
+        scheduled_at=scheduled_at,
174
+    )
175
+
176
+    # Optionally assign a driver and mark scheduled
177
+    if driver_id:
178
+        try:
179
+            User = get_user_model()
180
+            driver = User.objects.get(pk=int(driver_id))
181
+            pickup.assigned_driver = driver
182
+            if scheduled_at:
183
+                pickup.status = PickupOrder.STATUS_SCHEDULED
184
+            pickup.save(update_fields=["assigned_driver", "status"])
185
+        except Exception:
186
+            pass
187
+
188
+    messages.success(request, f"Created pickup #{pickup.id} for customer '{customer.name}'.")
189
+    return redirect("recycle_core:pickups_list")

+ 1 - 0
requirements.txt

@@ -19,3 +19,4 @@ django-extensions
19 19
 # pygraphviz is preferred; alternatively install pydotplus and graphviz
20 20
 pygraphviz
21 21
 django-npm
22
+django-widget-tweaks

+ 13 - 0
theme/static_src/src/styles.css

@@ -48,4 +48,17 @@
48 48
   [id^="div_id_"]:has(> input[type="radio"]) > label {
49 49
     @apply mb-0;
50 50
   }
51
+
52
+  /* Restore native picker icon that crispy-tailwind may hide via appearance-none */
53
+  input[type="date"], input[type="time"], input[type="datetime-local"] {
54
+    -webkit-appearance: auto;
55
+    appearance: auto;
56
+    color-scheme: light;
57
+    background-color: #fff;
58
+  }
59
+  input[type="datetime-local"]::-webkit-calendar-picker-indicator {
60
+    opacity: 1;
61
+    display: block;
62
+  }
51 63
 }
64
+