update notification + lead and add lead tracking
This commit is contained in:
parent
03dd57d4d1
commit
993d4bc712
18
inventory/migrations/0013_lead_converted_at.py
Normal file
18
inventory/migrations/0013_lead_converted_at.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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',
|
||||
),
|
||||
]
|
||||
18
inventory/migrations/0017_alter_activity_activity_type.py
Normal file
18
inventory/migrations/0017_alter_activity_activity_type.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@ -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")
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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'})
|
||||
|
||||
62
scripts/r.py
62
scripts/r.py
@ -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)
|
||||
|
||||
|
||||
@ -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 %}
|
||||
135
templates/crm/leads/lead_tracking.html
Normal file
135
templates/crm/leads/lead_tracking.html
Normal 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 %}
|
||||
@ -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">
|
||||
|
||||
50
templates/notifications-copy.html
Normal file
50
templates/notifications-copy.html
Normal 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>
|
||||
|
||||
@ -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>
|
||||
Loading…
x
Reference in New Issue
Block a user