implement the appointment and calendar

This commit is contained in:
ismail 2025-08-05 17:55:48 +03:00
parent 1ed2bbcb75
commit 9a42c82da5
66 changed files with 957 additions and 237 deletions

View File

@ -1179,10 +1179,16 @@ class ScheduleForm(forms.ModelForm):
"purpose",
"scheduled_type",
"scheduled_at",
"duration",
"start_time",
"end_time",
"notes",
]
widgets = {
"start_time": forms.TimeInput(attrs={"type": "time"}),
"end_time": forms.TimeInput(attrs={"type": "time"}),
}
class NoteForm(forms.ModelForm):
"""

View File

@ -69,12 +69,12 @@ class Command(BaseCommand):
user.is_staff = True
user.save()
staff_member = StaffMember.objects.create(user=user)
services = Service.objects.all()
for service in services:
staff_member.services_offered.add(service)
# staff_member = StaffMember.objects.create(user=user)
# services = Service.objects.all()
# for service in services:
# staff_member.services_offered.add(service)
staff = Staff.objects.create(dealer=dealer,staff_member=staff_member,name=name,arabic_name=name,phone_number=fake.phone_number(),active=True)
staff = Staff.objects.create(dealer=dealer,user=user,name=name,arabic_name=name,phone_number=fake.phone_number(),active=True)
groups = CustomGroup.objects.filter(dealer=dealer)
random_group = random.choice(list(groups))

View File

@ -108,13 +108,13 @@ class InjectDealerMiddleware:
request.is_dealer = True
request.dealer = request.user.dealer
elif hasattr(request.user, "staffmember"):
request.is_staff = True
request.staff = request.user.staffmember.staff
elif hasattr(request.user, "staff"):
request.staff = getattr(request.user, "staff")
request.dealer = request.staff.dealer
request.is_staff = True
staff_groups = request.staff.groups.values_list("name", flat=True)
staff = getattr(request.user.staffmember, "staff")
staff_groups = staff.groups.values_list("name", flat=True)
if "Accountant" in staff_groups:
request.is_accountant = True
elif "Manager" in staff_groups:

View File

@ -40,7 +40,7 @@ from django_ledger.models import (
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.serializers.json import DjangoJSONEncoder
from appointment.models import StaffMember
# from appointment.models import StaffMember
from plans.quota import get_user_quota
from plans.models import UserPlan
from django.db.models import Q
@ -1277,8 +1277,11 @@ class StaffTypes(models.TextChoices):
class Staff(models.Model, LocalizedNameMixin):
staff_member = models.OneToOneField(
StaffMember, on_delete=models.CASCADE, related_name="staff"
# staff_member = models.OneToOneField(
# StaffMember, on_delete=models.CASCADE, related_name="staff"
# )
user = models.OneToOneField(
User, on_delete=models.CASCADE, related_name="staff"
)
dealer = models.ForeignKey(Dealer, on_delete=models.CASCADE, related_name="staff")
first_name = models.CharField(max_length=255, verbose_name=_("First Name"))
@ -1338,17 +1341,17 @@ class Staff(models.Model, LocalizedNameMixin):
self.save()
def permenant_delete(self):
# self.user.delete()
self.staff_member.delete()
self.user.delete()
# self.staff_member.delete()
self.delete()
@property
def email(self):
return self.staff_member.user.email
return self.user.email
@property
def user(self):
return self.staff_member.user
# @property
# def user(self):
# return self.staff_member.user
@property
def groups(self):
@ -1388,6 +1391,12 @@ class Staff(models.Model, LocalizedNameMixin):
models.Index(fields=["staff_type"]),
]
permissions = []
constraints = [
models.UniqueConstraint(
fields=['dealer', 'user'],
name='unique_staff_email_per_dealer'
)
]
def __str__(self):
return f"{self.name}"
@ -1504,7 +1513,7 @@ class Customer(models.Model):
verbose_name=_("Gender"),
)
dob = models.DateField(verbose_name=_("Date of Birth"), null=True, blank=True)
email = models.EmailField(unique=True, verbose_name=_("Email"))
email = models.EmailField(verbose_name=_("Email"))
national_id = models.CharField(
max_length=10, unique=True, verbose_name=_("National ID"), null=True, blank=True
)
@ -1546,6 +1555,12 @@ class Customer(models.Model):
super().save(*args, **kwargs)
class Meta:
constraints = [
models.UniqueConstraint(
fields=['dealer', 'email'],
name='unique_customer_email_per_dealer'
)
]
verbose_name = _("Customer")
verbose_name_plural = _("Customers")
indexes = [
@ -1566,19 +1581,21 @@ class Customer(models.Model):
def create_customer_model(self, for_lead=False):
customer_dict = to_dict(self)
customer = self.dealer.entity.create_customer(
commit=False,
customer_model_kwargs={
"customer_name": self.full_name,
"address_1": self.address,
"phone": self.phone_number,
"email": self.email,
},
)
try:
customer.additional_info.update({"customer_info": customer_dict})
except Exception:
pass
customer = self.dealer.entity.get_customers().filter(email=self.email).first()
if not customer:
customer = self.dealer.entity.create_customer(
commit=False,
customer_model_kwargs={
"customer_name": self.full_name,
"address_1": self.address,
"phone": self.phone_number,
"email": self.email,
},
)
try:
customer.additional_info.update({"customer_info": customer_dict})
except Exception:
pass
customer.active = False if for_lead else True
customer.save()
self.customer_model = customer
@ -1608,15 +1625,17 @@ class Customer(models.Model):
return customer
def create_user_model(self, for_lead=False):
user = User.objects.create_user(
user, created = User.objects.get_or_create(
username=self.email,
email=self.email,
first_name=self.first_name,
last_name=self.last_name,
password=make_random_password(),
is_staff=False,
is_superuser=False,
is_active=False if for_lead else True,
defaults={
'email': self.email,
'first_name': self.first_name,
'last_name': self.last_name,
'password': make_random_password(),
'is_staff': False,
'is_superuser': False,
'is_active': False if for_lead else True,
},
)
self.user = user
self.save()
@ -2057,11 +2076,12 @@ class Schedule(models.Model):
scheduled_by = models.ForeignKey(User, on_delete=models.CASCADE)
purpose = models.CharField(max_length=200, choices=PURPOSE_CHOICES)
scheduled_at = models.DateTimeField()
start_time = models.TimeField(verbose_name=_("Start Time"), null=True, blank=True)
end_time = models.TimeField(verbose_name=_("End Time"), null=True, blank=True)
scheduled_type = models.CharField(
max_length=200, choices=ScheduledType, default="Call"
)
completed = models.BooleanField(default=False, verbose_name=_("Completed"))
duration = models.DurationField(default=timedelta(minutes=5))
notes = models.TextField(blank=True, null=True)
status = models.CharField(
max_length=200, choices=ScheduleStatusChoices, default="Scheduled"
@ -2073,11 +2093,17 @@ class Schedule(models.Model):
return f"Scheduled {self.purpose} on {self.scheduled_at}"
@property
def duration(self):
return (self.end_time - self.start_time).seconds
@property
def schedule_past_date(self):
if self.scheduled_at < now():
return True
return False
@property
def get_purpose(self):
return self.purpose.replace("_", " ").title()
class Meta:
ordering = ["-scheduled_at"]
verbose_name = _("Schedule")
@ -2319,6 +2345,8 @@ class Tasks(models.Model):
title = models.CharField(max_length=255, verbose_name=_("Title"))
description = models.TextField(verbose_name=_("Description"), null=True, blank=True)
due_date = models.DateField(verbose_name=_("Due Date"))
start_time = models.TimeField(verbose_name=_("Start Time"), null=True, blank=True)
end_time = models.TimeField(verbose_name=_("End Time"), null=True, blank=True)
completed = models.BooleanField(default=False, verbose_name=_("Completed"))
assigned_to = models.ForeignKey(
User,

View File

@ -298,79 +298,79 @@ def update_item_model_cost(sender, instance, created, **kwargs):
:return: None
"""
# if created and not instance.is_sold:
if created:
entity = instance.car.dealer.entity
coa = entity.get_default_coa()
inventory_account = (
entity.get_all_accounts()
.filter(name=f"Inventory:{instance.car.id_car_make.name}")
.first()
)
if not inventory_account:
inventory_account = create_make_accounts(
entity,
coa,
[instance.car.id_car_make],
"Inventory",
roles.ASSET_CA_INVENTORY,
"debit",
)
# if created:
# entity = instance.car.dealer.entity
# coa = entity.get_default_coa()
# inventory_account = (
# entity.get_all_accounts()
# .filter(name=f"Inventory:{instance.car.id_car_make.name}")
# .first()
# )
# if not inventory_account:
# inventory_account = create_make_accounts(
# entity,
# coa,
# [instance.car.id_car_make],
# "Inventory",
# roles.ASSET_CA_INVENTORY,
# "debit",
# )
cogs = (
entity.get_all_accounts()
.filter(name=f"Cogs:{instance.car.id_car_make.name}")
.first()
)
if not cogs:
cogs = create_make_accounts(
entity, coa, [instance.car.id_car_make], "Cogs", roles.COGS, "debit"
)
revenue = (
entity.get_all_accounts()
.filter(name=f"Revenue:{instance.car.id_car_make.name}")
.first()
)
if not revenue:
revenue = create_make_accounts(
entity,
coa,
[instance.car.id_car_make],
"Revenue",
roles.ASSET_CA_RECEIVABLES,
"credit",
)
# cogs = (
# entity.get_all_accounts()
# .filter(name=f"Cogs:{instance.car.id_car_make.name}")
# .first()
# )
# if not cogs:
# cogs = create_make_accounts(
# entity, coa, [instance.car.id_car_make], "Cogs", roles.COGS, "debit"
# )
# revenue = (
# entity.get_all_accounts()
# .filter(name=f"Revenue:{instance.car.id_car_make.name}")
# .first()
# )
# if not revenue:
# revenue = create_make_accounts(
# entity,
# coa,
# [instance.car.id_car_make],
# "Revenue",
# roles.ASSET_CA_RECEIVABLES,
# "credit",
# )
cash_account = (
# entity.get_all_accounts()
# .filter(name="Cash", role=roles.ASSET_CA_CASH)
# .first()
entity.get_all_accounts()
.filter(role=roles.ASSET_CA_CASH, role_default=True)
.first()
)
# cash_account = (
# # entity.get_all_accounts()
# # .filter(name="Cash", role=roles.ASSET_CA_CASH)
# # .first()
# entity.get_all_accounts()
# .filter(role=roles.ASSET_CA_CASH, role_default=True)
# .first()
# )
ledger = LedgerModel.objects.create(
entity=entity, name=f"Inventory Purchase - {instance.car}"
)
je = JournalEntryModel.objects.create(
ledger=ledger,
description=f"Acquired {instance.car} for inventory",
)
TransactionModel.objects.create(
journal_entry=je,
account=inventory_account,
amount=Decimal(instance.cost_price),
tx_type="debit",
description="",
)
# ledger = LedgerModel.objects.create(
# entity=entity, name=f"Inventory Purchase - {instance.car}"
# )
# je = JournalEntryModel.objects.create(
# ledger=ledger,
# description=f"Acquired {instance.car} for inventory",
# )
# TransactionModel.objects.create(
# journal_entry=je,
# account=inventory_account,
# amount=Decimal(instance.cost_price),
# tx_type="debit",
# description="",
# )
TransactionModel.objects.create(
journal_entry=je,
account=cash_account,
amount=Decimal(instance.cost_price),
tx_type="credit",
description="",
)
# TransactionModel.objects.create(
# journal_entry=je,
# account=cash_account,
# amount=Decimal(instance.cost_price),
# tx_type="credit",
# description="",
# )
instance.car.item_model.default_amount = instance.marked_price
# if not isinstance(instance.car.item_model.additional_info, dict):
@ -952,11 +952,11 @@ def create_po_item_upload(sender, instance, created, **kwargs):
)
@receiver(post_save, sender=models.Staff)
def add_service_to_staff(sender, instance, created, **kwargs):
if created:
for service in Service.objects.all():
instance.staff_member.services_offered.add(service)
# @receiver(post_save, sender=models.Staff)
# def add_service_to_staff(sender, instance, created, **kwargs):
# if created:
# for service in Service.objects.all():
# instance.services_offered.add(service)
##########################################################
@ -1026,10 +1026,11 @@ def po_fullfilled_notification(sender, instance, created, **kwargs):
user=recipient,
message=_(
"""
New Purchase Order has been added.
PO {po_number} has been fulfilled.
<a href="{url}" target="_blank">View</a>
"""
).format(
po_number=instance.po_number,
url=reverse(
"purchase_order_detail",
kwargs={"dealer_slug": dealer.slug,"entity_slug":instance.entity.slug, "pk": instance.pk},
@ -1187,31 +1188,31 @@ def bill_model_in_approve_notification(sender, instance, created, **kwargs):
)
@receiver(post_save, sender=BillModel)
def bill_model_after_approve_notification(sender, instance, created, **kwargs):
if instance.is_approved():
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)
.distinct()
)
# @receiver(post_save, sender=BillModel)
# def bill_model_after_approve_notification(sender, instance, created, **kwargs):
# if instance.is_approved():
# 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)
# .distinct()
# )
for recipient in recipients:
models.Notification.objects.create(
user=recipient,
message=_(
"""
Bill {bill_number} has been approved.
<a href="{url}" target="_blank">View</a>.
please complete the bill payment.
"""
).format(
bill_number=instance.bill_number,
url=reverse(
"bill-detail",
kwargs={"dealer_slug": dealer.slug, "entity_slug": dealer.entity.slug, "bill_pk": instance.pk},
),
),
)
# for recipient in recipients:
# models.Notification.objects.create(
# user=recipient,
# message=_(
# """
# Bill {bill_number} has been approved.
# <a href="{url}" target="_blank">View</a>.
# please complete the bill payment.
# """
# ).format(
# bill_number=instance.bill_number,
# url=reverse(
# "bill-detail",
# kwargs={"dealer_slug": dealer.slug, "entity_slug": dealer.entity.slug, "bill_pk": instance.pk},
# ),
# ),
# )

