update the task and integrate it with appointment calendar

This commit is contained in:
ismail 2025-07-08 11:18:06 +03:00
parent 25d17efa11
commit b23e84331f
12 changed files with 495 additions and 117 deletions

View File

@ -12,21 +12,21 @@ class Command(BaseCommand):
Service.objects.create( Service.objects.create(
name="call", name="call",
price=0, price=0,
duration=datetime.timedelta(minutes=10), duration=datetime.timedelta(minutes=60),
currency="SAR", currency="SAR",
description="15 min call", description="15 min call",
) )
Service.objects.create( Service.objects.create(
name="meeting", name="meeting",
price=0, price=0,
duration=datetime.timedelta(minutes=30), duration=datetime.timedelta(minutes=90),
currency="SAR", currency="SAR",
description="30 min meeting", description="30 min meeting",
) )
Service.objects.create( Service.objects.create(
name="email", name="email",
price=0, price=0,
duration=datetime.timedelta(minutes=30), duration=datetime.timedelta(minutes=90),
currency="SAR", currency="SAR",
description="30 min visit", description="30 min visit",
) )

View File

@ -123,13 +123,18 @@ class DealerSlugMiddleware:
response = self.get_response(request) response = self.get_response(request)
return response return response
def process_view(self, request, view_func, view_args, view_kwargs): def process_view(self, request, view_func, view_args, view_kwargs):
if request.path_info.startswith('/en/signup/') or \ if request.path_info.startswith('/ar/signup/') or \
request.path_info.startswith('/en/signup/') or \
request.path_info.startswith('/ar/login/') or \
request.path_info.startswith('/en/login/') or \ request.path_info.startswith('/en/login/') or \
request.path_info.startswith('/ar/logout/') or \
request.path_info.startswith('/en/logout/') or \ request.path_info.startswith('/en/logout/') or \
request.path_info.startswith('/en/ledger/') or \ request.path_info.startswith('/en/ledger/') or \
request.path_info.startswith('/ar/ledger/') or \ request.path_info.startswith('/ar/ledger/') or \
request.path_info.startswith('/en/notifications/') or \ request.path_info.startswith('/en/notifications/') or \
request.path_info.startswith('/ar/notifications/'): request.path_info.startswith('/ar/notifications/') or \
request.path_info.startswith('/en/appointment/') or \
request.path_info.startswith('/ar/appointment/'):
return None return None
if not request.user.is_authenticated: if not request.user.is_authenticated:

View File

