update notification + lead and add lead tracking

This commit is contained in:
ismail 2025-05-11 19:23:54 +03:00
parent 03dd57d4d1
commit 993d4bc712
14 changed files with 1022 additions and 134 deletions

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-05-11 14:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0012_alter_customer_dob_alter_customer_national_id'),
]
operations = [
migrations.AddField(
model_name='lead',
name='converted_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -0,0 +1,50 @@
# Generated by Django 5.1.7 on 2025-05-11 14:26
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0013_lead_converted_at'),
]
operations = [
migrations.AddField(
model_name='lead',
name='is_converted',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='lead',
name='status',
field=models.CharField(choices=[('new', 'New'), ('follow_up', 'Needs Follow-up'), ('negotiation', 'Under Negotiation'), ('won', 'Converted'), ('lost', 'Lost'), ('closed', 'Closed')], db_index=True, default='new', max_length=50, verbose_name='Status'),
),
migrations.AlterField(
model_name='leadstatushistory',
name='new_status',
field=models.CharField(choices=[('new', 'New'), ('follow_up', 'Needs Follow-up'), ('negotiation', 'Under Negotiation'), ('won', 'Converted'), ('lost', 'Lost'), ('closed', 'Closed')], max_length=50, verbose_name='New Status'),
),
migrations.AlterField(
model_name='leadstatushistory',
name='old_status',
field=models.CharField(choices=[('new', 'New'), ('follow_up', 'Needs Follow-up'), ('negotiation', 'Under Negotiation'), ('won', 'Converted'), ('lost', 'Lost'), ('closed', 'Closed')], max_length=50, verbose_name='Old Status'),
),
migrations.AlterField(
model_name='opportunity',
name='status',
field=models.CharField(choices=[('new', 'New'), ('follow_up', 'Needs Follow-up'), ('negotiation', 'Under Negotiation'), ('won', 'Converted'), ('lost', 'Lost'), ('closed', 'Closed')], default='new', max_length=20, verbose_name='Status'),
),
migrations.CreateModel(
name='LeadActivity',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(max_length=255)),
('notes', models.TextField(blank=True)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.staff')),
('lead', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='inventory.lead')),
],
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.1.7 on 2025-05-11 14:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0014_lead_is_converted_alter_lead_status_and_more'),
]
operations = [
migrations.AddField(
model_name='lead',
name='next_action',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Next Action'),
),
migrations.AddField(
model_name='lead',
name='next_action_date',
field=models.DateTimeField(blank=True, null=True, verbose_name='Next Action Date'),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 5.1.7 on 2025-05-11 15:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0015_lead_next_action_lead_next_action_date'),
]
operations = [
migrations.AlterField(
model_name='activity',
name='activity_type',
field=models.CharField(choices=[('call', 'Call'), ('sms', 'SMS'), ('email', 'Email'), ('whatsapp', 'WhatsApp'), ('visit', 'Visit'), ('negotiation', 'Negotiation'), ('won', 'Won'), ('lost', 'Lost'), ('closed', 'Closed'), ('converted', 'Converted'), ('transfer', 'Transfer'), ('add_car', 'Add Car'), ('sale_car', 'Sale Car'), ('reserve_car', 'Reserve Car'), ('transfer_car', 'Transfer Car'), ('remove_car', 'Remove Car'), ('create_quotation', 'Create Quotation'), ('cancel_quotation', 'Cancel Quotation'), ('create_order', 'Create Order'), ('cancel_order', 'Cancel Order'), ('create_invoice', 'Create Invoice'), ('cancel_invoice', 'Cancel Invoice')], max_length=50, verbose_name='Activity Type'),
),
migrations.DeleteModel(
name='LeadActivity',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-05-11 15:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0016_alter_activity_activity_type_delete_leadactivity'),
]
operations = [
migrations.AlterField(
model_name='activity',
name='activity_type',
field=models.CharField(choices=[('call', 'Call'), ('sms', 'SMS'), ('email', 'Email'), ('whatsapp', 'WhatsApp'), ('visit', 'Visit'), ('negotiation', 'Negotiation'), ('follow_up', 'Follow Up'), ('won', 'Won'), ('lost', 'Lost'), ('closed', 'Closed'), ('converted', 'Converted'), ('transfer', 'Transfer'), ('add_car', 'Add Car'), ('sale_car', 'Sale Car'), ('reserve_car', 'Reserve Car'), ('transfer_car', 'Transfer Car'), ('remove_car', 'Remove Car'), ('create_quotation', 'Create Quotation'), ('cancel_quotation', 'Cancel Quotation'), ('create_order', 'Create Order'), ('cancel_order', 'Cancel Order'), ('create_invoice', 'Create Invoice'), ('cancel_invoice', 'Cancel Invoice')], max_length=50, verbose_name='Activity Type'),
),
]

View File

@ -973,12 +973,11 @@ class Channel(models.TextChoices):
class Status(models.TextChoices):
NEW = "new", _("New")
PENDING = "pending", _("Pending")
IN_PROGRESS = "in_progress", _("In Progress")
QUALIFIED = "qualified", _("Qualified")
CONTACTED = "contacted", _("Contacted")
CONVERTED = "converted", _("Converted")
CANCELED = "canceled", _("Canceled")
FOLLOW_UP = "follow_up", _("Needs Follow-up")
NEGOTIATION = "negotiation", _("Under Negotiation")
WON = "won", _("Converted")
LOST = "lost", _("Lost")
CLOSED = "closed", _("Closed")
class Title(models.TextChoices):
@ -1000,6 +999,13 @@ class ActionChoices(models.TextChoices):
EMAIL = "email", _("Email")
WHATSAPP = "whatsapp", _("WhatsApp")
VISIT = "visit", _("Visit")
LEAD_NEGOTIATION = "negotiation", _("Negotiation")
LEAD_FOLLOW_UP = "follow_up", _("Follow Up")
LEAD_WON = "won", _("Won")
LEAD_LOST = "lost", _("Lost")
LEAD_CLOSED = "closed", _("Closed")
LEAD_CONVERTED = "converted", _("Converted")
LEAD_TRANSFER = "transfer", _("Transfer")
ADD_CAR = "add_car", _("Add Car")
SALE_CAR = "sale_car", _("Sale Car")
RESERVE_CAR = "reserve_car", _("Reserve Car")
@ -1324,6 +1330,20 @@ class Lead(models.Model):
db_index=True,
default=Status.NEW,
)
next_action = models.CharField(
max_length=255,
verbose_name=_("Next Action"),
blank=True,
null=True
)
next_action_date = models.DateTimeField(
verbose_name=_("Next Action Date"),
blank=True,
null=True
)
is_converted = models.BooleanField(default=False)
converted_at = models.DateTimeField(null=True, blank=True)
created = models.DateTimeField(
auto_now_add=True, verbose_name=_("Created"), db_index=True
)
@ -1339,8 +1359,8 @@ class Lead(models.Model):
def get_user_model(self):
return User.objects.get(email=self.email) or None
@property
def is_converted(self):
return bool(self.customer)
def activities(self):
return Activity.objects.filter(dealer=self.dealer, object_id=self.id)
def to_dict(self):
return {
@ -1360,6 +1380,34 @@ class Lead(models.Model):
self.status = Status.QUALIFIED
self.save()
return self.get_customer_model()
def get_status(self):
if self.is_converted:
return Status.WON
latest_activity = self.activities.order_by('-updated').first()
if latest_activity:
time_since_last = timezone.now() - latest_activity.updated
if "negotiation" in latest_activity.activity_type.lower():
return Status.NEGOTIATION
elif time_since_last > timedelta(days=3):
return Status.FOLLOW_UP
else:
return Status.NEW
return self.status
@property
def needs_follow_up(self):
latest = self.activities.order_by('-updated').first()
if not latest:
return True
return (timezone.now() - latest.updated).days > 3
@property
def stale_leads(self):
latest = self.activities.order_by('-updated').first()
if not latest:
return True
return (timezone.now() - latest.updated).days > 7
def get_customer_model(self):
if self.customer:
@ -1381,7 +1429,14 @@ class Lead(models.Model):
def get_notes(self):
return Notes.objects.filter(content_type__model="lead", object_id=self.pk)
def get_activities(self):
return Activity.objects.filter(content_type__model="lead", object_id=self.pk)
return Activity.objects.filter(dealer=self.dealer,content_type__model="lead", object_id=self.pk).order_by('-updated')
@property
def get_current_action(self):
return Activity.objects.filter(dealer=self.dealer,content_type__model="lead", object_id=self.pk).order_by('-updated').first()
def save(self, *args, **kwargs):
self.status = self.get_status()
super().save(*args, **kwargs)
class Schedule(models.Model):
PURPOSE_CHOICES = [
@ -1576,6 +1631,14 @@ class Notification(models.Model):
def __str__(self):
return self.message
@classmethod
def has_new_notifications(cls, user):
return cls.objects.filter(user=user,is_read=False).exists()
@classmethod
def get_notification_data(cls, user):
return cls.objects.filter(user=user)
class Vendor(models.Model, LocalizedNameMixin):
dealer = models.ForeignKey(Dealer, on_delete=models.CASCADE, related_name="vendors")

View File

@ -99,6 +99,9 @@ urlpatterns = [
views.add_note_to_customer,
name="add_note_to_customer",
),
path('update-lead-actions/', views.update_lead_actions, name='update_lead_actions'),
path('crm/leads/lead_tracking/', views.lead_tracking, name='lead_tracking'),
path("crm/leads/", views.LeadListView.as_view(), name="lead_list"),
path(
"crm/leads/<int:pk>/view/", views.LeadDetailView.as_view(), name="lead_detail"
@ -184,6 +187,19 @@ urlpatterns = [
name="opportunity_update_status",
),
# path('crm/opportunities/<int:pk>/logs/', views.OpportunityLogsView.as_view(), name='opportunity_logs'),
# #######################
path('stream/', views.sse_stream, name='sse_stream'),
path('fetch/', views.fetch_notifications, name='fetch_notifications'),
# Mark single notification as read
path('<int:notification_id>/mark-read/', views.mark_notification_as_read, name='mark_notification_as_read'),
# Mark all notifications as read
path('mark-all-read/', views.mark_all_notifications_as_read, name='mark_all_notifications_as_read'),
# Notification history
path('history/', views.notifications_history, name='notifications_history'),
# #######################
path(
"crm/notifications/",
views.NotificationListView.as_view(),

View File

@ -2,7 +2,8 @@
import cv2
import json
import logging
import datetime
from datetime import datetime
from time import sleep
import numpy as np
# from rich import print
from random import randint
@ -11,12 +12,12 @@ from datetime import timedelta
from calendar import month_name
from pyzbar.pyzbar import decode
from urllib.parse import urlparse, urlunparse
#####################################################################
from inventory.models import Status as LeadStatus
from background_task.models import Task
from django.db.models.deletion import RestrictedError
from django.http.response import StreamingHttpResponse
# Django
from django.db.models import Q
from django.conf import settings
@ -4649,7 +4650,7 @@ def lead_create(request):
organization.create_customer_model()
organization.save()
instance.organization = organization
instance.next_action = LeadStatus.FOLLOW_UP
instance.save()
messages.success(request, _("Lead created successfully"))
return redirect("lead_list")
@ -4668,6 +4669,53 @@ def lead_create(request):
)
return render(request, "crm/leads/lead_form.html", {"form": form})
def lead_tracking(request):
dealer = get_user_type(request)
new = models.Lead.objects.filter(dealer=dealer)
follow_up = models.Lead.objects.filter(dealer=dealer, next_action__in=["call", "meeting"])
won = models.Lead.objects.filter(dealer=dealer, status="won")
lose = models.Lead.objects.filter(dealer=dealer, status="lose")
negotiation = models.Lead.objects.filter(dealer=dealer, status="negotiation")
context = {"new": new,"follow_up": follow_up,"won": won,"lose": lose,"negotiation": negotiation}
return render(request, "crm/leads/lead_tracking.html", context)
# @require_POST
def update_lead_actions(request):
try:
lead_id = request.POST.get('lead_id')
current_action = request.POST.get('current_action')
next_action = request.POST.get('next_action')
next_action_date = request.POST.get('next_action_date')
action_notes = request.POST.get('action_notes', '')
# Validate required fields
if not all([lead_id, current_action, next_action, next_action_date]):
return JsonResponse({'success': False, 'message': 'All fields are required'}, status=400)
# Get the lead
lead = models.Lead.objects.get(id=lead_id)
# Update lead fields
lead.action = current_action
lead.next_action = next_action
lead.next_action_date = next_action_date
# Parse the datetime string
try:
next_action_datetime = datetime.strptime(next_action_date, '%Y-%m-%dT%H:%M')
lead.next_action_date = timezone.make_aware(next_action_datetime)
except ValueError:
return JsonResponse({'success': False, 'message': 'Invalid date format'}, status=400)
# Save the lead
lead.save()
return JsonResponse({'success': True, 'message': 'Actions updated successfully'})
except models.Lead.DoesNotExist:
return JsonResponse({'success': False, 'message': 'Lead not found'}, status=404)
except Exception as e:
return JsonResponse({'success': False, 'message': str(e)}, status=500)
class LeadUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
"""
@ -5342,49 +5390,49 @@ class NotificationListView(LoginRequiredMixin, ListView):
return models.Notification.objects.filter(user=self.request.user)
@login_required
def mark_notification_as_read(request, pk):
"""
Marks a user notification as read.
# @login_required
# def mark_notification_as_read(request, pk):
# """
# Marks a user notification as read.
This view allows an authenticated user to mark a specific notification,
identified by its primary key, as read. Upon successfully marking the
notification, a success message is displayed, and the user is redirected
to their notification history page.
# This view allows an authenticated user to mark a specific notification,
# identified by its primary key, as read. Upon successfully marking the
# notification, a success message is displayed, and the user is redirected
# to their notification history page.
:param request: The HTTP request object.
:type request: HttpRequest
:param pk: Primary key of the notification to be marked as read.
:type pk: int
:return: An HTTP response redirecting to the notification history page.
:rtype: HttpResponse
"""
notification = get_object_or_404(models.Notification, pk=pk, user=request.user)
notification.is_read = True
notification.save()
messages.success(request, _("Notification marked as read"))
return redirect("notifications_history")
# :param request: The HTTP request object.
# :type request: HttpRequest
# :param pk: Primary key of the notification to be marked as read.
# :type pk: int
# :return: An HTTP response redirecting to the notification history page.
# :rtype: HttpResponse
# """
# notification = get_object_or_404(models.Notification, pk=pk, user=request.user)
# notification.is_read = True
# notification.save()
# messages.success(request, _("Notification marked as read"))
# return redirect("notifications_history")
@login_required
def fetch_notifications(request):
"""
Fetches unread notifications for the currently logged-in user and renders them
to the `notifications.html` template. The notifications are filtered to include
only those belonging to the logged-in user and are sorted by creation date in
descending order.
# @login_required
# def fetch_notifications(request):
# """
# Fetches unread notifications for the currently logged-in user and renders them
# to the `notifications.html` template. The notifications are filtered to include
# only those belonging to the logged-in user and are sorted by creation date in
# descending order.
:param request: The HTTP request object representing the current user request.
Must include details of the logged-in user.
:return: An HttpResponse object that renders the `notifications.html` template
with the fetched notifications.
# :param request: The HTTP request object representing the current user request.
# Must include details of the logged-in user.
# :return: An HttpResponse object that renders the `notifications.html` template
# with the fetched notifications.
"""
notifications = models.Notification.objects.filter(
user=request.user, is_read=False
).order_by("-created")
# """
# notifications = models.Notification.objects.filter(
# user=request.user, is_read=False
# ).order_by("-created")
return render(request, "notifications.html", {"notifications_": notifications})
# return render(request, "notifications.html", {"notifications_": notifications})
class ItemServiceCreateView(
@ -7596,4 +7644,63 @@ def task_list(request):
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
return render(request, 'tasks/task_list.html', {'page_obj': page_obj})
return render(request, 'tasks/task_list.html', {'page_obj': page_obj})
def sse_stream(request):
def event_stream():
last_id = request.GET.get('last_id', 0)
while True:
# Check for new notifications
notifications = models.Notification.objects.filter(
user=request.user,
id__gt=last_id,
is_read=False
).order_by('created')
for notification in notifications:
notification_data = {
'id': notification.id,
'message': notification.message,
'created': notification.created.isoformat(),
}
yield (
f"id: {notification.id}\n"
f"event: notification\n"
f"data: {json.dumps(notification_data)}\n\n"
)
last_id = notification.id
sleep(2)
response = StreamingHttpResponse(
event_stream(),
content_type='text/event-stream'
)
response['Cache-Control'] = 'no-cache'
return response
@login_required
def fetch_notifications(request):
notifications = models.Notification.objects.filter(
user=request.user,
is_read=False
).order_by('-created')[:10] # Get 10 most recent
return JsonResponse({'notifications': list(notifications.values())})
@login_required
def mark_notification_as_read(request, notification_id):
notification = get_object_or_404(models.Notification, id=notification_id, user=request.user)
notification.read = True
notification.save()
return JsonResponse({'status': 'success'})
@login_required
def mark_all_notifications_as_read(request):
models.Notification.objects.filter(user=request.user, is_read=False).update(read=True)
return JsonResponse({'status': 'success'})
@login_required
def notifications_history(request):
models.Notification.objects.filter(user=request.user, is_read=False).update(read=True)
return JsonResponse({'status': 'success'})

View File

@ -3,39 +3,41 @@ import requests
from django.urls import reverse
from django.conf import settings
from django.contrib.auth.models import User
from inventory.models import PaymentHistory
from inventory.models import PaymentHistory,Notification
from plans.models import Order, PlanPricing,AbstractOrder
def run():
request = {
"csrfmiddlewaretoken": [
"mAnzSt7JjHkHGb27cyF1AiFvuVF7iKhONDVUzyzYuH1U0b7hxXL89D1UA4XQInuu"
],
"selected_plan": ["33"],
"first_name": ["ismail"],
"last_name": ["mosa"],
"email": ["ismail.mosa.ibrahim@gmail.com"],
"phone": ["0566703794"],
"company": ["Tenhal"],
"card_name": ["ppppppppppp"],
"card_number": ["4111 1111 1111 1111"],
"card_expiry": ["08/28"],
"card_cvv": ["123"],
}
selected_plan_id = request.get("selected_plan")[0]
pp = PlanPricing.objects.get(pk=selected_plan_id)
user = User.objects.first()
order = Order.objects.create(
user=user,
plan=pp.plan,
pricing=pp.pricing,
amount=pp.price,
currency="SAR",
tax=15,
status=AbstractOrder.STATUS.NEW
)
print(Notification.get_notification_data(user))
# request = {
# "csrfmiddlewaretoken": [
# "mAnzSt7JjHkHGb27cyF1AiFvuVF7iKhONDVUzyzYuH1U0b7hxXL89D1UA4XQInuu"
# ],
# "selected_plan": ["33"],
# "first_name": ["ismail"],
# "last_name": ["mosa"],
# "email": ["ismail.mosa.ibrahim@gmail.com"],
# "phone": ["0566703794"],
# "company": ["Tenhal"],
# "card_name": ["ppppppppppp"],
# "card_number": ["4111 1111 1111 1111"],
# "card_expiry": ["08/28"],
# "card_cvv": ["123"],
# }
handle_payment(request,order)
# selected_plan_id = request.get("selected_plan")[0]
# pp = PlanPricing.objects.get(pk=selected_plan_id)
# user = User.objects.first()
# order = Order.objects.create(
# user=user,
# plan=pp.plan,
# pricing=pp.pricing,
# amount=pp.price,
# currency="SAR",
# tax=15,
# status=AbstractOrder.STATUS.NEW
# )
# handle_payment(request,order)

View File

@ -5,6 +5,62 @@
{% block content %}
<div class="row g-3">
<h2 class="mb-4">{{ _("Leads")|capfirst }}</h2>
<!-- Action Tracking Modal -->
<div class="modal fade" id="actionTrackingModal" tabindex="-1" aria-labelledby="actionTrackingModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="actionTrackingModalLabel">{{ _("Update Lead Actions") }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="actionTrackingForm" method="post">
<div class="modal-body">
{% csrf_token %}
<input type="hidden" id="leadId" name="lead_id">
<div class="mb-3">
<label for="currentAction" class="form-label">{{ _("Current Action") }}</label>
<select class="form-select" id="currentAction" name="current_action" required>
<option value="">{{ _("Select Action") }}</option>
<option value="contacted">{{ _("Contacted") }}</option>
<option value="follow_up">{{ _("Follow Up") }}</option>
<option value="proposal_sent">{{ _("Proposal Sent") }}</option>
<option value="negotiation">{{ _("Negotiation") }}</option>
<option value="closed">{{ _("Closed") }}</option>
</select>
</div>
<div class="mb-3">
<label for="nextAction" class="form-label">{{ _("Next Action") }}</label>
<select class="form-select" id="nextAction" name="next_action" required>
<option value="">{{ _("Select Next Action") }}</option>
<option value="call">{{ _("Call") }}</option>
<option value="meeting">{{ _("Meeting") }}</option>
<option value="email">{{ _("Email") }}</option>
<option value="proposal">{{ _("Send Proposal") }}</option>
<option value="follow_up">{{ _("Follow Up") }}</option>
</select>
</div>
<div class="mb-3">
<label for="nextActionDate" class="form-label">{{ _("Next Action Date") }}</label>
<input type="datetime-local" class="form-control" id="nextActionDate" name="next_action_date" required>
</div>
<div class="mb-3">
<label for="actionNotes" class="form-label">{{ _("Notes") }}</label>
<textarea class="form-control" id="actionNotes" name="action_notes" rows="3"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ _("Close") }}</button>
<button type="submit" class="btn btn-primary">{{ _("Save Changes") }}</button>
</div>
</form>
</div>
</div>
</div>
<div class="row g-3 justify-content-between mb-4">
<div class="col-auto">
<div class="d-md-flex justify-content-between">
@ -53,6 +109,24 @@
<span>{{ _("Schedule") }}</span>
</div>
</th>
<th class="align-middle white-space-nowrap text-uppercase" scope="col" style="width: 10%;">
<div class="d-inline-flex flex-center">
<div class="d-flex align-items-center bg-info-subtle rounded me-2"><span class="text-info-dark" data-feather="database"></span></div>
<span>{{ _("Current Action")|capfirst }}</span>
</div>
</th>
<th class="align-middle white-space-nowrap text-uppercase" scope="col" style="width: 10%;">
<div class="d-inline-flex flex-center">
<div class="d-flex align-items-center bg-info-subtle rounded me-2"><span class="text-info-dark" data-feather="database"></span></div>
<span>{{ _("Next Action")|capfirst }}</span>
</div>
</th>
<th class="align-middle white-space-nowrap text-uppercase" scope="col" style="width: 10%;">
<div class="d-inline-flex flex-center">
<div class="d-flex align-items-center bg-info-subtle rounded me-2"><span class="text-info-dark" data-feather="database"></span></div>
<span>{{ _("Next Action Date")|capfirst }}</span>
</div>
</th>
<th class="align-middle white-space-nowrap text-uppercase" scope="col" style="width: 10%;">
<div class="d-inline-flex flex-center">
<div class="d-flex align-items-center bg-info-subtle rounded me-2"><span class="text-info-dark" data-feather="database"></span></div>
@ -180,6 +254,9 @@
</div>
</div>
</td>
<td class="align-middle white-space-nowrap text-body-tertiary text-opacity-85 fw-semibold text-body-highlight">{{ lead.get_status|upper }}</td>
<td class="align-middle white-space-nowrap text-body-tertiary text-opacity-85 fw-semibold text-body-highlight">{% if lead.next_action %}{{ lead.next_action|upper }}{% endif %}</td>
<td class="align-middle white-space-nowrap text-body-tertiary text-opacity-85 fw-semibold text-body-highlight">{% if lead.next_action %}{{ lead.next_action_date|upper }}{% endif %}</td>
<td class="align-middle white-space-nowrap text-body-tertiary text-opacity-85 fw-semibold text-body-highlight">{{ lead.staff|upper }}</td>
<td class="align-middle white-space-nowrap fw-semibold text-body-highlight">{{ lead.source|upper }}</td>
<td class="align-middle white-space-nowrap text-body-tertiary text-opacity-85 fw-semibold text-body-highlight">{{ lead.channel|upper }}</td>
@ -219,19 +296,22 @@
<span class="fas fa-ellipsis-h fs-10"></span>
</button>
<div class="dropdown-menu dropdown-menu-end py-2">
{% if perms.inventory.change_lead %}
<a href="{% url 'lead_update' lead.id %}" class="dropdown-item text-success-dark">{% trans "Edit" %}</a>
{% endif %}
<a href="{% url 'send_lead_email' lead.id %}" class="dropdown-item text-success-dark">{% trans "Send Email" %}</a>
<a href="{% url 'schedule_lead' lead.id %}" class="dropdown-item text-success-dark">{% trans "Schedule Event" %}</a>
{% if not lead.opportunity %}
<a href="{% url 'lead_convert' lead.id %}" class="dropdown-item text-success-dark">{% trans "Convert" %}</a>
{% endif %}
<div class="dropdown-divider"></div>
{% if perms.inventory.delete_lead %}
<button class="dropdown-item text-danger" data-bs-toggle="modal" data-bs-target="#deleteModal">{% trans "Delete" %}</button>
{% endif %}
</div>
{% if perms.inventory.change_lead %}
<a href="{% url 'lead_update' lead.id %}" class="dropdown-item text-success-dark">{% trans "Edit" %}</a>
{% endif %}
<button class="dropdown-item text-primary" onclick="openActionModal('{{ lead.id }}', '{{ lead.action }}', '{{ lead.next_action }}', '{{ lead.next_action_date|date:"Y-m-d\TH:i" }}')">
{% trans "Update Actions" %}
</button>
<a href="{% url 'send_lead_email' lead.id %}" class="dropdown-item text-success-dark">{% trans "Send Email" %}</a>
<a href="{% url 'schedule_lead' lead.id %}" class="dropdown-item text-success-dark">{% trans "Schedule Event" %}</a>
{% if not lead.opportunity %}
<a href="{% url 'lead_convert' lead.id %}" class="dropdown-item text-success-dark">{% trans "Convert" %}</a>
{% endif %}
<div class="dropdown-divider"></div>
{% if perms.inventory.delete_lead %}
<button class="dropdown-item text-danger" data-bs-toggle="modal" data-bs-target="#deleteModal">{% trans "Delete" %}</button>
{% endif %}
</div>
</div>
{% endif %}
</td>
@ -287,3 +367,106 @@
{% endblock %}
{% block customJS %}
<script>
// Initialize SweetAlert Toast for general messages
let Toast = Swal.mixin({
toast: true,
position: "top-end",
showConfirmButton: false,
timer: 3000,
timerProgressBar: true,
didOpen: (toast) => {
toast.onmouseenter = Swal.stopTimer;
toast.onmouseleave = Swal.resumeTimer;
}
});
// Display Django messages
{% if messages %}
{% for message in messages %}
Toast.fire({
icon: "{{ message.tags }}",
titleText: "{{ message|safe }}"
});
{% endfor %}
{% endif %}
function openActionModal(leadId, currentAction, nextAction, nextActionDate) {
const modal = new bootstrap.Modal(document.getElementById('actionTrackingModal'));
document.getElementById('leadId').value = leadId;
document.getElementById('currentAction').value = currentAction;
document.getElementById('nextAction').value = nextAction;
document.getElementById('nextActionDate').value = nextActionDate;
modal.show();
}
document.getElementById('actionTrackingForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
// Show loading indicator
Swal.fire({
title: 'Updating Actions',
text: 'Please wait...',
allowOutsideClick: false,
didOpen: () => {
Swal.showLoading();
}
});
fetch("{% url 'update_lead_actions' %}", {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': '{{ csrf_token }}'
}
})
.then(response => response.json())
.then(data => {
Swal.close();
if (data.success) {
// Success notification
Swal.fire({
icon: 'success',
title: 'Success!',
text: data.message || 'Actions updated successfully',
confirmButtonText: 'OK',
timer: 3000,
timerProgressBar: true
}).then(() => {
location.reload(); // Refresh after user clicks OK
});
} else {
// Error notification
Swal.fire({
icon: 'error',
title: 'Error',
text: data.message || 'Failed to update actions',
confirmButtonText: 'OK'
});
}
})
.catch(error => {
Swal.close();
console.error('Error:', error);
Swal.fire({
icon: 'error',
title: 'Error',
text: 'An unexpected error occurred',
confirmButtonText: 'OK'
});
});
});
// Helper function for notifications
function notify(tag, msg) {
Toast.fire({
icon: tag,
titleText: msg
});
}
</script>
{% endblock customJS %}

View File

@ -0,0 +1,135 @@
{% extends 'base.html' %}
{% load i18n static %}
{% block title %}{{ _('Leads')|capfirst }}{% endblock title %}
{% block customCSS %}
<style>
.kanban-column {
background-color: #f8f9fa;
border-radius: 8px;
padding: 1rem;
min-height: 500px;
}
.kanban-header {
position: relative;
background-color:rgb(237, 241, 245);
font-weight: 600;
padding: 0.5rem 1rem;
margin-bottom: 1rem;
color: #333;
clip-path: polygon(0 0, calc(100% - 15px) 0, 100% 50%, calc(100% - 15px) 100%, 0 100%);
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.kanban-header::after {
content: "";
position: absolute;
right: -20px;
top: 0;
width: 0;
height: 0;
border-top: 28px solid transparent;
border-bottom: 28px solid transparent;
border-left: 20px solid #dee2e6;
}
.lead-card {
background-color: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 1rem;
}
.lead-card small {
color: #6c757d;
}
</style>
{% endblock customCSS %}
{% block content %}
<div class="container-fluid my-4">
<div class="d-flex justify-content-between mb-3">
<h3>Lead Tracking</h3>
</div>
<div class="row g-3">
<!-- Column Template -->
<div class="col-md">
<div class="kanban-column">
<div class="kanban-header">New Leads ({{new|length}})</div>
{% for lead in new %}
<a href="{% url 'lead_detail' lead.id %}">
<div class="lead-card">
<strong>{{lead.full_name|capfirst}}</strong><br>
<small>{{lead.email}}</small><br>
<small>{{lead.phone_number}}</small>
</div>
</a>
{% endfor %}
</div>
</div>
<!-- Follow Ups -->
<div class="col-md">
<div class="kanban-column">
<div class="kanban-header">Follow Ups ({{follow_up|length}})</div>
{% for lead in follow_up %}
<a href="{% url 'lead_detail' lead.id %}">
<div class="lead-card">
<strong>{{lead.full_name|capfirst}}</strong><br>
<small>{{lead.email}}</small><br>
<small>{{lead.phone_number}}</small>
</div>
</a>
{% endfor %}
</div>
</div>
<!-- Under Review -->
<div class="col-md">
<div class="kanban-column">
<div class="kanban-header">Won ({{won|length}})</div>
{% for lead in won %}
<a href="{% url 'lead_detail' lead.id %}">
<div class="lead-card">
<strong>{{lead.full_name|capfirst}}</strong><br>
<small>{{lead.email}}</small><br>
<small>{{lead.phone_number}}</small>
</div>
</a>
{% endfor %}
</div>
</div>
<!-- Demo -->
<div class="col-md">
<div class="kanban-column">
<div class="kanban-header">Lose ({{lose|length}})</div>
{% for lead in lose %}
<a href="{% url 'lead_detail' lead.id %}">
<div class="lead-card">
<strong>{{lead.full_name|capfirst}}</strong><br>
<small>{{lead.email}}</small><br>
<small>{{lead.phone_number}}</small>
</div>
</a>
{% endfor %}
</div>
</div>
<!-- Negotiation -->
<div class="col-md">
<div class="kanban-column">
<div class="kanban-header">Negotiation ({{negotiation|length}})</div>
{% for lead in negotiation %}
<a href="{% url 'lead_detail' lead.id %}">
<div class="lead-card">
<strong>{{lead.full_name|capfirst}}</strong><br>
<small>{{lead.email}}</small><br>
<small>{{lead.phone_number}}</small>
</div>
</a>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -135,6 +135,13 @@
</div>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'lead_tracking' %}">
<div class="d-flex align-items-center">
<span class="nav-link-icon"><span data-feather="users"></span></span><span class="nav-link-text">{% trans 'leads Tracking'|capfirst %}</span>
</div>
</a>
</li>
{% endif %}
{% if perms.django_ledger.view_customermodel %}
<li class="nav-item">

View File

@ -0,0 +1,50 @@
<li class="nav-item dropdown">
<div class="notification-count" hx-get="{% url 'fetch_notifications' %}" hx-trigger="every 20s" hx-swap="innerHTML" hx-select=".notification-count">
{% if notifications_ %}
<span class="badge bg-danger rounded-pill " id="notification-counter" style="position: absolute; top: 8px; right: 3px; font-size: 0.50rem;">{{ notifications_.count }}</span>
{% endif %}
</div>
<a class="nav-link" href="{% url 'fetch_notifications' %}" hx-get="{% url 'fetch_notifications' %}" hx-swap="innerHTML" hx-target=".card-body" hx-select=".card-body" style="min-width: 2.25rem" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" data-bs-auto-close="outside"><span class="d-block" style="height:20px;width:20px;"><span data-feather="bell" style="height:20px;width:20px;"></span></span></span>
</a>
<div class="dropdown-menu dropdown-menu-end notification-dropdown-menu py-0 shadow border navbar-dropdown-caret" id="navbarDropdownNotfication" aria-labelledby="navbarDropdownNotfication">
<div class="card position-relative border-0">
<div class="card-header p-2">
<div class="d-flex justify-content-between">
<h5 class="text-body-emphasis mb-0">{{ _("Notifications") }}</h5>
<button class="btn btn-link p-0 fs-9 fw-normal" type="button">{{ _("Mark all as read")}}</button>
</div>
</div>
<div class="card-body p-0">
<div class="scrollbar-overlay" style="height: 27rem;">
{% for notification in notifications_ %}
<div class="px-2 px-sm-3 py-3 notification-card position-relative read border-bottom">
<div class="d-flex align-items-center justify-content-between position-relative">
<div class="d-flex">
<div class="flex-1 me-sm-3">
<h4 class="fs-9 text-body-emphasis">System</h4>
<p class="fs-9 text-body-highlight mb-2 mb-sm-3 fw-normal">
<span class="me-1 fs-10">💬</span>{{notification.message|safe}}<span class="ms-2 text-body-quaternary text-opacity-75 fw-bold fs-10">10m</span>
</p>
<p class="text-body-secondary fs-9 mb-0">
<span class="me-1 fas fa-clock"></span><span class="fw-bold">10:41 AM</span>{{notification.created}}
</p>
</div>
</div>
<div class="dropdown notification-dropdown">
<button class="btn fs-10 btn-sm dropdown-toggle dropdown-caret-none transition-none" type="button" data-bs-toggle="dropdown" data-boundary="window" aria-haspopup="true" aria-expanded="false" data-bs-reference="parent"><span class="fas fa-ellipsis-h fs-10 text-body"></span></button>
<div class="dropdown-menu py-2">
<a class="dropdown-item" href="{% url 'mark_notification_as_read' notification.id %}">Mark as read</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="card-footer p-0 border-top border-translucent border-0">
<div class="my-2 text-center fw-bold fs-10 text-body-tertiary text-opactity-85"><a class="fw-bolder" href="{% url 'notifications_history' %}">Notification history</a></div>
</div>
</div>
</div>
</li>

View File

@ -1,50 +1,245 @@
<li class="nav-item dropdown">
<div class="notification-count" hx-get="{% url 'fetch_notifications' %}" hx-trigger="every 20s" hx-swap="innerHTML" hx-select=".notification-count">
{% if notifications_ %}
<span class="badge bg-danger rounded-pill " id="notification-counter" style="position: absolute; top: 8px; right: 3px; font-size: 0.50rem;">{{ notifications_.count }}</span>
{% endif %}
</div>
<a class="nav-link" href="{% url 'fetch_notifications' %}" hx-get="{% url 'fetch_notifications' %}" hx-swap="innerHTML" hx-target=".card-body" hx-select=".card-body" style="min-width: 2.25rem" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" data-bs-auto-close="outside"><span class="d-block" style="height:20px;width:20px;"><span data-feather="bell" style="height:20px;width:20px;"></span></span></span>
</a>
<div class="dropdown-menu dropdown-menu-end notification-dropdown-menu py-0 shadow border navbar-dropdown-caret" id="navbarDropdownNotfication" aria-labelledby="navbarDropdownNotfication">
<!-- Notification counter -->
<div class="notification-count">
{% if notifications_ %}
<span class="badge bg-danger rounded-pill" id="notification-counter" style="position: absolute; top: 8px; right: 3px; font-size: 0.50rem;">{{ notifications_.count }}</span>
{% else %}
<span class="badge bg-danger rounded-pill d-none" id="notification-counter" style="position: absolute; top: 8px; right: 3px; font-size: 0.50rem;">0</span>
{% endif %}
</div>
<!-- Bell icon -->
<a class="nav-link" href="{% url 'fetch_notifications' %}" style="min-width: 2.25rem" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" data-bs-auto-close="outside">
<span class="d-block" style="height:20px;width:20px;">
<span data-feather="bell" style="height:20px;width:20px;"></span>
</span>
</a>
<!-- Dropdown menu -->
<div class="dropdown-menu dropdown-menu-end notification-dropdown-menu py-0 shadow border navbar-dropdown-caret" id="navbarDropdownNotfication">
<div class="card position-relative border-0">
<div class="card-header p-2">
<div class="d-flex justify-content-between">
<h5 class="text-body-emphasis mb-0">{{ _("Notifications") }}</h5>
<button class="btn btn-link p-0 fs-9 fw-normal" type="button">{{ _("Mark all as read")}}</button>
<div class="card-header p-2">
<div class="d-flex justify-content-between">
<h5 class="text-body-emphasis mb-0">{{ _("Notifications") }}</h5>
<button class="btn btn-link p-0 fs-9 fw-normal" type="button" id="mark-all-read">{{ _("Mark all as read")}}</button>
</div>
</div>
</div>
<div class="card-body p-0">
<div class="scrollbar-overlay" style="height: 27rem;">
{% for notification in notifications_ %}
<div class="px-2 px-sm-3 py-3 notification-card position-relative read border-bottom">
<div class="card-body p-0">
<div class="scrollbar-overlay" style="height: 27rem;" id="notifications-container">
{% for notification in notifications_ %}
<div class="px-2 px-sm-3 py-3 notification-card position-relative read border-bottom" data-notification-id="{{ notification.id }}">
<!-- Notification content -->
</div>
{% endfor %}
</div>
</div>
<div class="card-footer p-0 border-top border-translucent border-0">
<div class="my-2 text-center fw-bold fs-10 text-body-tertiary text-opactity-85">
<a class="fw-bolder" href="{% url 'notifications_history' %}">Notification history</a>
</div>
</div>
</div>
</div>
</li>
<script>
document.addEventListener('DOMContentLoaded', function() {
let lastNotificationId = {{ notifications_.last.id|default:0 }};
let seenNotificationIds = new Set();
let counter = document.getElementById('notification-counter');
let notificationsContainer = document.getElementById('notifications-container');
let eventSource = null;
let initialUnreadCount = {{ notifications_.count|default:0 }};
updateCounter(initialUnreadCount);
fetchInitialNotifications();
function fetchInitialNotifications() {
fetch("{% url 'fetch_notifications' %}")
.then(response => response.json())
.then(data => {
if (data.notifications && data.notifications.length > 0) {
lastNotificationId = data.notifications[0].id;
seenNotificationIds = new Set();
let unreadCount = 0;
data.notifications.forEach(notification => {
seenNotificationIds.add(notification.id);
if (notification.unread) {
unreadCount++;
}
});
renderNotifications(data.notifications);
updateCounter(unreadCount);
connectSSE();
}
})
.catch(error => {
console.error('Error fetching initial notifications:', error);
connectSSE();
});
}
function connectSSE() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource("{% url 'sse_stream' %}?last_id=" + lastNotificationId);
eventSource.addEventListener('notification', function(e) {
try {
const data = JSON.parse(e.data);
if (seenNotificationIds.has(data.id)) return;
seenNotificationIds.add(data.id);
if (data.id > lastNotificationId) {
lastNotificationId = data.id;
}
updateCounter('increment');
const notificationElement = createNotificationElement(data);
notificationsContainer.insertAdjacentHTML('afterbegin', notificationElement);
} catch (error) {
console.error('Error processing notification:', error);
}
});
eventSource.addEventListener('error', function(e) {
console.error('SSE connection error:', e);
setTimeout(connectSSE, 5000);
});
}
function renderNotifications(notifications) {
if (!notificationsContainer) return;
let html = '';
notifications.forEach(notification => {
html += createNotificationElement(notification);
});
notificationsContainer.innerHTML = html;
}
function createNotificationElement(data) {
const isRead = data.read ? 'read' : 'unread';
return `
<div class="px-2 px-sm-3 py-3 notification-card position-relative ${isRead} border-bottom"
data-notification-id="${data.id}">
<div class="d-flex align-items-center justify-content-between position-relative">
<div class="d-flex">
<div class="flex-1 me-sm-3">
<h4 class="fs-9 text-body-emphasis">System</h4>
<p class="fs-9 text-body-highlight mb-2 mb-sm-3 fw-normal">
<span class="me-1 fs-10">💬</span>{{notification.message|safe}}<span class="ms-2 text-body-quaternary text-opacity-75 fw-bold fs-10">10m</span>
</p>
<p class="text-body-secondary fs-9 mb-0">
<span class="me-1 fas fa-clock"></span><span class="fw-bold">10:41 AM</span>{{notification.created}}
</p>
</div>
<div class="flex-1 me-sm-3">
<h4 class="fs-9 text-body-emphasis">System</h4>
<p class="fs-9 text-body-highlight mb-2 mb-sm-3 fw-normal">
<span class="me-1 fs-10">💬</span>${data.message}<span class="ms-2 text-body-quaternary text-opacity-75 fw-bold fs-10">Just now</span>
</p>
<p class="text-body-secondary fs-9 mb-0">
<span class="me-1 fas fa-clock"></span><span class="fw-bold">${new Date(data.created).toLocaleTimeString()}</span>
</p>
</div>
</div>
<div class="dropdown notification-dropdown">
<button class="btn fs-10 btn-sm dropdown-toggle dropdown-caret-none transition-none" type="button" data-bs-toggle="dropdown" data-boundary="window" aria-haspopup="true" aria-expanded="false" data-bs-reference="parent"><span class="fas fa-ellipsis-h fs-10 text-body"></span></button>
<div class="dropdown-menu py-2">
<a class="dropdown-item" href="{% url 'mark_notification_as_read' notification.id %}">Mark as read</a>
</div>
<button class="btn fs-10 btn-sm dropdown-toggle dropdown-caret-none transition-none"
type="button" data-bs-toggle="dropdown">
<span class="fas fa-ellipsis-h fs-10 text-body"></span>
</button>
<div class="dropdown-menu py-2">
<a class="dropdown-item mark-as-read" href="#"
data-notification-id="${data.id}">Mark as read</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="card-footer p-0 border-top border-translucent border-0">
<div class="my-2 text-center fw-bold fs-10 text-body-tertiary text-opactity-85"><a class="fw-bolder" href="{% url 'notifications_history' %}">Notification history</a></div>
</div>
</div>
</div>
</li>
</div>
`;
}
function updateCounter(action) {
if (!counter) {
counter = document.getElementById('notification-counter');
if (!counter) {
const notificationCountDiv = document.querySelector('.notification-count');
if (notificationCountDiv) {
notificationCountDiv.innerHTML = `
<span class="badge bg-danger rounded-pill" id="notification-counter"
style="position: absolute; top: 8px; right: 3px; font-size: 0.50rem;">0</span>
`;
counter = document.getElementById('notification-counter');
}
}
}
let currentCount = parseInt(counter.textContent) || 0;
if (action === 'increment') {
currentCount += 1;
} else if (action === 'decrement') {
currentCount = Math.max(0, currentCount - 1);
} else if (typeof action === 'number') {
currentCount = action;
}
counter.textContent = currentCount;
if (currentCount > 0) {
counter.classList.remove('d-none');
} else {
counter.classList.add('d-none');
}
}
document.getElementById('mark-all-read')?.addEventListener('click', function() {
fetch("{% url 'mark_all_notifications_as_read' %}", {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'Content-Type': 'application/json'
}
}).then(response => {
if (response.ok) {
updateCounter(0);
document.querySelectorAll('.notification-card').forEach(card => {
card.classList.remove('unread');
card.classList.add('read');
});
}
});
});
document.addEventListener('click', function(e) {
if (e.target.classList.contains('mark-as-read')) {
e.preventDefault();
const notificationId = e.target.getAttribute('data-notification-id');
fetch(`/notifications/${notificationId}/mark-read/`, {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'Content-Type': 'application/json'
}
}).then(response => {
if (response.ok) {
const notificationCard = document.querySelector(`[data-notification-id="${notificationId}"]`);
if (notificationCard) {
notificationCard.classList.remove('unread');
notificationCard.classList.add('read');
updateCounter('decrement');
}
}
});
}
});
});
</script>