View File

@ -1,7 +1,9 @@
import base64
import logging
from plans.models import Plan
from django.urls import reverse
from django.conf import settings
from django.utils import timezone
from django.db import transaction
from django_ledger.io import roles
from django_q.tasks import async_task
@ -11,11 +13,11 @@ from django.utils.translation import activate
from django.core.files.base import ContentFile
from django.contrib.auth import get_user_model
from allauth.account.models import EmailAddress
from inventory.models import DealerSettings, Dealer
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User, Group, Permission
from inventory.models import DealerSettings, Dealer,Schedule,Notification
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
@ -1166,7 +1168,7 @@ def create_user_dealer(email, password, name, arabic_name, phone, crn, vrn, addr
):
group.permissions.add(perm)
StaffMember.objects.create(user=user)
# StaffMember.objects.create(user=user)
dealer = Dealer.objects.create(
user=user,
name=name,
@ -1256,3 +1258,65 @@ def handle_email_result(task):
logger.info(f"Email task succeeded: {task.result}")
else:
logger.error(f"Email task failed: {task.result}")
def send_schedule_reminder_email(schedule_id):
"""
Sends an email reminder for a specific schedule.
This function is designed to be called by django-q.
"""
try:
schedule = Schedule.objects.get(pk=schedule_id)
# Ensure the user has an email and the schedule is not completed/canceled
if not schedule.scheduled_by.email or schedule.status in ["completed", "canceled"]:
logger.error(f"Skipping email for Schedule ID {schedule_id}: No email or schedule status is {schedule.status}.")
return
user_email = schedule.scheduled_by.email
Notification.objects.create(
user=schedule.scheduled_by,
message=_(
"""
Reminder: You have an appointment scheduled for {scheduled_type} After 15 minutes <a href="{url}" target="_blank">View</a>.
"""
).format(scheduled_type=schedule.scheduled_type, url=reverse("schedule_calendar", kwargs={"dealer_slug": schedule.dealer.slug})),)
# Prepare context for email templates
context = {
'schedule_purpose': schedule.purpose,
'scheduled_at': schedule.scheduled_at.astimezone(timezone.get_current_timezone()).strftime('%Y-%m-%d %H:%M %Z'), # Format with timezone
'schedule_type': schedule.scheduled_type,
'customer_name': schedule.customer.customer_name if schedule.customer else 'N/A',
'notes': schedule.notes,
'user_name': schedule.scheduled_by.get_full_name() or schedule.scheduled_by.email,
}
# Render email content from templates
html_message = render_to_string('emails/schedule_reminder.html', context)
plain_message = render_to_string('emails/schedule_reminder.txt', context)
send_mail(
f'Reminder: Your Upcoming Schedule - {schedule.purpose}',
plain_message,
settings.DEFAULT_FROM_EMAIL,
[user_email],
html_message=html_message,
)
logger.info(f"Successfully sent reminder email for Schedule ID: {schedule_id} to {user_email}")
except Schedule.DoesNotExist:
logger.info(f"Schedule with ID {schedule_id} does not exist. Cannot send reminder.")
except Exception as e:
logger.info(f"Error sending reminder email for Schedule ID {schedule_id}: {e}")
# Optional: A hook function to log the status of the email task (add to your_app/tasks.py)
def log_email_status(task):
"""
This function will be called by django-q after the send_schedule_reminder_email task completes.
It logs whether the task was successful or not.
"""
if task.success:
logger.info(f"Email task for Schedule ID {task.args[0]} completed successfully. Result: {task.result}")
else:
logger.error(f"Email task for Schedule ID {task.args[0]} failed. Error: {task.result}")

View File

@ -1281,6 +1281,9 @@ urlpatterns = [
path('feature/recall/<int:pk>/view/', views.RecallDetailView.as_view(), name='recall_detail'),
path('feature/recall/create/', views.RecallCreateView.as_view(), name='recall_create'),
path('feature/recall/success/', views.RecallSuccessView.as_view(), name='recall_success'),
path('<slug:dealer_slug>/schedules/calendar/', views.schedule_calendar, name='schedule_calendar'),
]
handler404 = "inventory.views.custom_page_not_found_view"

View File

@ -186,7 +186,7 @@ def get_user_type(request):
if request.is_dealer:
return request.user.dealer
elif request.is_staff:
return request.user.staffmember.staff.dealer
return request.user.staff.dealer
return None
@ -1110,16 +1110,16 @@ class CarFinanceCalculator:
"quantity": sum(
self._get_quantity(item) for item in self.item_transactions
),
"total_price": totals["total_price"],
"total_price_discounted": totals["total_price_discounted"],
"total_price_before_discount": totals["total_price_before_discount"],
"total_vat": totals["total_vat_amount"] + totals["total_price"],
"total_vat_amount": totals["total_vat_amount"],
"total_discount": totals["total_discount"],
"total_additionals": totals["total_additionals"],
"grand_total": totals["grand_total"],
"total_price": round(totals["total_price"], 2),
"total_price_discounted": round(totals["total_price_discounted"], 2),
"total_price_before_discount": round(totals["total_price_before_discount"], 2),
"total_vat": round(totals["total_vat_amount"] + totals["total_price"], 2),
"total_vat_amount": round(totals["total_vat_amount"], 2),
"total_discount": round(totals["total_discount"], 2),
"total_additionals": round(totals["total_additionals"], 2),
"grand_total": round(totals["grand_total"], 2),
"additionals": self._get_additional_services(),
"vat": self.vat_rate,
"vat": round(self.vat_rate, 2),
}
# class CarFinanceCalculator:
# """