@ -1836,6 +1836,7 @@ class Schedule(models.Model):
("completed", _("Completed")), ("completed", _("Completed")),
("canceled", _("Canceled")), ("canceled", _("Canceled")),
] ]
dealer = models.ForeignKey(Dealer, on_delete=models.CASCADE)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField() object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id') content_object = GenericForeignKey('content_type', 'object_id')
@ -1852,6 +1853,7 @@ class Schedule(models.Model):
scheduled_type = models.CharField( scheduled_type = models.CharField(
max_length=200, choices=ScheduledType, default="Call" max_length=200, choices=ScheduledType, default="Call"
) )
completed = models.BooleanField(default=False, verbose_name=_("Completed"))
duration = models.DurationField(default=timedelta(minutes=5)) duration = models.DurationField(default=timedelta(minutes=5))
notes = models.TextField(blank=True, null=True) notes = models.TextField(blank=True, null=True)
status = models.CharField( status = models.CharField(
@ -1861,7 +1863,7 @@ class Schedule(models.Model):
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return f"Scheduled {self.purpose} with {self.lead.full_name} on {self.scheduled_at}" return f"Scheduled {self.purpose} on {self.scheduled_at}"
@property @property
def schedule_past_date(self): def schedule_past_date(self):

View File

@ -950,7 +950,7 @@ def create_po_item_upload(sender,instance,created,**kwargs):
def create_po_fulfilled_notification(sender,instance,created,**kwargs): def create_po_fulfilled_notification(sender,instance,created,**kwargs):
if instance.po_status == "fulfilled": if instance.po_status == "fulfilled":
dealer = models.Dealer.objects.get(entity=instance.entity) dealer = models.Dealer.objects.get(entity=instance.entity)
accountants = models.CustomGroup.objects.filter(dealer=dealer,name="Inventory").first().group.user_set.exclude(email=dealer.user.email) accountants = models.CustomGroup.objects.filter(dealer=dealer,name="Inventory").first().group.user_set.exclude(email=dealer.user.email).distinct()
for accountant in accountants: for accountant in accountants:
models.Notification.objects.create( models.Notification.objects.create(
user=accountant, user=accountant,
@ -963,7 +963,7 @@ def create_po_fulfilled_notification(sender,instance,created,**kwargs):
@receiver(post_save, sender=models.Car) @receiver(post_save, sender=models.Car)
def car_created_notification(sender, instance, created, **kwargs): def car_created_notification(sender, instance, created, **kwargs):
if created: if created:
accountants = models.CustomGroup.objects.filter(dealer=instance.dealer,name__in=["Manager","Accountant"]).first().group.user_set.all() accountants = models.CustomGroup.objects.filter(dealer=instance.dealer,name__in=["Manager","Accountant"]).first().group.user_set.all().distinct()
for accountant in accountants: for accountant in accountants:
models.Notification.objects.create( models.Notification.objects.create(
user=accountant, user=accountant,
@ -979,7 +979,7 @@ def po_fullfilled_notification(sender, instance, created, **kwargs):
recipients = User.objects.filter( recipients = User.objects.filter(
groups__customgroup__dealer=instance.dealer, groups__customgroup__dealer=instance.dealer,
groups__customgroup__name__in=["Manager", "Inventory"] groups__customgroup__name__in=["Manager", "Inventory"]
) ).distinct()
for recipient in recipients: for recipient in recipients:
models.Notification.objects.create( models.Notification.objects.create(
user=recipient, user=recipient,
@ -994,7 +994,7 @@ def vendor_created_notification(sender, instance, created, **kwargs):
recipients = User.objects.filter( recipients = User.objects.filter(
groups__customgroup__dealer=instance.dealer, groups__customgroup__dealer=instance.dealer,
groups__customgroup__name__in=["Manager", "Inventory"] groups__customgroup__name__in=["Manager", "Inventory"]
) ).distinct()
for recipient in recipients: for recipient in recipients:
models.Notification.objects.create( models.Notification.objects.create(
@ -1007,7 +1007,7 @@ def vendor_created_notification(sender, instance, created, **kwargs):
@receiver(post_save, sender=models.SaleOrder) @receiver(post_save, sender=models.SaleOrder)
def sale_order_created_notification(sender, instance, created, **kwargs): def sale_order_created_notification(sender, instance, created, **kwargs):
if created: if created:
recipients = models.CustomGroup.objects.filter(dealer=instance.dealer,name="Accountant").first().group.user_set.exclude(email=instance.dealer.user.email) recipients = models.CustomGroup.objects.filter(dealer=instance.dealer,name="Accountant").first().group.user_set.exclude(email=instance.dealer.user.email).distinct()
for recipient in recipients: for recipient in recipients:
models.Notification.objects.create( models.Notification.objects.create(
@ -1032,7 +1032,7 @@ def lead_created_notification(sender, instance, created, **kwargs):
def estimate_in_review_notification(sender, instance, created, **kwargs): def estimate_in_review_notification(sender, instance, created, **kwargs):
if instance.is_review(): if instance.is_review():
dealer = models.Dealer.objects.get(entity=instance.entity) dealer = models.Dealer.objects.get(entity=instance.entity)
recipients = models.CustomGroup.objects.filter(dealer=dealer,name="Manager").first().group.user_set.exclude(email=dealer.user.email) recipients = models.CustomGroup.objects.filter(dealer=dealer,name="Manager").first().group.user_set.exclude(email=dealer.user.email).distinct()
for recipient in recipients: for recipient in recipients:
models.Notification.objects.create( models.Notification.objects.create(
user=recipient, user=recipient,
@ -1065,7 +1065,7 @@ def estimate_in_approve_notification(sender, instance, created, **kwargs):
def bill_model_in_approve_notification(sender, instance, created, **kwargs): def bill_model_in_approve_notification(sender, instance, created, **kwargs):
if instance.is_review(): if instance.is_review():
dealer = models.Dealer.objects.get(entity=instance.ledger.entity) dealer = models.Dealer.objects.get(entity=instance.ledger.entity)
recipients = models.CustomGroup.objects.filter(dealer=dealer,name="Manager").first().group.user_set.exclude(email=dealer.user.email) recipients = models.CustomGroup.objects.filter(dealer=dealer,name="Manager").first().group.user_set.exclude(email=dealer.user.email).distinct()
for recipient in recipients: for recipient in recipients:
models.Notification.objects.create( models.Notification.objects.create(
@ -1080,7 +1080,7 @@ def bill_model_in_approve_notification(sender, instance, created, **kwargs):
def bill_model_after_approve_notification(sender, instance, created, **kwargs): def bill_model_after_approve_notification(sender, instance, created, **kwargs):
if instance.is_approved(): if instance.is_approved():
dealer = models.Dealer.objects.get(entity=instance.ledger.entity) dealer = models.Dealer.objects.get(entity=instance.ledger.entity)
recipients = models.CustomGroup.objects.filter(dealer=dealer,name="Accountant").first().group.user_set.exclude(email=dealer.user.email) recipients = models.CustomGroup.objects.filter(dealer=dealer,name="Accountant").first().group.user_set.exclude(email=dealer.user.email).distinct()
for recipient in recipients: for recipient in recipients:
models.Notification.objects.create( models.Notification.objects.create(

View File

@ -120,6 +120,11 @@ urlpatterns = [
views.update_task, views.update_task,
name="update_task", name="update_task",
), ),
path(
"<slug:dealer_slug>/<int:pk>/update-schedule/",
views.update_schedule,
name="update_schedule",
),
path( path(
"<slug:dealer_slug>/crm/<str:content_type>/<slug:slug>/add-task/", "<slug:dealer_slug>/crm/<str:content_type>/<slug:slug>/add-task/",
views.add_task, views.add_task,

View File

@ -44,7 +44,7 @@ from django.http import (
JsonResponse, JsonResponse,
HttpResponseForbidden, HttpResponseForbidden,
) )
from django.forms import HiddenInput, ValidationError from django.forms import CharField, HiddenInput, ValidationError
from django.shortcuts import HttpResponse from django.shortcuts import HttpResponse
from django.db.models import Sum, F, Count from django.db.models import Sum, F, Count
@ -4346,8 +4346,6 @@ def create_estimate(request, dealer_slug, slug=None):
data = json.loads(request.body) data = json.loads(request.body)
title = data.get("title") title = data.get("title")
customer_id = data.get("customer") customer_id = data.get("customer")
# terms = data.get("terms")
# customer = entity.get_customers().filter(pk=customer_id).first()
customer = models.Customer.objects.filter(pk=int(customer_id)).first() customer = models.Customer.objects.filter(pk=int(customer_id)).first()
items = data.get("item", []) items = data.get("item", [])
@ -4543,7 +4541,8 @@ def create_estimate(request, dealer_slug, slug=None):
status="available", status="available",
) )
.annotate( .annotate(
color=F("colors__exterior__rgb"), exterior_color=F("colors__exterior__rgb"),
interior_color=F("colors__interior__rgb"),
color_name=F("colors__exterior__arabic_name"), color_name=F("colors__exterior__arabic_name"),
) )
.values_list( .values_list(
@ -4551,9 +4550,11 @@ def create_estimate(request, dealer_slug, slug=None):
"id_car_model__arabic_name", "id_car_model__arabic_name",
"id_car_serie__arabic_name", "id_car_serie__arabic_name",
"id_car_trim__arabic_name", "id_car_trim__arabic_name",
"color", "exterior_color",
"interior_color",
"color_name", "color_name",
"hash", "hash",
"id_car_make__logo"
) )
.annotate(hash_count=Count("hash")) .annotate(hash_count=Count("hash"))
.distinct() .distinct()
@ -4566,10 +4567,12 @@ def create_estimate(request, dealer_slug, slug=None):
"model": x[1], "model": x[1],
"serie": x[2], "serie": x[2],
"trim": x[3], "trim": x[3],
"color": x[4], "exterior_color": x[4],
"color_name": x[5], "interior_color": x[5],
"hash": x[6], "color_name": x[6],
"hash_count": x[7], "hash": x[7],
"logo": settings.MEDIA_URL + x[8],
"hash_count": x[9],
} }
for x in car_list for x in car_list
], ],
@ -4849,7 +4852,6 @@ def estimate_mark_as(request, dealer_slug, pk):
"estimate_detail", dealer_slug=dealer.slug, pk=estimate.pk "estimate_detail", dealer_slug=dealer.slug, pk=estimate.pk
) )
estimate.mark_as_review() estimate.mark_as_review()
elif mark == "approved": elif mark == "approved":
if not estimate.can_approve(): if not estimate.can_approve():
messages.error(request, _("Quotation is not ready for approval")) messages.error(request, _("Quotation is not ready for approval"))
@ -5618,6 +5620,11 @@ class LeadDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
context["tasks"] = models.Tasks.objects.filter( context["tasks"] = models.Tasks.objects.filter(
content_type__model="lead", object_id=self.object.id content_type__model="lead", object_id=self.object.id
) )
context["schedules"] = models.Schedule.objects.filter(
dealer=dealer,
content_type__model="lead", object_id=self.object.id,
scheduled_by=self.request.user
)
context["status_history"] = models.LeadStatusHistory.objects.filter( context["status_history"] = models.LeadStatusHistory.objects.filter(
lead=self.object lead=self.object
) )
@ -5630,6 +5637,7 @@ class LeadDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
context["activity_form"] = forms.ActivityForm() context["activity_form"] = forms.ActivityForm()
context["staff_task_form"] = forms.StaffTaskForm() context["staff_task_form"] = forms.StaffTaskForm()
context["note_form"] = forms.NoteForm() context["note_form"] = forms.NoteForm()
context["schedule_form"] = forms.ScheduleForm()
return context return context
@ -6099,6 +6107,7 @@ def lead_convert(request,dealer_slug, slug):
@login_required @login_required
@require_POST
@permission_required("inventory.add_schedule", raise_exception=True) @permission_required("inventory.add_schedule", raise_exception=True)
def schedule_event(request, dealer_slug,content_type,slug): def schedule_event(request, dealer_slug,content_type,slug):
""" """
@ -6145,6 +6154,7 @@ def schedule_event(request, dealer_slug,content_type,slug):
form = forms.ScheduleForm(request.POST) form = forms.ScheduleForm(request.POST)
if form.is_valid(): if form.is_valid():
instance = form.save(commit=False) instance = form.save(commit=False)
instance.dealer = dealer
instance.content_object = obj instance.content_object = obj
instance.scheduled_by = request.user instance.scheduled_by = request.user
@ -6178,8 +6188,8 @@ def schedule_event(request, dealer_slug,content_type,slug):
Appointment.objects.create( Appointment.objects.create(
client=client, client=client,
appointment_request=appointment_request, appointment_request=appointment_request,
phone=instance.phone, phone=instance.customer.phone,
address=instance.address_1, address=instance.customer.address_1,
) )
instance.save() instance.save()
@ -6199,8 +6209,8 @@ def schedule_event(request, dealer_slug,content_type,slug):
) )
messages.error(request, f"Invalid form data: {str(form.errors)}") messages.error(request, f"Invalid form data: {str(form.errors)}")
return redirect(request.META.get("HTTP_REFERER")) return redirect(request.META.get("HTTP_REFERER"))
form = forms.ScheduleForm() # form = forms.ScheduleForm()
return render(request, "crm/leads/schedule_lead.html", {"lead": lead, "form": form}) # return render(request, "crm/leads/schedule_lead.html", {"lead": lead, "form": form})
@login_required @login_required
@ -9321,6 +9331,16 @@ def update_task(request,dealer_slug, pk):
# tasks = models.Tasks.objects.filter(content_type__model=content_type, object_id=obj.id) # tasks = models.Tasks.objects.filter(content_type__model=content_type, object_id=obj.id)
return render(request, "partials/task.html", {"task": task}) return render(request, "partials/task.html", {"task": task})
@login_required
@permission_required("inventory.change_schedule", raise_exception=True)
def update_schedule(request,dealer_slug, pk):
task = get_object_or_404(models.Schedule, pk=pk)
if request.method == "POST":
task.completed = False if task.completed else True
task.save()
return render(request, "partials/task.html", {"task": task})
@login_required @login_required

View File

@ -9,7 +9,7 @@
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form action="{% url 'schedule_lead' request.dealer.slug content_type slug %}" method="post" class="add_schedule_form"> <form action="{% url 'schedule_event' request.dealer.slug content_type slug %}" method="post" class="add_schedule_form">
{% csrf_token %} {% csrf_token %}
{{ schedule_form|crispy }} {{ schedule_form|crispy }}
<button type="submit" class="btn btn-phoenix-success w-100">{% trans 'Save' %}</button> <button type="submit" class="btn btn-phoenix-success w-100">{% trans 'Save' %}</button>

View File

@ -56,8 +56,8 @@
<div class="d-flex justify-content-between align-items-center mb-2 d-md-none"> <div class="d-flex justify-content-between align-items-center mb-2 d-md-none">
<h3 class="mb-0">{{ _("Lead Details")}}</h3> <h3 class="mb-0">{{ _("Lead Details")}}</h3>
<button class="btn p-0" ><span class="uil uil-times fs-7"></span></button> <button class="btn p-0" ><span class="uil uil-times fs-7"></span></button>
</div> </div>
<div class="card mb-2"> <div class="card mb-2">
<div class="card-body"> <div class="card-body">
<div class="row align-items-center g-3 text-center text-xxl-start"> <div class="row align-items-center g-3 text-center text-xxl-start">
<div class="col-6 col-sm-auto flex-1"> <div class="col-6 col-sm-auto flex-1">
@ -205,7 +205,6 @@
<div class="mb-1 d-flex justify-content-between align-items-center"> <div class="mb-1 d-flex justify-content-between align-items-center">
<h3 class="mb-4" id="s crollspyTask">{{ _("Activities") }} <span class="fw-light fs-7">({{ activities.count}})</span></h3> <h3 class="mb-4" id="s crollspyTask">{{ _("Activities") }} <span class="fw-light fs-7">({{ activities.count}})</span></h3>
{% if perms.inventory.change_lead%} {% if perms.inventory.change_lead%}
<button class="btn btn-phoenix-primary btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#activityModal"><span class="fas fa-plus me-1"></span>{{ _("Add Activity") }}</button>
{% endif %} {% endif %}
</div> </div>
<div class="row justify-content-between align-items-md-center hover-actions-trigger btn-reveal-trigger border-translucent py-3 gx-0 border-top"> <div class="row justify-content-between align-items-md-center hover-actions-trigger btn-reveal-trigger border-translucent py-3 gx-0 border-top">
@ -471,7 +470,8 @@
<div class="mb-1 d-flex justify-content-between align-items-center"> <div class="mb-1 d-flex justify-content-between align-items-center">
<h3 class="mb-0" id="scrollspyEmails">{{ _("Tasks") }}</h3> <h3 class="mb-0" id="scrollspyEmails">{{ _("Tasks") }}</h3>
{% if perms.inventory.change_lead%} {% if perms.inventory.change_lead%}
<button class="btn btn-phoenix-primary btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#taskModal"><span class="fas fa-plus me-1"></span>{{ _("Add Task") }}</button> {% comment %} <button class="btn btn-phoenix-primary btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#taskModal"><span class="fas fa-plus me-1"></span>{{ _("Add Task") }}</button> {% endcomment %}
<button class="btn btn-phoenix-primary btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#scheduleModal"><span class="fas fa-plus me-1"></span>{{ _("Add Task") }}</button>
{% endif %} {% endif %}
</div> </div>
<div> <div>
@ -493,7 +493,7 @@
</tr> </tr>
</thead> </thead>
<tbody class="list" id="all-tasks-table-body"> <tbody class="list" id="all-tasks-table-body">
{% for task in tasks %} {% for task in schedules %}
{% include "partials/task.html" %} {% include "partials/task.html" %}
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -466,8 +466,13 @@
{% endif %} {% endif %}
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link px-3 d-block" href=""> <span class="me-2 text-body align-bottom" data-feather="help-circle"></span>Help Center</a> <a class="nav-link px-3 d-block" href=""> <span class="me-2 text-body align-bottom" data-feather="help-circle"></span>{{ _("Help Center") }}</a>
</li> </li>
{% if request.is_staff %}
<li class="nav-item">
<a class="nav-link px-3 d-block" href="{% url 'appointment:get_user_appointments' %}"> <span class="me-2 text-body align-bottom" data-feather="calendar"></span>{{ _("Calendar") }}</a>
</li>
{% endif %}
<!--<li class="nav-item"><a class="nav-link px-3 d-block" href=""> Language</a></li>--> <!--<li class="nav-item"><a class="nav-link px-3 d-block" href=""> Language</a></li>-->
</ul> </ul>
</div> </div>

View File

@ -2,14 +2,14 @@
<tr id="task-{{task.pk}}" class="hover-actions-trigger btn-reveal-trigger position-static {% if task.completed %}completed-task{% endif %}"> <tr id="task-{{task.pk}}" class="hover-actions-trigger btn-reveal-trigger position-static {% if task.completed %}completed-task{% endif %}">
<td class="fs-9 align-middle px-0 py-3"> <td class="fs-9 align-middle px-0 py-3">
<div class="form-check mb-0 fs-8"> <div class="form-check mb-0 fs-8">
<input class="form-check-input" {% if task.completed %}checked{% endif %} type="checkbox" hx-post="{% url 'update_task' request.dealer.slug task.pk %}" hx-trigger="change" hx-swap="outerHTML" hx-target="#task-{{task.pk}}" /> <input class="form-check-input" {% if task.completed %}checked{% endif %} type="checkbox" hx-post="{% url 'update_schedule' request.dealer.slug task.pk %}" hx-trigger="change" hx-swap="outerHTML" hx-target="#task-{{task.pk}}" />
</div> </div>
</td> </td>
<td class="subject order align-middle white-space-nowrap py-2 ps-0"><a class="fw-semibold text-primary" href="">{{task.title}}</a> <td class="subject order align-middle white-space-nowrap py-2 ps-0"><a class="fw-semibold text-primary" href="">{{task.purpose|capfirst}}</a>
<div class="fs-10 d-block">{{task.description}}</div> <div class="fs-10 d-block">{{task.scheduled_type|capfirst}}</div>
</td> </td>
<td class="sent align-middle white-space-nowrap text-start fw-bold text-body-tertiary py-2">{{task.assigned_to}}</td> <td class="sent align-middle white-space-nowrap text-start fw-bold text-body-tertiary py-2">{{task.scheduled_by}}</td>
<td class="date align-middle white-space-nowrap text-body py-2">{{task.created|naturalday|capfirst}}</td> <td class="date align-middle white-space-nowrap text-body py-2">{{task.created_at|naturalday|capfirst}}</td>
<td class="date align-middle white-space-nowrap text-body py-2"> <td class="date align-middle white-space-nowrap text-body py-2">
{% if task.completed %} {% if task.completed %}
<span class="badge badge-phoenix fs-10 badge-phoenix-success"><i class="fa-solid fa-check"></i></span> <span class="badge badge-phoenix fs-10 badge-phoenix-success"><i class="fa-solid fa-check"></i></span>

View File

@ -0,0 +1,195 @@
{% extends "base.html" %}
{% load crispy_forms_filters %}
{% load i18n static %}
{% block title %}{{ _("Create Quotation") }}{% endblock title %}
{% block customCSS %}
<style>
.disabled{
opacity: 0.5;
pointer-events: none;
}
</style>
{% endblock customCSS %}
{% block content %}
<div class="row mt-4">
{% if not items %}
<div class="alert alert-outline-warning d-flex align-items-center" role="alert">
<i class="fa-solid fa-circle-info fs-6"></i>
<p class="mb-0 flex-1">{{ _("Please add at least one car before creating a quotation.") }}<a class="ms-3 text-body-primary fs-9" href="{% url 'car_add' request.dealer.slug %}"> {{ _("Add Car") }} </a></p>
<button class="btn-close" type="button" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{% if not customer_count %}
<div class="alert alert-outline-warning d-flex align-items-center" role="alert">
<i class="fa-solid fa-circle-info fs-6"></i>&nbsp;&nbsp;
<p class="mb-0 flex-1"> {{ _("Please add at least one customer before creating a quotation.") }}<a class="ms-3 text-body-primary fs-9" href="{% url 'customer_create' request.dealer.slug %}"> {{ _("Add Customer") }} </a></p>
<button class="btn-close" type="button" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<form id="mainForm" method="post" class="needs-validation {% if not items or not customer_count %}disabled{% endif %}">
<h3 class="text-center"><i class="fa-regular fa-file-lines"></i> {% trans "Create Quotation" %}</h3>
{% csrf_token %}
<div class="row g-3 col-10">
{{ form|crispy }}
<div class="row mt-5">
<div id="formrow">
<h3 class="text-start"><i class="fa-solid fa-car-side"></i> {{ _("Cars") }}</h3>
<div class="form-row row g-3 mb-3 mt-5">
<div class="mb-2 col-sm-4 col-md-6 col-lg-4">
<select class="form-control item" name="item[]" required>
{% for item in items %}
<option style="background-color: rgb({{ item.color }});" value="{{ item.hash }}">{{ item.make }} {{item.model}} {{item.serie}} {{item.trim}} {{item.color_name}} ({{item.hash_count}})</option>
{% empty %}
<option disabled>{% trans "No Cars Found" %}</option>
{% endfor %}
</select>
</div>
<div class="mb-2 col-sm-4 col-md-3">
<input class="form-control quantity" type="number" placeholder="Quantity" name="quantity[]" required>
</div>
<div class="mb-2 col-sm-3 col-md-3">
<button class="btn btn-sm btn-phoenix-danger removeBtn"><i class="fa-solid fa-trash me-1"></i> {{ _("Remove") }}</button>
</div>
</div>
</div>
<div class="col-12">
<button id="addMoreBtn" class="btn btn-sm btn-phoenix-primary"><i class="fa-solid fa-plus me-2"></i> {{ _("Add More")}}</button>
</div>
</div>
</div>
<!-- Buttons -->
{% comment %} <div class="mt-5 text-center">
<button type="submit" class="btn btn-success me-2" {% if not items %}disabled{% endif %}><i class="fa-solid fa-floppy-disk me-1"></i> {% trans "Save" %}</button>
<a href="{% url 'estimate_list' %}" class="btn btn-danger"><i class="fa-solid fa-ban me-1"></i> {% trans "Cancel" %}</a>
</div> {% endcomment %}
<div class="d-flex justify-content-center">
<button class="btn btn-sm btn-phoenix-success me-2" type="submit"><i class="fa-solid fa-floppy-disk me-1"></i>
<!--<i class="bi bi-save"></i> -->
{{ _("Save") }}
</button>
<a href="{{request.META.HTTP_REFERER}}" class="btn btn-sm btn-phoenix-danger"><i class="fa-solid fa-ban me-1"></i>{% trans "Cancel" %}</a>
</div>
</form>
</div>
{% endblock content %}
{% block customJS %}
<script>
const Toast = Swal.mixin({
toast: true,
position: "top-end",
showConfirmButton: false,
timer: 2000,
timerProgressBar: false,
didOpen: (toast) => {
toast.onmouseenter = Swal.stopTimer;
toast.onmouseleave = Swal.resumeTimer;
}
});
// Add new form fields
document.getElementById('addMoreBtn').addEventListener('click', function(e) {
e.preventDefault();
const formrow = document.getElementById('formrow');
const newForm = document.createElement('div');
newForm.className = 'form-row row g-3 mb-3 mt-5';
newForm.innerHTML = `
<div class="mb-2 col-sm-4">
<select class="form-control item" name="item[]" required>
{% for item in items %}
<option value="{{ item.hash }}">{{ item.make }} {{item.model}} {{item.serie}} {{item.trim}} {{item.color}}</option>
{% endfor %}
</select>
</div>
<div class="mb-2 col-sm-2">
<input class="form-control quantity" type="number" placeholder="Quantity" name="quantity[]" required>
</div>
<div class="mb-2 col-sm-1">
<button class="btn btn-phoenix-danger removeBtn"><i class="fa-solid fa-trash"></i> {{ _("Remove") }}</button>
</div>
`;
formrow.appendChild(newForm);
// Add remove button functionality
newForm.querySelector('.removeBtn').addEventListener('click', function() {
newForm.remove();
});
});
// Add remove button functionality to the initial form
document.querySelectorAll('.form-row').forEach(row => {
row.querySelector('.removeBtn').addEventListener('click', function() {
row.remove();
});
});
// Handle form submission
document.getElementById('mainForm').addEventListener('submit', async function(e) {
e.preventDefault();
const titleInput = document.querySelector('[name="title"]');
if (titleInput.value.length < 5) {
notify("error", "Customer Estimate Title must be at least 5 characters long.");
return; // Stop form submission
}
// Collect all form data
const formData = {
csrfmiddlewaretoken: document.querySelector('[name=csrfmiddlewaretoken]').value,
title: document.querySelector('[name=title]').value,
customer: document.querySelector('[name=customer]').value,
item: [],
quantity: [],
opportunity_id: "{{opportunity_id}}"
};
// Collect multi-value fields (e.g., item[], quantity[])
document.querySelectorAll('[name="item[]"]').forEach(input => {
formData.item.push(input.value);
});
document.querySelectorAll('[name="quantity[]"]').forEach(input => {
formData.quantity.push(input.value);
});
console.log(formData);
try {
// Send data to the server using fetch
const response = await fetch("{% url 'estimate_create' request.dealer.slug %}", {
method: 'POST',
headers: {
'X-CSRFToken': formData.csrfmiddlewaretoken,
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
});
// Parse the JSON response
const data = await response.json();
// Handle the response
if (data.status === "error") {
notify("error", data.message); // Display an error message
} else if (data.status === "success") {
notify("success","Estimate created successfully");
setTimeout(() => {
window.location.assign(data.url); // Redirect to the provided URL
}, 1000);
} else {
notify("error","Unexpected response from the server");
}
} catch (error) {
notify("error", error);
}
});
</script>
{% endblock customJS %}

View File

@ -9,9 +9,118 @@
.disabled{ .disabled{
opacity: 0.5; opacity: 0.5;
pointer-events: none; pointer-events: none;
}
.color-box {
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid #ccc;
} /* Custom select styles */
.custom-select {
position: relative;
width: 100%;
margin-bottom: 1.5rem;
}
.select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 1rem;
border: 1px solid #ddd;
border-radius: 6px;
background-color: white;
cursor: pointer;
transition: all 0.2s ease;
}
.select-trigger:hover {
border-color: #aaa;
}
.select-trigger.active {
border-color: #4a90e2;
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
}
.selected-value {
display: flex;
align-items: center;
gap: 0.75rem;
}
.selected-value img {
width: 30px;
height: 20px;
object-fit: contain;
}
.dropdown-icon {
transition: transform 0.2s ease;
}
.custom-select.open .dropdown-icon {
transform: rotate(180deg);
}
.options-container {
position: absolute;
top: 100%;
left: 0;
width: 100%;
max-height: 300px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 6px;
background-color: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 100;
display: none;
}
.custom-select.open .options-container {
display: block;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.option {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
cursor: pointer;
transition: background-color 0.1s ease;
gap: 0.75rem;
}
.option:hover {
background-color: #f0f7ff;
}
.option.selected {
background-color: #e6f0ff;
}
.option img {
width: 30px;
height: 20px;
object-fit: contain;
}
/* Hidden native select for form submission */
.native-select {
position: absolute;
opacity: 0;
height: 0;
width: 0;
} }
</style> </style>
{% endblock customCSS %} {% endblock customCSS %}
{% block content %} {% block content %}
<div class="row mt-4"> <div class="row mt-4">
{% if not items %} {% if not items %}
@ -33,58 +142,53 @@
{% csrf_token %} {% csrf_token %}
<div class="row g-3 col-10"> <div class="row g-3 col-10">
{{ form|crispy }} {{ form|crispy }}
<div class="row mt-5"> <div class="custom-select">
<div id="formrow"> <!-- Hidden native select for form submission -->
<h3 class="text-start"><i class="fa-solid fa-car-side"></i> {{ _("Cars") }}</h3> <select class="native-select" name="item" required tabindex="-1">
<div class="form-row row g-3 mb-3 mt-5"> <option value="">Select a car</option>
<div class="mb-2 col-sm-4 col-md-6 col-lg-4"> {% for item in items %}
<select class="form-control item" name="item[]" required> <option value="{{ item.hash }}"></option>
{% for item in items %} {% endfor %}
<option style="background-color: rgb({{ item.color }});" value="{{ item.hash }}">{{ item.make }} {{item.model}} {{item.serie}} {{item.trim}} {{item.color_name}} ({{item.hash_count}})</option> </select>
{% empty %}
<option disabled>{% trans "No Cars Found" %}</option>
{% endfor %}
</select>
</div>
<div class="mb-2 col-sm-4 col-md-3">
<input class="form-control quantity" type="number" placeholder="Quantity" name="quantity[]" required>
</div>
<div class="mb-2 col-sm-3 col-md-3">
<button class="btn btn-sm btn-phoenix-danger removeBtn"><i class="fa-solid fa-trash me-1"></i> {{ _("Remove") }}</button>
</div>
</div>
</div>
<div class="col-12">
<button id="addMoreBtn" class="btn btn-sm btn-phoenix-primary"><i class="fa-solid fa-plus me-2"></i> {{ _("Add More")}}</button>
</div>
<!-- Custom select UI -->
<div class="select-trigger">
<div class="selected-value">
<span>Select a car</span>
</div>
<i class="fas fa-chevron-down dropdown-icon"></i>
</div>
<div class="options-container">
{% for item in items %}
<div class="option" data-value="{{ item.hash }}" data-image="{{item.logo}}">
<img src="{{item.logo}}" alt="{{item.model}}">
<span>{{item.make}} {{item.model}} {{item.serie}} {{item.trim}} {{item.color_name}}</span>
<div class="color-box" style="background-color: rgb({{ item.exterior_color }});"></div>
<div class="color-box" style="background-color: rgb({{ item.interior_color }});"></div>
</div>
{% endfor %}
</div> </div>
</div> </div>
<!-- Buttons --> </div>
{% comment %} <div class="mt-5 text-center">
<button type="submit" class="btn btn-success me-2" {% if not items %}disabled{% endif %}><i class="fa-solid fa-floppy-disk me-1"></i> {% trans "Save" %}</button>
<a href="{% url 'estimate_list' %}" class="btn btn-danger"><i class="fa-solid fa-ban me-1"></i> {% trans "Cancel" %}</a>
</div> {% endcomment %}
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
<button class="btn btn-sm btn-phoenix-success me-2" type="submit">
<button class="btn btn-sm btn-phoenix-success me-2" type="submit"><i class="fa-solid fa-floppy-disk me-1"></i> <i class="fa-solid fa-floppy-disk me-1"></i>
<!--<i class="bi bi-save"></i> -->
{{ _("Save") }} {{ _("Save") }}
</button> </button>
<a href="{{request.META.HTTP_REFERER}}" class="btn btn-sm btn-phoenix-danger">
<a href="{{request.META.HTTP_REFERER}}" class="btn btn-sm btn-phoenix-danger"><i class="fa-solid fa-ban me-1"></i>{% trans "Cancel" %}</a> <i class="fa-solid fa-ban me-1"></i>{% trans "Cancel" %}
</a>
</div> </div>
</form> </form>
</div> </div>
{% endblock content %} {% endblock content %}
{% block customJS %} {% block customJS %}
<script> <script>
const Toast = Swal.mixin({ document.addEventListener('DOMContentLoaded', function() {
const Toast = Swal.mixin({
toast: true, toast: true,
position: "top-end", position: "top-end",
showConfirmButton: false, showConfirmButton: false,
@ -95,44 +199,86 @@
toast.onmouseleave = Swal.resumeTimer; toast.onmouseleave = Swal.resumeTimer;
} }
}); });
// Add new form fields function notify(tag,msg){Toast.fire({icon: tag,titleText: msg});}
document.getElementById('addMoreBtn').addEventListener('click', function(e) {
e.preventDefault();
const formrow = document.getElementById('formrow');
const newForm = document.createElement('div');
newForm.className = 'form-row row g-3 mb-3 mt-5';
newForm.innerHTML = `
<div class="mb-2 col-sm-4">
<select class="form-control item" name="item[]" required>
{% for item in items %}
<option value="{{ item.hash }}">{{ item.make }} {{item.model}} {{item.serie}} {{item.trim}} {{item.color}}</option>
{% endfor %}
</select>
</div>
<div class="mb-2 col-sm-2">
<input class="form-control quantity" type="number" placeholder="Quantity" name="quantity[]" required>
</div>
<div class="mb-2 col-sm-1">
<button class="btn btn-phoenix-danger removeBtn"><i class="fa-solid fa-trash"></i> {{ _("Remove") }}</button>
</div>
`;
formrow.appendChild(newForm);
// Add remove button functionality const customSelects = document.querySelectorAll('.custom-select');
newForm.querySelector('.removeBtn').addEventListener('click', function() {
newForm.remove(); customSelects.forEach(select => {
const trigger = select.querySelector('.select-trigger');
const optionsContainer = select.querySelector('.options-container');
const options = select.querySelectorAll('.option');
const nativeSelect = select.querySelector('.native-select');
const selectedValue = select.querySelector('.selected-value');
// Toggle dropdown
trigger.addEventListener('click', (e) => {
e.stopPropagation();
select.classList.toggle('open');
trigger.classList.toggle('active');
// Close other open selects
document.querySelectorAll('.custom-select').forEach(otherSelect => {
if (otherSelect !== select) {
otherSelect.classList.remove('open');
otherSelect.querySelector('.select-trigger').classList.remove('active');
}
});
});
// Handle option selection
options.forEach(option => {
option.addEventListener('click', () => {
const value = option.getAttribute('data-value');
const image = option.getAttribute('data-image');
const text = option.querySelector('span').textContent;
// Update selected display
selectedValue.innerHTML = `
<img src="${image}" alt="${text}">
<span>${text}</span>
`;
// Update native select value
nativeSelect.value = value;
// Mark as selected
options.forEach(opt => opt.classList.remove('selected'));
option.classList.add('selected');
// Close dropdown
select.classList.remove('open');
trigger.classList.remove('active');
// Trigger change event
const event = new Event('change');
nativeSelect.dispatchEvent(event);
});
});
// Close when clicking outside
document.addEventListener('click', () => {
select.classList.remove('open');
trigger.classList.remove('active');
});
// Initialize with native select value
if (nativeSelect.value) {
const selectedOption = select.querySelector(`.option[data-value="${nativeSelect.value}"]`);
if (selectedOption) {
selectedOption.click();
}
}
}); });
});
// Add remove button functionality to the initial form // Form submission
document.querySelectorAll('.form-row').forEach(row => { /*const form = document.getElementById('demo-form');
row.querySelector('.removeBtn').addEventListener('click', function() { form.addEventListener('submit', function(e) {
row.remove(); e.preventDefault();
}); const formData = new FormData(form);
}); alert(`Selected value: ${formData.get('car')}`);
});*/
// Handle form submission document.getElementById('mainForm').addEventListener('submit', async function(e) {
document.getElementById('mainForm').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
const titleInput = document.querySelector('[name="title"]'); const titleInput = document.querySelector('[name="title"]');
@ -147,18 +293,16 @@
title: document.querySelector('[name=title]').value, title: document.querySelector('[name=title]').value,
customer: document.querySelector('[name=customer]').value, customer: document.querySelector('[name=customer]').value,
item: [], item: [],
quantity: [], quantity: [1],
opportunity_id: "{{opportunity_id}}" opportunity_id: "{{opportunity_id}}"
}; };
// Collect multi-value fields (e.g., item[], quantity[]) // Collect multi-value fields (e.g., item[], quantity[])
document.querySelectorAll('[name="item[]"]').forEach(input => { document.querySelectorAll('[name="item"]').forEach(input => {
formData.item.push(input.value); formData.item.push(input.value);
}); });
document.querySelectorAll('[name="quantity[]"]').forEach(input => {
formData.quantity.push(input.value);
});
console.log(formData); console.log(formData);
try { try {
@ -191,5 +335,7 @@
notify("error", error); notify("error", error);
} }
}); });
});
</script> </script>
{% endblock customJS %} {% endblock customJS %}