diff --git a/db.sqlite b/db.sqlite index 0031e560..7f709095 100644 Binary files a/db.sqlite and b/db.sqlite differ diff --git a/inventory/__pycache__/admin.cpython-311.pyc b/inventory/__pycache__/admin.cpython-311.pyc index d6fb450c..77d36131 100644 Binary files a/inventory/__pycache__/admin.cpython-311.pyc and b/inventory/__pycache__/admin.cpython-311.pyc differ diff --git a/inventory/__pycache__/models.cpython-311.pyc b/inventory/__pycache__/models.cpython-311.pyc index b3f36000..dd8f553c 100644 Binary files a/inventory/__pycache__/models.cpython-311.pyc and b/inventory/__pycache__/models.cpython-311.pyc differ diff --git a/inventory/__pycache__/urls.cpython-311.pyc b/inventory/__pycache__/urls.cpython-311.pyc index 2b3c0aad..a85cbd34 100644 Binary files a/inventory/__pycache__/urls.cpython-311.pyc and b/inventory/__pycache__/urls.cpython-311.pyc differ diff --git a/inventory/__pycache__/views.cpython-311.pyc b/inventory/__pycache__/views.cpython-311.pyc index 947b528b..b68b7a79 100644 Binary files a/inventory/__pycache__/views.cpython-311.pyc and b/inventory/__pycache__/views.cpython-311.pyc differ diff --git a/inventory/admin.py b/inventory/admin.py index edf1a77c..f1115584 100644 --- a/inventory/admin.py +++ b/inventory/admin.py @@ -30,6 +30,7 @@ admin.site.register(models.VatRate) admin.site.register(models.Customer) admin.site.register(models.Opportunity) admin.site.register(models.Notification) +admin.site.register(models.OpportunityLog) @admin.register(models.CarMake) class CarMakeAdmin(admin.ModelAdmin): diff --git a/inventory/migrations/0021_log.py b/inventory/migrations/0021_opportunitylog.py similarity index 95% rename from inventory/migrations/0021_log.py rename to inventory/migrations/0021_opportunitylog.py index 35ceefcf..9a30de75 100644 --- a/inventory/migrations/0021_log.py +++ b/inventory/migrations/0021_opportunitylog.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.4 on 2024-12-31 15:48 +# Generated by Django 5.1.4 on 2024-12-31 16:13 import django.db.models.deletion from django.conf import settings @@ -14,7 +14,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Log', + name='OpportunityLog', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('action', models.CharField(choices=[('create', 'Create'), ('update', 'Update'), ('delete', 'Delete'), ('status_change', 'Status Change')], max_length=50, verbose_name='Action')), diff --git a/inventory/migrations/0022_alter_customer_is_lead.py b/inventory/migrations/0022_alter_customer_is_lead.py new file mode 100644 index 00000000..02c490b6 --- /dev/null +++ b/inventory/migrations/0022_alter_customer_is_lead.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.4 on 2024-12-31 21:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0021_opportunitylog'), + ] + + operations = [ + migrations.AlterField( + model_name='customer', + name='is_lead', + field=models.BooleanField(default=False, verbose_name='Is Lead'), + ), + ] diff --git a/inventory/migrations/0022_rename_log_oportunitylog.py b/inventory/migrations/0022_rename_log_oportunitylog.py deleted file mode 100644 index f7390cd9..00000000 --- a/inventory/migrations/0022_rename_log_oportunitylog.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.4 on 2024-12-31 15:49 - -from django.conf import settings -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('inventory', '0021_log'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RenameModel( - old_name='Log', - new_name='OportunityLog', - ), - ] diff --git a/inventory/migrations/0023_remove_opportunitylog_user_opportunitylog_staff.py b/inventory/migrations/0023_remove_opportunitylog_user_opportunitylog_staff.py new file mode 100644 index 00000000..2447e96a --- /dev/null +++ b/inventory/migrations/0023_remove_opportunitylog_user_opportunitylog_staff.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.4 on 2025-01-01 01:24 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0022_alter_customer_is_lead'), + ] + + operations = [ + migrations.RemoveField( + model_name='opportunitylog', + name='user', + ), + migrations.AddField( + model_name='opportunitylog', + name='staff', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.staff', verbose_name='Staff'), + ), + ] diff --git a/inventory/models.py b/inventory/models.py index 26b8c6d9..980795f8 100644 --- a/inventory/models.py +++ b/inventory/models.py @@ -260,6 +260,11 @@ class Car(models.Model): active_reservations = self.reservations.filter(reserved_until__gt=now()) return active_reservations.exists() + @property + def get_car_group(self): + return f"{self.id_car_make.get_local_name} {self.id_car_model.get_local_name}" + + # class CarData(models.Model): # vin = models.CharField(max_length=17, unique=True, verbose_name=_("VIN")) # make = models.CharField(max_length=255, verbose_name=_("Make")) @@ -623,7 +628,7 @@ class Staff(models.Model, LocalizedNameMixin): permissions = [] def __str__(self): - return f"{self.name} - {self.dealer}" + return f"{self.name} - {self.get_staff_type_display()}" # Vendor Model @@ -677,7 +682,7 @@ class Customer(models.Model): max_length=200, blank=True, null=True, verbose_name=_("Address") ) created = models.DateTimeField(auto_now_add=True, verbose_name=_("Created")) - is_lead = models.BooleanField(default=True, verbose_name=_("Is Lead")) + is_lead = models.BooleanField(default=False, verbose_name=_("Is Lead")) class Meta: verbose_name = _("Customer") @@ -753,10 +758,10 @@ class ActionChoices(models.TextChoices): STATUS_CHANGE = "status_change", _("Status Change") -class OportunityLog(models.Model): +class OpportunityLog(models.Model): opportunity = models.ForeignKey(Opportunity, on_delete=models.CASCADE, related_name="logs") action = models.CharField(max_length=50, choices=ActionChoices.choices, verbose_name=_("Action")) - user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name=_("User")) + staff = models.ForeignKey(Staff, on_delete=models.SET_NULL, null=True, verbose_name=_("Staff")) old_status = models.CharField(max_length=50, choices=DealStatus.choices, null=True, blank=True, verbose_name=_("Old Status")) new_status = models.CharField(max_length=50, choices=DealStatus.choices, null=True, blank=True, verbose_name=_("New Status")) details = models.TextField(blank=True, null=True, verbose_name=_("Details")) diff --git a/inventory/signals.py b/inventory/signals.py index 5fef2283..f008f0d5 100644 --- a/inventory/signals.py +++ b/inventory/signals.py @@ -5,6 +5,7 @@ from django.contrib.auth import get_user_model from django_ledger.io import roles from django_ledger.models import EntityModel,AccountModel,ItemModel,ItemModelAbstract,UnitOfMeasureModel, VendorModel from . import models +from .models import OpportunityLog User = get_user_model() @@ -342,3 +343,35 @@ def notify_staff_on_deal_status_change(sender, instance, **kwargs): message = f"Deal '{instance.deal_name}' status changed from {previous.deal_status} to {instance.deal_status}." models.Notification.objects.create(staff=instance.created_by, message=message) + +@receiver(post_save, sender=models.Opportunity) +def log_opportunity_creation(sender, instance, created, **kwargs): + if created: + models.OpportunityLog.objects.create( + opportunity=instance, + action='create', + user=instance.created_by, + details=f"Opportunity '{instance.deal_name}' was created." + ) + + +@receiver(pre_save, sender=models.Opportunity) +def log_opportunity_update(sender, instance, **kwargs): + if instance.pk: + previous = models.Opportunity.objects.get(pk=instance.pk) + if previous.deal_status != instance.deal_status: + models.OpportunityLog.objects.create( + opportunity=instance, + action='status_change', + user=instance.created_by, + old_status=previous.deal_status, + new_status=instance.deal_status, + details=f"Status changed from {previous.deal_status} to {instance.deal_status}." + ) + else: + models.OpportunityLog.objects.create( + opportunity=instance, + action='update', + user=instance.created_by, + details=f"Opportunity '{instance.deal_name}' was updated." + ) \ No newline at end of file diff --git a/inventory/urls.py b/inventory/urls.py index 63326b25..714cfbe4 100644 --- a/inventory/urls.py +++ b/inventory/urls.py @@ -27,7 +27,6 @@ urlpatterns = [ path('login/code/', allauth_views.RequestLoginCodeView.as_view(template_name='account/request_login_code.html')), #Dashboards path('dashboards/accounting/', views.AccountingDashboard.as_view(), name='accounting'), - path('dashboards/crm/', views.notifications_view, name='staff_dashboard'), # Dealer URLs path('dealers//', views.DealerDetailView.as_view(), name='dealer_detail'), @@ -48,7 +47,9 @@ urlpatterns = [ path('opportunities//edit/', views.OpportunityUpdateView.as_view(), name='update_opportunity'), path('opportunities/', views.OpportunityListView.as_view(), name='opportunity_list'), path('opportunities//delete/', views.delete_opportunity, name='delete_opportunity'), + path('opportunities//logs/', views.OpportunityLogsView.as_view(), name='opportunity_logs'), path('notifications/', views.NotificationListView.as_view(), name='notifications_history'), + path('fetch_notifications/', views.fetch_notifications, name='fetch_notifications'), path('notifications//mark_as_read/', views.mark_notification_as_read, name='mark_notification_as_read'), #Vendor URLs diff --git a/inventory/views.py b/inventory/views.py index 3add075b..0e8ad85e 100644 --- a/inventory/views.py +++ b/inventory/views.py @@ -674,8 +674,10 @@ class CustomerListView(LoginRequiredMixin, PermissionRequiredMixin, ListView): def get_queryset(self): query = self.request.GET.get("q") + if self.request.user.is_staff: + dealer = self.request.user.staff.dealer customers = models.Customer.objects.filter( - dealer=self.request.user.dealer + dealer=dealer, ) if query: @@ -2059,9 +2061,19 @@ def delete_opportunity(request, pk): return redirect("opportunity_list") -def notifications_view(request): - notifications = models.Notification.objects.filter(user=request.user, is_read=False).order_by('-created_at') - return render(request, 'notifications.html', {'notifications': notifications}) +class OpportunityLogsView(LoginRequiredMixin, ListView): + model = models.OpportunityLog + template_name = 'crm/opportunity_logs.html' + context_object_name = 'logs' + + def get_queryset(self): + opportunity_id = self.kwargs['pk'] + return models.OpportunityLog.objects.filter(opportunity_id=opportunity_id).order_by('-created_at') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['opportunity'] = models.Opportunity.objects.get(pk=self.kwargs['pk']) + return context class NotificationListView(LoginRequiredMixin, ListView): @@ -2074,10 +2086,25 @@ class NotificationListView(LoginRequiredMixin, ListView): return models.Notification.objects.filter(user=self.request.user).order_by('-created_at') +@login_required def mark_notification_as_read(request, pk): - notification = get_object_or_404(models.Notification, pk=pk) + notification = get_object_or_404(models.Notification, pk=pk, user=request.user) notification.is_read = True notification.save() + messages.success(request, _("Notification marked as read.")) return redirect('notifications_history') +@login_required +def fetch_notifications(request): + notifications = models.Notification.objects.filter(user=request.user, is_read=False).order_by('-created_at') + notifications_data = [ + { + 'id': notification.id, + 'message': notification.message, + 'created_at': notification.created_at.strftime('%Y-%m-%d %H:%M:%S'), + } + for notification in notifications + ] + return JsonResponse({'notifications': notifications_data}) + diff --git a/static/.DS_Store b/static/.DS_Store index 8f3b2fa5..66355cb4 100644 Binary files a/static/.DS_Store and b/static/.DS_Store differ diff --git a/static/images/.DS_Store b/static/images/.DS_Store index 881631b2..2097c64d 100644 Binary files a/static/images/.DS_Store and b/static/images/.DS_Store differ diff --git a/static/images/favicons/android-chrome-192x192.png b/static/images/favicons/android-chrome-192x192.png index 0527f276..46f85037 100644 Binary files a/static/images/favicons/android-chrome-192x192.png and b/static/images/favicons/android-chrome-192x192.png differ diff --git a/static/images/favicons/android-chrome-512x512.png b/static/images/favicons/android-chrome-512x512.png index 380719a8..b5aea1fd 100644 Binary files a/static/images/favicons/android-chrome-512x512.png and b/static/images/favicons/android-chrome-512x512.png differ diff --git a/static/images/favicons/apple-touch-icon.png b/static/images/favicons/apple-touch-icon.png index 22aed0cf..b08a22b9 100644 Binary files a/static/images/favicons/apple-touch-icon.png and b/static/images/favicons/apple-touch-icon.png differ diff --git a/static/images/favicons/favicon.ico b/static/images/favicons/favicon.ico index 78c7938e..cefd2e79 100644 Binary files a/static/images/favicons/favicon.ico and b/static/images/favicons/favicon.ico differ diff --git a/static/images/favicons/favicon2.ico b/static/images/favicons/favicon2.ico new file mode 100644 index 00000000..78c7938e Binary files /dev/null and b/static/images/favicons/favicon2.ico differ diff --git a/static/images/spot-illustrations/21.png b/static/images/spot-illustrations/21.png index 19ae05d9..939645ba 100644 Binary files a/static/images/spot-illustrations/21.png and b/static/images/spot-illustrations/21.png differ diff --git a/static/images/spot-illustrations/dark_21.png b/static/images/spot-illustrations/dark_21.png index f66c44a1..5c0f04a8 100644 Binary files a/static/images/spot-illustrations/dark_21.png and b/static/images/spot-illustrations/dark_21.png differ diff --git a/static/js/main.js b/static/js/main.js index 8324353b..45f4e06e 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -15,11 +15,209 @@ function getCookie(name) { } +const getDataTableInit = () => { + const togglePaginationButtonDisable = (button, disabled) => { + button.disabled = disabled; + button.classList[disabled ? 'add' : 'remove']('disabled'); + }; + // Selectors + const table = document.getElementById('opportunityTable'); + if (table) { + const options = { + page: 10, + pagination: { + item: "
  • " + }, + item: values => { + const { + orderId, + id, + customer, + date, + address, + deliveryType, + status, + badge, + amount + } = values; + return ` + + + + ${orderId} + + + + + ${customer} + + + + ${date} + + + ${address} + + +

    ${deliveryType}

    + + + + ${status} + + + + + ${amount} + + +
    + + +
    + + + `; + } + }; + const paginationButtonNext = table.querySelector( + '[data-list-pagination="next"]' + ); + const paginationButtonPrev = table.querySelector( + '[data-list-pagination="prev"]' + ); + const viewAll = table.querySelector('[data-list-view="*"]'); + const viewLess = table.querySelector('[data-list-view="less"]'); + const listInfo = table.querySelector('[data-list-info]'); + const listFilter = document.querySelector('[data-list-filter]'); - function notify(tag,msg){ - Toast.fire({ - icon: tag, - titleText: msg + const orderList = new window.List(table, options, orders); + + // Fallback + orderList.on('updated', item => { + const fallback = + table.querySelector('.fallback') || + document.getElementById(options.fallback); + + if (fallback) { + if (item.matchingItems.length === 0) { + fallback.classList.remove('d-none'); + } else { + fallback.classList.add('d-none'); + } + } + }); + + const totalItem = orderList.items.length; + const itemsPerPage = orderList.page; + const btnDropdownClose = + orderList.listContainer.querySelector('.btn-close'); + let pageQuantity = Math.ceil(totalItem / itemsPerPage); + let numberOfcurrentItems = orderList.visibleItems.length; + let pageCount = 1; + + btnDropdownClose && + btnDropdownClose.addEventListener('search.close', () => + orderList.fuzzySearch('') + ); + + const updateListControls = () => { + listInfo && + (listInfo.innerHTML = `${orderList.i} to ${numberOfcurrentItems} of ${totalItem}`); + paginationButtonPrev && + togglePaginationButtonDisable(paginationButtonPrev, pageCount === 1); + paginationButtonNext && + togglePaginationButtonDisable( + paginationButtonNext, + pageCount === pageQuantity + ); + + if (pageCount > 1 && pageCount < pageQuantity) { + togglePaginationButtonDisable(paginationButtonNext, false); + togglePaginationButtonDisable(paginationButtonPrev, false); + } + }; + updateListControls(); + + if (paginationButtonNext) { + paginationButtonNext.addEventListener('click', e => { + e.preventDefault(); + pageCount += 1; + + const nextInitialIndex = orderList.i + itemsPerPage; + nextInitialIndex <= orderList.size() && + orderList.show(nextInitialIndex, itemsPerPage); + numberOfcurrentItems += orderList.visibleItems.length; + updateListControls(); }); - } \ No newline at end of file + } + + if (paginationButtonPrev) { + paginationButtonPrev.addEventListener('click', e => { + e.preventDefault(); + pageCount -= 1; + + numberOfcurrentItems -= orderList.visibleItems.length; + const prevItem = orderList.i - itemsPerPage; + prevItem > 0 && orderList.show(prevItem, itemsPerPage); + updateListControls(); + }); + } + + const toggleViewBtn = () => { + viewLess.classList.toggle('d-none'); + viewAll.classList.toggle('d-none'); + }; + + if (viewAll) { + viewAll.addEventListener('click', () => { + orderList.show(1, totalItem); + pageQuantity = 1; + pageCount = 1; + numberOfcurrentItems = totalItem; + updateListControls(); + toggleViewBtn(); + }); + } + if (viewLess) { + viewLess.addEventListener('click', () => { + orderList.show(1, itemsPerPage); + pageQuantity = Math.ceil(totalItem / itemsPerPage); + pageCount = 1; + numberOfcurrentItems = orderList.visibleItems.length; + updateListControls(); + toggleViewBtn(); + }); + } + if (options.pagination) { + table.querySelector('.pagination').addEventListener('click', e => { + if (e.target.classList[0] === 'page') { + pageCount = Number(e.target.innerText); + updateListControls(); + } + }); + } + if (options.filter) { + const { key } = options.filter; + listFilter.addEventListener('change', e => { + orderList.filter(item => { + if (e.target.value === '') { + return true; + } + return item + .values() + [key].toLowerCase() + .includes(e.target.value.toLowerCase()); + }); + }); + } + } + }; diff --git a/templates/base.html b/templates/base.html index 8858f472..231aa852 100644 --- a/templates/base.html +++ b/templates/base.html @@ -42,7 +42,7 @@
    - {% include 'messages.html' %} + {% include 'header.html' %}
    @@ -77,6 +77,42 @@ + \ No newline at end of file diff --git a/templates/crm/opportunity_logs.html b/templates/crm/opportunity_logs.html new file mode 100644 index 00000000..7973f1d4 --- /dev/null +++ b/templates/crm/opportunity_logs.html @@ -0,0 +1,35 @@ +{% extends 'base.html' %} + +{% block content %} +

    Logs for {{ opportunity.deal_name }}

    +
    +
    + + + + + + + + + + + + + {% for log in logs %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    ActionUserOld StatusNew StatusDetailsDate
    {{ log.get_action_display }}{{ log.user }}{{ log.get_old_status_display }}{{ log.get_new_status_display }}{{ log.details }}{{ log.created_at }}
    No logs found.
    +{% endblock %} \ No newline at end of file diff --git a/templates/customers/customer_list.html b/templates/customers/customer_list.html index 89af364c..d7fd80fd 100644 --- a/templates/customers/customer_list.html +++ b/templates/customers/customer_list.html @@ -6,17 +6,13 @@ {% block content %}
    -
    -

    {{ _("Customers")|capfirst }}

    -
    @@ -24,12 +20,12 @@
    @@ -43,6 +39,7 @@ + - +
    {{ _("Name")|capfirst }}
    @@ -73,7 +70,7 @@ {% for customer in customers %} -
    + {% if customer.is_lead %} + + {{ _("Lead") }} + + + {% endif %} +
    - diff --git a/templates/customers/view_customer.html b/templates/customers/view_customer.html index 1a4df724..85c6ec04 100644 --- a/templates/customers/view_customer.html +++ b/templates/customers/view_customer.html @@ -52,11 +52,7 @@
    {{_("Update")}} - {% if not customer.is_lead %} - {{ _("Mark as Lead")}} - {% else %} - {{_("Opportunity")}} - {% endif %} +
    diff --git a/templates/footer.html b/templates/footer.html index a30ae866..ba03fb12 100644 --- a/templates/footer.html +++ b/templates/footer.html @@ -1,4 +1,5 @@ {% load i18n %} +