View File

@ -511,8 +511,8 @@ class SalesDashboard(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
dealer = get_user_type(self.request)
staff = getattr(self.request.user, "staff", None)
dealer = self.request.dealer
staff = self.request.staff
total_cars = models.Car.objects.filter(dealer=dealer).count()
total_reservations = models.CarReservation.objects.filter(
reserved_by=self.request.user, reserved_until__gte=timezone.now()
@ -2478,7 +2478,7 @@ class CustomerCreateView(
customer = form.instance.create_customer_model()
form.instance.user = user
form.instance.customer_model = customer
# form.instance.customer_model = customer
return super().form_valid(form)
@ -3417,7 +3417,7 @@ class UserCreateView(
return self.form_invalid(form)
email = form.cleaned_data["email"]
if User.objects.filter(email=email).exists():
if models.Staff.objects.filter(dealer=dealer, user__email=email).exists():
messages.error(
self.request,
_(
@ -3425,16 +3425,25 @@ class UserCreateView(
),
)
return redirect("user_create", dealer_slug=dealer.slug)
password = "Tenhal@123"
user = User.objects.create_user(first_name=staff.first_name, last_name=staff.last_name, username=email, email=email, password=password)
user, created = User.objects.get_or_create(
email=email,
defaults={
"first_name": staff.first_name,
"last_name": staff.last_name,
"username": email,
"password": password,
},
)
user.is_staff = True
user.save()
staff_member = StaffMember.objects.create(user=user)
for service in form.cleaned_data["service_offered"]:
staff_member.services_offered.add(service)
staff.staff_member = staff_member
# staff_member, _ = StaffMember.objects.get_or_create(user=user)
# for service in form.cleaned_data["service_offered"]:
# staff_member.services_offered.add(service)
staff.user = user
staff.dealer = dealer
staff.save()
self.staff_pk = staff.pk
@ -3495,7 +3504,7 @@ class UserUpdateView(
def get_initial(self):
initial = super().get_initial()
initial["email"] = self.object.staff_member.user.email
initial["email"] = self.object.user.email
initial["group"] = self.object.groups
return initial
@ -4350,7 +4359,7 @@ def sales_list_view(request, dealer_slug):
"""
dealer = get_object_or_404(models.Dealer, slug=dealer_slug)
staff = getattr(request.user.staffmember, "staff", None)
staff = getattr(request.user, "staff", None)
qs = []
try:
if any([request.is_dealer, request.is_manager, request.is_accountant]):
@ -4525,7 +4534,7 @@ def create_estimate(request, dealer_slug, slug=None):
data = json.loads(request.body)
title = data.get("title")
customer_id = data.get("customer")
customer = models.Customer.objects.filter(pk=int(customer_id)).first()
customer = models.Customer.objects.filter(pk=int(customer_id),dealer=dealer).first()
items = data.get("item", [])
quantities = data.get("quantity", [])
@ -5173,7 +5182,7 @@ class InvoiceListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
def get_queryset(self):
dealer = get_object_or_404(models.Dealer, slug=self.kwargs["dealer_slug"])
entity = dealer.entity
staff = getattr(self.request.user.staffmember, "staff", None)
staff = getattr(self.request.user, "staff", None)
qs = []
try:
if any(
@ -5933,12 +5942,12 @@ class LeadDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
)
context["transfer_form"] = forms.LeadTransferForm()
context["transfer_form"].fields["transfer_to"].queryset = (
models.Staff.objects.select_related("staff_member", "staff_member__user")
models.Staff.objects.select_related("user")
.filter(
dealer=dealer,
staff_member__user__groups__permissions__codename__contains="can_reassign_lead",
user__groups__permissions__codename__contains="can_reassign_lead",
)
.exclude(staff_member__user=self.request.user)
.exclude(user=self.request.user)
.distinct()
)
@ -6062,10 +6071,10 @@ def lead_create(request, dealer_slug):
)
form.fields["staff"].queryset = (
form.fields["staff"]
.queryset.select_related("staff_member", "staff_member__user")
.queryset.select_related("user")
.filter(
dealer=dealer,
staff_member__user__groups__permissions__codename__contains="add_lead",
user__groups__permissions__codename__contains="add_lead",
)
.distinct()
)
@ -6093,8 +6102,8 @@ def lead_create(request, dealer_slug):
def lead_tracking(request, dealer_slug):
dealer = get_object_or_404(models.Dealer, slug=dealer_slug)
staff = (
models.Staff.objects.select_related("staff_member", "staff_member__user")
.filter(dealer=dealer, staff_member__user=request.user)
models.Staff.objects.select_related("user")
.filter(dealer=dealer, user=request.user)
.first()
)
@ -6275,10 +6284,10 @@ class LeadUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
].queryset = form.instance.id_car_make.carmodel_set.all()
form.fields["staff"].queryset = (
form.fields["staff"]
.queryset.select_related("staff_member", "staff_member__user")
.queryset.select_related("user")
.filter(
dealer=dealer,
staff_member__user__groups__permissions__codename__contains="add_lead",
user__groups__permissions__codename__contains="add_lead",
)
.distinct()
)
@ -6522,6 +6531,8 @@ def schedule_event(request, dealer_slug, content_type, slug):
return redirect(f"{content_type}_detail", dealer_slug=dealer_slug, slug=slug)
if request.method == "POST":
from django_q.models import Schedule as DjangoQSchedule
form = forms.ScheduleForm(request.POST)
if form.is_valid():
instance = form.save(commit=False)
@ -6534,34 +6545,33 @@ def schedule_event(request, dealer_slug, content_type, slug):
elif obj.organization:
instance.cutsomer = obj.organization.customer_model
service = Service.objects.get(name=instance.scheduled_type)
# Log attempt to create AppointmentRequest
logger.debug(
f"User {user_username} attempting to create AppointmentRequest "
f"for {content_type} ID: {obj.pk} ('{obj.slug}'). Service: '{service.name}', "
f"Scheduled At: '{instance.scheduled_at}', Duration: '{instance.duration}'."
)
# service = Service.objects.get(name=instance.scheduled_type)
# # Log attempt to create AppointmentRequest
# logger.debug(
# f"User {user_username} attempting to create AppointmentRequest "
# f"for {content_type} ID: {obj.pk} ('{obj.slug}'). Service: '{service.name}', "
# f"Scheduled At: '{instance.scheduled_at}', Duration: '{instance.duration}'."
# )
try:
appointment_request = AppointmentRequest.objects.create(
date=instance.scheduled_at.date(),
start_time=instance.scheduled_at.time(),
end_time=(instance.scheduled_at + instance.duration).time(),
service=service,
staff_member=request.user.staffmember,
)
except ValidationError as e:
messages.error(request, str(e))
return redirect(request.META.get("HTTP_REFERER"))
# try:
# appointment_request = AppointmentRequest.objects.create(
# date=instance.scheduled_at.date(),
client = get_object_or_404(User, email=instance.customer.email)
# service=service,
# staff_member=request.user.staffmember,
# )
# except ValidationError as e:
# messages.error(request, str(e))
# return redirect(request.META.get("HTTP_REFERER"))
# client = get_object_or_404(User, email=instance.customer.email)
# Create Appointment
Appointment.objects.create(
client=client,
appointment_request=appointment_request,
phone=instance.customer.phone,
address=instance.customer.address_1,
)
# Appointment.objects.create(
# client=client,
# appointment_request=appointment_request,
# phone=instance.customer.phone,
# address=instance.customer.address_1,
# )
instance.save()
models.Activity.objects.create(
@ -6571,12 +6581,20 @@ def schedule_event(request, dealer_slug, content_type, slug):
created_by=request.user,
activity_type=instance.scheduled_type,
)
scheduled_at_aware = timezone.make_aware(instance.scheduled_at, timezone.get_current_timezone()) if timezone.is_naive(instance.scheduled_at) else instance.scheduled_at
# --- Logging for successful AppointmentRequest and Appointment creation ---
logger.info(
f"User {user_username} successfully scheduled {content_type} ID: {obj.pk} ('{obj.slug}'). "
f"AppointmentRequest ID: {appointment_request.pk}, Appointment ID: {appointment_request.appointment.pk}."
)
reminder_time = scheduled_at_aware - timezone.timedelta(minutes=15)
# Only schedule if the reminder time is in the future
# Reminder emails are scheduled to be sent 15 minutes before the scheduled time
if reminder_time > timezone.now():
DjangoQSchedule.objects.create(
name=f"send_schedule_reminder_email_to_{instance.scheduled_by.email}_for_{content_type}_with_PK_{instance.pk}",
func='inventory.tasks.send_schedule_reminder_email',
args=f'"{instance.pk}"',
schedule_type=DjangoQSchedule.ONCE,
next_run=reminder_time,
hook='inventory.tasks.log_email_status',
)
messages.success(request, _("Appointment Created Successfully"))
return redirect(f'{content_type}_detail',dealer_slug=dealer_slug, slug=slug)
@ -6902,7 +6920,7 @@ class OpportunityUpdateView(
def get_form(self, form_class=None):
form = super().get_form(form_class)
dealer = get_object_or_404(models.Dealer, slug=self.kwargs.get("dealer_slug"))
staff = getattr(self.request.user.staffmember, "staff", None)
staff = getattr(self.request.user, "staff", None)
form.fields["car"].queryset = models.Car.objects.filter(
dealer=dealer, status="available", finances__marked_price__gt=0
)
@ -9764,12 +9782,12 @@ def management_view(request, dealer_slug):
@login_required
@permission_required("inventory.change_dealer", raise_exception=True)
def user_management(request, dealer_slug):
get_object_or_404(models.Dealer, slug=dealer_slug)
dealer = get_object_or_404(models.Dealer, slug=dealer_slug)
context = {
"customers": models.Customer.objects.filter(active=False),
"organizations": models.Organization.objects.filter(active=False),
"vendors": models.Vendor.objects.filter(active=False),
"staff": models.Staff.objects.filter(active=False),
"customers": models.Customer.objects.filter(active=False,dealer=dealer),
"organizations": models.Organization.objects.filter(active=False,dealer=dealer),
"vendors": models.Vendor.objects.filter(active=False,dealer=dealer),
"staff": models.Staff.objects.filter(active=False,dealer=dealer),
}
return render(request, "admin_management/user_management.html", context)
@ -10910,3 +10928,15 @@ class RecallCreateView(FormView):
class RecallSuccessView(TemplateView):
template_name = 'recalls/recall_success.html'
@login_required
def schedule_calendar(request,dealer_slug):
dealer = get_object_or_404(models.Dealer, slug=dealer_slug)
user_schedules = models.Schedule.objects.filter(dealer=dealer,scheduled_by=request.user).order_by('scheduled_at')
upcoming_schedules = user_schedules.filter(scheduled_at__gte=timezone.now()).order_by('scheduled_at')
context = {
'schedules': user_schedules,
'upcoming_schedules':upcoming_schedules
}
return render(request, 'schedule_calendar.html', context)

18
pg-compose.yml Normal file
View File

@ -0,0 +1,18 @@
version: '3.8'
services:
db:
image: postgres:16
container_name: dev_db
restart: always
environment:
POSTGRES_DB: dev_database
POSTGRES_USER: dev_user
POSTGRES_PASSWORD: dev_password
ports:
- "5432:5432"
volumes:
- dev_postgres_data:/var/lib/postgresql/data
volumes:
dev_postgres_data:

View File

@ -0,0 +1,105 @@
/* Card and container styling */
.card {
background-color: #2d3748;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
border: 1px solid #4a5568;
}
/* FullCalendar header */
.fc .fc-toolbar.fc-header-toolbar {
margin-bottom: 1.5em;
}
.fc .fc-toolbar-title {
font-size: 1.5rem;
font-weight: 600;
color: #edf2f7;
}
/* Calendar buttons */
.fc .fc-button-group > .fc-button {
background-color: #4a5568;
border-color: #4a5568;
color: #e2e8f0;
border-radius: 4px;
transition: all 0.2s ease-in-out;
}
.fc .fc-button-group > .fc-button:hover {
background-color: #64748b;
}
.fc .fc-button-primary:not(:disabled).fc-button-active,
.fc .fc-button-primary:not(:disabled):active {
background-color: #4299e1;
border-color: #4299e1;
color: #fff;
box-shadow: none;
}
/* Day cells */
.fc-daygrid-day {
background-color: #2d3748;
border: 1px solid #4a5568;
border-radius: 4px;
}
.fc-day-other {
background-color: #202c3c !important;
color: #718096;
}
.fc-day-today {
background-color: #38a169 !important;
border-color: #38a169 !important;
}
.fc-daygrid-day-number {
font-weight: 500;
color: #e2e8f0;
}
/* Event styling */
.fc-event {
border-radius: 4px;
padding: 3px 6px;
font-size: 12px;
color: #ffffff !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
/* Event colors (you can adjust these in your Django template) */
/* .fc-event-completed { background-color: #38a169; border-color: #38a169; } */
/* .fc-event-canceled { background-color: #e53e3e; border-color: #e53e3e; } */
/* .fc-event-scheduled { background-color: #4299e1; border-color: #4299e1; } */
/* List group styling */
.list-group-item {
border-color: #4a5568;
background-color: #2d3748;
color: #e2e8f0;
transition: background-color 0.2s ease-in-out;
}
.list-group-item:hover {
background-color: #4a5568;
}
.modal-content {
background-color: #2d3748;
color: #e2e8f0;
}
.modal-header .close {
color: #e2e8f0;
}
/* Responsive adjustments */
@media (max-width: 767.98px) {
.fc .fc-toolbar-title {
font-size: 1.25rem;
}
}

View File

@ -0,0 +1,99 @@
/* static/css/light_theme.css */
/* Card and container styling */
.card {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border: 1px solid #e0e6ed;
}
/* FullCalendar header */
.fc .fc-toolbar.fc-header-toolbar {
margin-bottom: 1.5em;
}
.fc .fc-toolbar-title {
font-size: 1.5rem;
font-weight: 600;
color: #2c3e50;
}
/* Calendar buttons */
.fc .fc-button-group > .fc-button {
background-color: #e9ecef;
border-color: #e9ecef;
color: #495057;
border-radius: 4px;
transition: all 0.2s ease-in-out;
}
.fc .fc-button-group > .fc-button:hover {
background-color: #e2e6ea;
}
.fc .fc-button-primary:not(:disabled).fc-button-active,
.fc .fc-button-primary:not(:disabled):active {
background-color: #007bff;
border-color: #007bff;
color: #fff;
box-shadow: none;
}
/* Day cells */
.fc-daygrid-day {
background-color: #ffffff;
border: 1px solid #e0e6ed;
border-radius: 4px;
}
.fc-day-other {
background-color: #f8f9fa !important;
color: #ced4da;
}
.fc-day-today {
background-color: #fff3cd !important;
border-color: #ffeeba !important;
}
.fc-daygrid-day-number {
font-weight: 500;
}
/* Event styling */
.fc-event {
border-radius: 4px;
padding: 3px 6px;
font-size: 12px;
color: #ffffff !important;
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* List group styling */
.list-group-item {
border-color: #e0e6ed;
background-color: #ffffff;
transition: background-color 0.2s ease-in-out;
}
.list-group-item:hover {
background-color: #f8f9fa;
}
.modal-content {
background-color: #ffffff;
color: #34495e;
}
.modal-header .close {
color: #adb5bd;
}
/* Responsive adjustments */
@media (max-width: 767.98px) {
.fc .fc-toolbar-title {
font-size: 1.25rem;
}
}

BIN
static/images/logos/vendors/logo1.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -132,3 +132,45 @@ html[dir="rtl"] .form-icon-container .form-control {
to { transform: rotate(360deg); }
}
#spinner {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
}
#spinner-bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.7);
opacity: 0;
transition: opacity 500ms ease-in;
z-index: 5;
}
#spinner-bg.htmx-request {
opacity: .8;
}
/* .fade-me-in.htmx-added {
opacity: 0;
}
.fade-me-in {
opacity: .9;
transition: opacity 300ms ease-out;
} */
#main_content.fade-me-in:not(.modal):not(.modal *) {
opacity: 1;
transition: opacity 300ms ease-out;
}
#main_content.fade-me-in.htmx-added:not(.modal):not(.modal *) {
opacity: 0;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
staticfiles/images/logos/vendors/vnd.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -249,3 +249,46 @@ const getDataTableInit = () => {
};
/*
// Register delete modal initializer
htmxInitializer.register(function initDeleteModals() {
const deleteModal = document.getElementById("deleteModal");
const confirmDeleteBtn = document.getElementById("deleteModalConfirm");
const deleteModalMessage = document.getElementById("deleteModalText");
// Clean up old listeners
document.querySelectorAll(".delete-btn").forEach(btn => {
btn.removeEventListener("click", handleDeleteClick);
});
// Add new listeners
document.querySelectorAll(".delete-btn").forEach(button => {
button.addEventListener("click", handleDeleteClick);
});
function handleDeleteClick() {
if (!deleteModal || !confirmDeleteBtn || !deleteModalMessage) return;
const deleteUrl = this.getAttribute("data-url");
const deleteMessage = this.getAttribute("data-message") || "Are you sure?";
confirmDeleteBtn.setAttribute("href", deleteUrl);
deleteModalMessage.textContent = deleteMessage;
if (typeof htmx !== 'undefined') htmx.process(confirmDeleteBtn);
if (typeof bootstrap !== 'undefined') new bootstrap.Modal(deleteModal).show();
}
}, "delete_modals");
// Register custom selects initializer
htmxInitializer.register(function initCustomSelects() {
// Your custom select initialization code
}, "custom_selects");
// Register form submission initializer
htmxInitializer.register(function initForms() {
// Your form handling code
}, "forms");
*/

1
staticfiles/spinner.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'><rect fill='#09577F' stroke='#09577F' stroke-width='15' width='30' height='30' x='25' y='85'><animate attributeName='opacity' calcMode='spline' dur='2' values='1;0;1;' keySplines='.5 0 .5 1;.5 0 .5 1' repeatCount='indefinite' begin='-.4'></animate></rect><rect fill='#09577F' stroke='#09577F' stroke-width='15' width='30' height='30' x='85' y='85'><animate attributeName='opacity' calcMode='spline' dur='2' values='1;0;1;' keySplines='.5 0 .5 1;.5 0 .5 1' repeatCount='indefinite' begin='-.2'></animate></rect><rect fill='#09577F' stroke='#09577F' stroke-width='15' width='30' height='30' x='145' y='85'><animate attributeName='opacity' calcMode='spline' dur='2' values='1;0;1;' keySplines='.5 0 .5 1;.5 0 .5 1' repeatCount='indefinite' begin='0'></animate></rect></svg>

BIN
staticfiles/user-logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
staticfiles/user-logo1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 B

View File

@ -84,7 +84,7 @@
{% include "plans/expiration_messages.html" %}
{% block period_navigation %}
{% endblock period_navigation %}
<div id="main_content" class="fade-me-in" hx-boost="true" hx-target="#main_content" hx-select="#main_content" hx-swap="outerHTML transition:true" hx-select-oob="#toast-container" hx-history-elt>
<div id="main_content" class="fade-me-in" hx-boost="false" hx-target="#main_content" hx-select="#main_content" hx-swap="outerHTML transition:true" hx-select-oob="#toast-container" hx-history-elt>
<div id="spinner" class="htmx-indicator spinner-bg">
<img src="{% static 'spinner.svg' %}" width="100" height="100" alt="">
</div>

View File

@ -201,25 +201,25 @@
<div class="d-flex flex-wrap gap-2 mt-2">
<!-- Update Button -->
{% if perms.django_ledger.change_billmodel %}
<a hx-boost="true" href="{% url 'bill-update' dealer_slug=dealer_slug entity_slug=entity_slug bill_pk=bill.uuid %}">
<button class="btn btn-phoenix-primary"
{% if not request.is_accountant %}disabled{% endif %}>
{% if "update" not in request.path %}
<a hx-boost="true" href="{% url 'bill-update' dealer_slug=dealer_slug entity_slug=entity_slug bill_pk=bill.uuid %}">
<button class="btn btn-phoenix-primary"
<i class="fas fa-edit me-2"></i>{% trans 'Update' %}
</button>
</a>
</button>
</a>
{% endif %}
{% if "detail" not in request.path %}
<!-- Mark as Draft -->
{% if bill.can_draft %}
<button class="btn btn-phoenix-success"
{% if not request.is_accountant %}disabled{% endif %}
onclick="showPOModal('Mark as Draft', '{% url 'bill-action-mark-as-draft' dealer_slug=request.dealer.slug entity_slug=entity_slug bill_pk=bill.pk %}', 'Mark as Draft')">
<i class="fas fa-check-circle me-2"></i>{% trans 'Mark as Draft' %}
</button>
{% endif %}
<!-- Mark as Review -->
{% if bill.can_review %}
{{request.is_accountant}}
<button class="btn btn-phoenix-warning"
{% if not request.is_accountant %}disabled{% endif %}
onclick="showPOModal('Mark as Review', '{% url 'bill-action-mark-as-review' dealer_slug=request.dealer.slug entity_slug=entity_slug bill_pk=bill.pk %}', 'Mark as Review')">
<i class="fas fa-check-circle me-2"></i>{% trans 'Mark as Review' %}
</button>
@ -253,7 +253,6 @@
<!-- Cancel Button -->
{% if bill.can_cancel %}
<button class="btn btn-phoenix-danger"
{% if not request.is_accountant %}disabled{% endif %}
onclick="showPOModal('Mark as Canceled', '{% url 'bill-action-mark-as-canceled' dealer_slug=request.dealer.slug entity_slug=entity_slug bill_pk=bill.pk %}', 'Mark as Canceled')">
<i class="fas fa-check-circle me-2"></i>{% trans 'Mark as Canceled' %}
</button>

View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; background-color: #f4f4f4; margin: 0; padding: 0; }
.container { max-width: 600px; margin: 20px auto; background-color: #ffffff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); }
h2 { color: #333333; }
p { color: #555555; line-height: 1.6; }
.footer { text-align: center; margin-top: 20px; font-size: 0.8em; color: #888888; }
.highlight { font-weight: bold; color: #007bff; }
</style>
</head>
<body>
<div class="container">
<h2>Hello {{ user_name }},</h2>
<p>This is a friendly reminder for your upcoming schedule:</p>
<p>
<span class="highlight">Purpose:</span> {{ schedule_purpose }}<br>
<span class="highlight">Scheduled At:</span> {{ scheduled_at }}<br>
<span class="highlight">Type:</span> {{ schedule_type }}<br>
{% if customer_name != 'N/A' %}<span class="highlight">Customer:</span> {{ customer_name }}<br>{% endif %}
{% if notes %}<span class="highlight">Notes:</span> {{ notes }}<br>{% endif %}
</p>
<p>Please be prepared for your schedule.</p>
<p>Thank you!</p>
<div class="footer">
<p>This is an automated reminder. Please do not reply to this email.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,16 @@
Hello {{ user_name }},
This is a friendly reminder for your upcoming schedule:
Purpose: {{ schedule_purpose }}
Scheduled At: {{ scheduled_at }}
Type: {{ schedule_type }}
{% if customer_name != 'N/A' %}Customer: {{ customer_name }}{% endif %}
{% if notes %}Notes: {{ notes }}{% endif %}
Please be prepared for your schedule.
Thank you!
---
This is an automated reminder. Please do not reply to this email.

View File

@ -59,7 +59,7 @@
<tr>
<td>
<p>
<strong>{{ _("Name") }}:</strong> {{ user.staffmember.staff }}
<strong>{{ _("Name") }}:</strong> {{ user.staff }}
</p>
</td>
<td>

View File

@ -445,8 +445,8 @@
<li class="nav-item">
<div class="theme-control-toggle fa-icon-wait">
<input class="form-check-input ms-0 theme-control-toggle-input" type="checkbox" data-theme-control="phoenixTheme" value="dark" id="themeControlToggleSm" />
<label class="mb-0 theme-control-toggle-label theme-control-toggle-light" for="themeControlToggleSm" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="{{ _("Switch theme")}}" style="height:32px;width:32px;"><span class="icon" data-feather="moon"></span></label>
<label class="mb-0 theme-control-toggle-label theme-control-toggle-dark" for="themeControlToggleSm" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="{{ _("Switch theme")}}" style="height:32px;width:32px;"><span class="icon" data-feather="sun"></span></label>
<label class="mb-0 theme-control-toggle-label theme-control-toggle-light" for="themeControlToggleSm" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title='{{ _("Switch theme")}}' style="height:32px;width:32px;"><span class="icon" data-feather="moon"></span></label>
<label class="mb-0 theme-control-toggle-label theme-control-toggle-dark" for="themeControlToggleSm" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title='{{ _("Switch theme")}}' style="height:32px;width:32px;"><span class="icon" data-feather="sun"></span></label>
</div>
</li>
<li class="nav-item dropdown">
@ -462,7 +462,7 @@
{% include "notifications.html" %}
{% endif %}
{% if user.is_authenticated and request.is_dealer or request.is_staff %}
{% if user.is_authenticated%}
<li class="nav-item dropdown">
<a hx-boost="false" class="nav-link lh-1 pe-0" id="navbarDropdownUser" role="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-haspopup="true" aria-expanded="false">
<div class="avatar avatar-l text-center align-middle">
@ -492,7 +492,7 @@
{% if request.is_dealer %}
<h6 class="mt-2 text-body-emphasis">{{ user.dealer.get_local_name }}</h6>
{% else %}
<h6 class="mt-2 text-body-emphasis">{{ user.staffmember.staff.get_local_name }}</h6>
<h6 class="mt-2 text-body-emphasis">{{ user.staff.get_local_name }}</h6>
{% endif %}
</div>
</div>
@ -530,7 +530,7 @@
</li>
{% if request.is_staff %}
<li class="nav-item">
<a hx-boost="false" 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>{{ _("My Calendar") }}</a>
<a hx-boost="false" class="nav-link px-3 d-block" href="{% url 'schedule_calendar' request.dealer.slug %}"> <span class="me-2 text-body align-bottom" data-feather="calendar"></span>{{ _("My Calendar") }}</a>
</li>
{% endif %}
<!--<li class="nav-item"><a class="nav-link px-3 d-block" href=""> Language</a></li>-->

View File

@ -164,7 +164,7 @@
<th>{% trans "Custom Card" %}</th>
<td>
{% if perms.inventory.add_customcard %}
<button type="button"
<button type="button"
class="btn btn-sm btn-phoenix-success"
data-bs-toggle="modal"
data-bs-target="#mainModal"

View File

@ -19,7 +19,7 @@
<script src="{% static 'vendors/zxing/index.min.js' %}"></script>
<script src="{% static 'vendors/tesseract/tesseract.min.js' %}"></script>
{% if not vendor_exists %}
{% url "user_create" request.dealer.slug as create_vendor_url %}
{% url "vendor_create" request.dealer.slug as create_vendor_url %}
{% include "empty-illustration-page.html" with value="Vendor" url=create_vendor_url %}
{% endif %}
<!---->

View File

@ -0,0 +1,233 @@
{% extends 'base.html' %}
{% load static %}
{% block customCSS %}
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.css" rel="stylesheet">
<link id="theme-link" rel="stylesheet" href="{% static 'css/calendar_dark.css' %}">
<style>
.schedule-list {
max-height: 80vh;
overflow-y: auto;
}
</style>
{% endblock customCSS %}
{% block content %}
<div class="container-fluid mt-4">
<div class="row">
<div class="col-md-8">
<div id="calendar"></div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h4>Upcoming Schedules</h4>
{% comment %} <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 New Schedule") }}
</button> {% endcomment %}
</div>
<div class="card-body schedule-list">
<ul class="list-group">
{% for schedule in upcoming_schedules %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-1">{{ schedule.get_purpose }}</h6>
<p class="mb-1 text-muted">{{ schedule.scheduled_at|date:"F d, Y P" }}</p>
</div>
<button class="btn btn-info btn-sm view-schedule-btn" data-toggle="modal" data-target="#viewScheduleModal"
data-purpose="{{ schedule.get_purpose }}"
data-scheduled-type="{{ schedule.scheduled_type }}"
data-scheduled-at="{{ schedule.scheduled_at }}"
data-start-time="{{ schedule.start_time }}"
data-end-time="{{ schedule.end_time }}"
data-completed="{{ schedule.completed }}"
data-status="{{ schedule.status }}"
data-notes="{{ schedule.notes }}"
data-customer="{% if schedule.customer %}{{ schedule.customer.customer_name }}{% else %}N/A{% endif %}"
>
View
</button>
</li>
{% empty %}
<li class="list-group-item text-center text-muted">No schedules found.</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="addScheduleModal" tabindex="-1" role="dialog" aria-labelledby="addScheduleModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addScheduleModalLabel">Add New Schedule</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<form action="{% url 'schedule_calendar' request.dealer.slug %}" method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Save Schedule</button>
</form>
</div>
</div>
</div>
</div>
<div class="modal fade" id="viewScheduleModal" tabindex="-1" role="dialog" aria-labelledby="viewScheduleModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="viewScheduleTitle">Schedule Details</h5>
</div>
<div class="modal-body">
<p><strong>Purpose:</strong> <span id="modal-schedule-purpose"></span></p>
<p><strong>Scheduled Type:</strong> <span id="modal-schedule-type"></span></p>
<p><strong>Customer:</strong> <span id="modal-schedule-customer"></span></p>
<p><strong>Scheduled At:</strong> <span id="modal-schedule-at"></span></p>
<p><strong>Time:</strong> <span id="modal-schedule-time"></span></p>
<p><strong>Status:</strong> <span id="modal-schedule-status"></span></p>
<p><strong>Notes:</strong> <span id="modal-schedule-notes"></span></p>
<p><strong>Completed:</strong> <span id="modal-schedule-completed"></span></p>
</div>
</div>
</div>
</div>
{% endblock content %}
{% block customJS %}
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('themeControlToggleSm');
const themeLink = document.getElementById('theme-link');
const savedTheme = localStorage.getItem('theme') || 'light';
function setTheme(theme) {
if (theme === 'dark') {
themeLink.href = '{% static "css/dark_theme.css" %}';
themeToggle.checked = true;
} else {
themeLink.href = '{% static "css/light_theme.css" %}';
themeToggle.checked = false;
}
localStorage.setItem('theme', theme);
}
// Set the initial theme on page load
setTheme(savedTheme);
// Listen for changes on the toggle switch
themeToggle.addEventListener('change', function() {
if (this.checked) {
setTheme('dark');
} else {
setTheme('light');
}
});
var calendarEl = document.getElementById('calendar');
var calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
themeSystem: 'bootstrap',
locale: '{{ LANGUAGE_CODE }}',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
},
events: [
{% for schedule in schedules %}
{
title: '{{ schedule.get_purpose|escapejs }}',
start: '{{ schedule.scheduled_at|date:"Y-m-d" }}{% if schedule.start_time %}T{{ schedule.start_time|time:"H:i:s" }}{% endif %}',
end: '{{ schedule.scheduled_at|date:"Y-m-d" }}{% if schedule.end_time %}T{{ schedule.end_time|time:"H:i:s" }}{% endif %}',
backgroundColor: '{% if schedule.status == "completed" %}#1abc9c{% elif schedule.status == "canceled" %}#e74c3c{% else %}#3498db{% endif %}',
borderColor: '{% if schedule.status == "completed" %}#1abc9c{% elif schedule.status == "canceled" %}#e74c3c{% else %}#3498db{% endif %}',
extendedProps: {
scheduledType: '{{ schedule.scheduled_type|escapejs }}',
notes: '{{ schedule.notes|escapejs }}',
completed: '{{ schedule.completed }}',
status: '{{ schedule.status|escapejs }}',
customer: '{{ schedule.customer.customer_name|default:"N/A"|escapejs }}'
}
},
{% endfor %}
],
eventClick: function(info) {
// Populate the view modal with schedule details
$('#viewScheduleTitle').text(info.event.title);
$('#modal-schedule-purpose').text(info.event.title);
$('#modal-schedule-type').text(info.event.extendedProps.scheduledType);
$('#modal-schedule-customer').text(info.event.extendedProps.customer);
$('#modal-schedule-at').text(info.event.startStr.split('T')[0]);
let startTime = info.event.startStr.split('T')[1];
let endTime = info.event.endStr ? info.event.endStr.split('T')[1] : '';
let timeString = '';
if (startTime) {
timeString += startTime.substring(0, 5);
}
if (endTime) {
timeString += ' - ' + endTime.substring(0, 5);
}
$('#modal-schedule-time').text(timeString);
$('#modal-schedule-status').text(info.event.extendedProps.status);
$('#modal-schedule-notes').text(info.event.extendedProps.notes);
$('#modal-schedule-completed').text(info.event.extendedProps.completed === 'True' ? 'Yes' : 'No');
$('#viewScheduleModal').modal('show');
}
});
calendar.render();
});
// Handle view schedule buttons in the list
$('.view-schedule-btn').on('click', function() {
const purpose = $(this).data('purpose');
const scheduledType = $(this).data('scheduled-type');
const customer = $(this).data('customer');
const scheduledAt = $(this).data('scheduled-at');
const startTime = $(this).data('start-time');
const endTime = $(this).data('end-time');
const status = $(this).data('status');
const notes = $(this).data('notes');
const completed = $(this).data('completed');
$('#viewScheduleTitle').text(purpose);
$('#modal-schedule-purpose').text(purpose);
$('#modal-schedule-type').text(scheduledType);
$('#modal-schedule-customer').text(customer);
$('#modal-schedule-at').text(scheduledAt);
let timeString = '';
if (startTime) {
timeString += startTime.substring(0, 5);
}
if (endTime) {
if (timeString) {
timeString += ' - ';
}
timeString += endTime.substring(0, 5);
}
$('#modal-schedule-time').text(timeString);
$('#modal-schedule-status').text(status);
$('#modal-schedule-notes').text(notes);
$('#modal-schedule-completed').text(completed === true ? 'Yes' : 'No');
$('#viewScheduleModal').modal('show');
});
</script>
{% endblock customJS %}