Merge branch 'main' of http://10.10.1.136:3000/ismail/haikal into frontend
This commit is contained in:
commit
08687340a3
@ -1,4 +1,4 @@
|
|||||||
from datetime import timezone
|
from django.utils import timezone
|
||||||
import logging
|
import logging
|
||||||
from .models import Dealer
|
from .models import Dealer
|
||||||
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
||||||
@ -325,7 +325,8 @@ class BasePurchaseOrderActionActionView(
|
|||||||
try:
|
try:
|
||||||
if po_model.can_fulfill():
|
if po_model.can_fulfill():
|
||||||
po_model.mark_as_fulfilled()
|
po_model.mark_as_fulfilled()
|
||||||
# po_model.date_fulfilled = timezone.now()
|
if po_model.is_fulfilled():
|
||||||
|
po_model.date_fulfilled = timezone.now().date()
|
||||||
po_model.save()
|
po_model.save()
|
||||||
messages.add_message(
|
messages.add_message(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@ -947,7 +947,7 @@ def create_po_item_upload(sender, instance, created, **kwargs):
|
|||||||
if instance.po_status == "fulfilled":
|
if instance.po_status == "fulfilled":
|
||||||
for item in instance.get_itemtxs_data()[0]:
|
for item in instance.get_itemtxs_data()[0]:
|
||||||
dealer = models.Dealer.objects.get(entity=instance.entity)
|
dealer = models.Dealer.objects.get(entity=instance.entity)
|
||||||
models.PoItemsUploaded.objects.create(
|
models.PoItemsUploaded.objects.get_or_create(
|
||||||
dealer=dealer, po=instance, item=item, status="fulfilled"
|
dealer=dealer, po=instance, item=item, status="fulfilled"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -6578,7 +6578,8 @@ def schedule_event(request, dealer_slug, content_type, slug):
|
|||||||
)
|
)
|
||||||
messages.success(request, _("Appointment Created Successfully"))
|
messages.success(request, _("Appointment Created Successfully"))
|
||||||
|
|
||||||
return redirect(request.META.get("HTTP_REFERER"))
|
return redirect(f'{content_type}_detail',dealer_slug=dealer_slug, slug=slug)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Log for invalid form data
|
# Log for invalid form data
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@ -9640,9 +9641,11 @@ def add_task(request, dealer_slug, content_type, slug):
|
|||||||
f"for {content_type} ID: {obj.slug} (Dealer: {dealer_slug}). Errors: {form.errors.as_json()}"
|
f"for {content_type} ID: {obj.slug} (Dealer: {dealer_slug}). Errors: {form.errors.as_json()}"
|
||||||
)
|
)
|
||||||
messages.error(request, _("Task form is not valid"))
|
messages.error(request, _("Task form is not valid"))
|
||||||
|
|
||||||
return redirect(f"{content_type}_detail", dealer_slug=dealer_slug, slug=slug)
|
return redirect(f"{content_type}_detail", dealer_slug=dealer_slug, slug=slug)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@permission_required("inventory.change_tasks", raise_exception=True)
|
@permission_required("inventory.change_tasks", raise_exception=True)
|
||||||
def update_task(request, dealer_slug, pk):
|
def update_task(request, dealer_slug, pk):
|
||||||
@ -9673,7 +9676,6 @@ def update_schedule(request, dealer_slug, pk):
|
|||||||
@permission_required("inventory.add_notes", raise_exception=True)
|
@permission_required("inventory.add_notes", raise_exception=True)
|
||||||
def add_note(request, dealer_slug, content_type, slug):
|
def add_note(request, dealer_slug, content_type, slug):
|
||||||
# Get user information for logging
|
# Get user information for logging
|
||||||
print("hi")
|
|
||||||
user_username = (
|
user_username = (
|
||||||
request.user.username if request.user.is_authenticated else "anonymous"
|
request.user.username if request.user.is_authenticated else "anonymous"
|
||||||
)
|
)
|
||||||
@ -9732,7 +9734,6 @@ def add_note(request, dealer_slug, content_type, slug):
|
|||||||
@permission_required("inventory.change_notes", raise_exception=True)
|
@permission_required("inventory.change_notes", raise_exception=True)
|
||||||
def update_note(request, dealer_slug, pk):
|
def update_note(request, dealer_slug, pk):
|
||||||
note = get_object_or_404(models.Notes, pk=pk)
|
note = get_object_or_404(models.Notes, pk=pk)
|
||||||
print(note)
|
|
||||||
lead = get_object_or_404(models.Lead, pk=note.content_object.id)
|
lead = get_object_or_404(models.Lead, pk=note.content_object.id)
|
||||||
dealer = get_object_or_404(models.Dealer, slug=dealer_slug)
|
dealer = get_object_or_404(models.Dealer, slug=dealer_slug)
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
|||||||
@ -156,7 +156,7 @@
|
|||||||
document.getElementById('global-indicator')
|
document.getElementById('global-indicator')
|
||||||
];
|
];
|
||||||
});*/
|
});*/
|
||||||
let Toast = Swal.mixin({
|
let Toast = Swal.mixin({
|
||||||
toast: true,
|
toast: true,
|
||||||
position: "top-end",
|
position: "top-end",
|
||||||
showConfirmButton: false,
|
showConfirmButton: false,
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form action="{% url 'schedule_event' request.dealer.slug content_type slug %}"
|
<form id="scheduleForm" action="{% url 'schedule_event' request.dealer.slug content_type slug %}"
|
||||||
hx-select=".taskTable"
|
hx-select=".taskTable"
|
||||||
hx-target=".taskTable"
|
hx-target=".taskTable"
|
||||||
hx-on::after-request="{
|
hx-on::after-request="{
|
||||||
|
|||||||
@ -22,13 +22,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form action="{% url 'add_task' request.dealer.slug content_type slug %}"
|
<form id="taskForm" action="{% url 'add_task' request.dealer.slug content_type slug %}"
|
||||||
method="post"
|
method="post"
|
||||||
class="add_task_form"
|
class="add_task_form"
|
||||||
hx-post="{% url 'add_task' request.dealer.slug content_type slug %}"
|
hx-post="{% url 'add_task' request.dealer.slug content_type slug %}"
|
||||||
hx-target="#your-content-container"
|
hx-target="#your-content-container"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-boost="false">
|
hx-boost="true">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ staff_task_form|crispy }}
|
{{ staff_task_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>
|
||||||
|
|||||||
@ -324,6 +324,11 @@
|
|||||||
<div class="tab-content" id="myTabContent">
|
<div class="tab-content" id="myTabContent">
|
||||||
<div class="tab-pane fade"
|
<div class="tab-pane fade"
|
||||||
id="tab-activity"
|
id="tab-activity"
|
||||||
|
hx-get="{% url 'lead_detail' request.dealer.slug lead.slug %}"
|
||||||
|
hx-trigger="htmx:afterRequest from:#noteForm, htmx:afterRequest from:#scheduleForm"
|
||||||
|
hx-select="#tab-activity"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="outerHTML"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-labelledby="activity-tab">
|
aria-labelledby="activity-tab">
|
||||||
<div class="mb-1 d-flex justify-content-between align-items-center">
|
<div class="mb-1 d-flex justify-content-between align-items-center">
|
||||||
@ -824,19 +829,7 @@
|
|||||||
let form = document.querySelector('.add_note_form')
|
let form = document.querySelector('.add_note_form')
|
||||||
form.action = "{% url 'add_note' request.dealer.slug 'lead' lead.slug %}"
|
form.action = "{% url 'add_note' request.dealer.slug 'lead' lead.slug %}"
|
||||||
}
|
}
|
||||||
/*let Toast = Swal.mixin({
|
|
||||||
toast: true,
|
|
||||||
position: "top-end",
|
|
||||||
showConfirmButton: false,
|
|
||||||
timer: 3000,
|
|
||||||
timerProgressBar: true,
|
|
||||||
didOpen: (toast) => {
|
|
||||||
toast.onmouseenter = Swal.stopTimer;
|
|
||||||
toast.onmouseleave = Swal.resumeTimer;
|
|
||||||
}
|
|
||||||
});*/
|
|
||||||
|
|
||||||
// Display Django messages
|
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
Toast.fire({
|
Toast.fire({
|
||||||
@ -857,90 +850,7 @@
|
|||||||
modal.show();
|
modal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
/*document.getElementById('actionTrackingForm').addEventListener('submit', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(this);
|
|
||||||
|
|
||||||
// Show loading indicator
|
|
||||||
Swal.fire({
|
|
||||||
toast: true,
|
|
||||||
icon: 'info',
|
|
||||||
text: 'Please wait...',
|
|
||||||
allowOutsideClick: false,
|
|
||||||
position: "top-end",
|
|
||||||
showConfirmButton: false,
|
|
||||||
timer: 2000,
|
|
||||||
timerProgressBar: false,
|
|
||||||
didOpen: (toast) => {
|
|
||||||
toast.onmouseenter = Swal.stopTimer;
|
|
||||||
toast.onmouseleave = Swal.resumeTimer;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
fetch("{% url 'update_lead_actions' request.dealer.slug %}", {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
headers: {
|
|
||||||
'X-CSRFToken': '{{ csrf_token }}'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
Swal.close();
|
|
||||||
if (data.success) {
|
|
||||||
// Success notification
|
|
||||||
Swal.fire({
|
|
||||||
toast: true,
|
|
||||||
icon: 'success',
|
|
||||||
position: "top-end",
|
|
||||||
text: data.message || 'Actions updated successfully',
|
|
||||||
showConfirmButton: false,
|
|
||||||
timer: 2000,
|
|
||||||
timerProgressBar: false,
|
|
||||||
didOpen: (toast) => {
|
|
||||||
toast.onmouseenter = Swal.stopTimer;
|
|
||||||
toast.onmouseleave = Swal.resumeTimer;
|
|
||||||
}
|
|
||||||
}).then(() => {
|
|
||||||
location.reload(); // Refresh after user clicks OK
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Error notification
|
|
||||||
Swal.fire({
|
|
||||||
toast: true,
|
|
||||||
icon: 'error',
|
|
||||||
position: "top-end",
|
|
||||||
text: data.message || 'Failed to update actions',
|
|
||||||
showConfirmButton: false,
|
|
||||||
timer: 2000,
|
|
||||||
timerProgressBar: false,
|
|
||||||
didOpen: (toast) => {
|
|
||||||
toast.onmouseenter = Swal.stopTimer;
|
|
||||||
toast.onmouseleave = Swal.resumeTimer;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
Swal.close();
|
|
||||||
console.error('Error:', error);
|
|
||||||
Swal.fire({
|
|
||||||
toast: true,
|
|
||||||
icon: 'error',
|
|
||||||
position: "top-end",
|
|
||||||
text: 'An unexpected error occurred',
|
|
||||||
showConfirmButton: false,
|
|
||||||
timer: 2000,
|
|
||||||
timerProgressBar: false,
|
|
||||||
didOpen: (toast) => {
|
|
||||||
toast.onmouseenter = Swal.stopTimer;
|
|
||||||
toast.onmouseleave = Swal.resumeTimer;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});*/
|
|
||||||
|
|
||||||
// Helper function for notifications
|
|
||||||
function notify(tag, msg) {
|
function notify(tag, msg) {
|
||||||
Toast.fire({
|
Toast.fire({
|
||||||
icon: tag,
|
icon: tag,
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<!-- Action Tracking Modal -->
|
<!-- Action Tracking Modal -->
|
||||||
{% comment %} {% include "crm/leads/partials/update_action.html" %} {% endcomment %}
|
{% comment %} {% include "crm/leads/partials/update_action.html" %} {% endcomment %}
|
||||||
|
|
||||||
<div class="row g-3 justify-content-between mb-4">
|
<div class="row g-3 justify-content-between mb-4">
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<div class="d-md-flex justify-content-between">
|
<div class="d-md-flex justify-content-between">
|
||||||
@ -29,7 +29,7 @@
|
|||||||
<div class="d-flex">{% include 'partials/search_box.html' %}</div>
|
<div class="d-flex">{% include 'partials/search_box.html' %}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
{% if page_obj.object_list %}
|
{% if page_obj.object_list %}
|
||||||
@ -205,7 +205,7 @@
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="align-middle white-space-nowrap text-end">
|
<td class="align-middle white-space-nowrap text-end">
|
||||||
{% if user == lead.staff.user or request.is_dealer %}
|
{% if user == lead.staff.user or request.is_dealer %}
|
||||||
<div class="btn-reveal-trigger position-static">
|
<div class="btn-reveal-trigger position-static">
|
||||||
@ -253,7 +253,7 @@
|
|||||||
<div class="d-flex">{% include 'partials/pagination.html' %}</div>
|
<div class="d-flex">{% include 'partials/pagination.html' %}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -262,70 +262,4 @@
|
|||||||
{% url 'lead_create' request.dealer.slug as create_lead_url %}
|
{% url 'lead_create' request.dealer.slug as create_lead_url %}
|
||||||
{% include "empty-illustration-page.html" with value="lead" url=create_lead_url %}
|
{% include "empty-illustration-page.html" with value="lead" url=create_lead_url %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block customJS %}
|
|
||||||
<script>
|
|
||||||
// Initialize SweetAlert Toast for general messages
|
|
||||||
let Toast = Swal.mixin({
|
|
||||||
toast: true,
|
|
||||||
position: "top-end",
|
|
||||||
showConfirmButton: false,
|
|
||||||
timer: 3000,
|
|
||||||
timerProgressBar: true,
|
|
||||||
didOpen: (toast) => {
|
|
||||||
toast.onmouseenter = Swal.stopTimer;
|
|
||||||
toast.onmouseleave = Swal.resumeTimer;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Display Django messages
|
|
||||||
{% if messages %}
|
|
||||||
{% for message in messages %}
|
|
||||||
Toast.fire({
|
|
||||||
icon: "{{ message.tags }}",
|
|
||||||
titleText: "{{ message|safe }}"
|
|
||||||
});
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
function openActionModal(leadId, currentAction, nextAction, nextActionDate) {
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('actionTrackingModal'));
|
|
||||||
document.getElementById('leadId').value = leadId;
|
|
||||||
document.getElementById('currentAction').value = currentAction;
|
|
||||||
document.getElementById('nextAction').value = nextAction;
|
|
||||||
document.getElementById('nextActionDate').value = nextActionDate;
|
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('actionTrackingForm').addEventListener('submit', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(this);
|
|
||||||
|
|
||||||
// Show loading indicator
|
|
||||||
Swal.fire({
|
|
||||||
toast: true,
|
|
||||||
icon: 'info',
|
|
||||||
text: 'Please wait...',
|
|
||||||
allowOutsideClick: false,
|
|
||||||
position: "top-end",
|
|
||||||
showConfirmButton: false,
|
|
||||||
timer: 2000,
|
|
||||||
timerProgressBar: false,
|
|
||||||
didOpen: (toast) => {
|
|
||||||
toast.onmouseenter = Swal.stopTimer;
|
|
||||||
toast.onmouseleave = Swal.resumeTimer;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
// Helper function for notifications
|
|
||||||
function notify(tag, msg) {
|
|
||||||
Toast.fire({
|
|
||||||
icon: tag,
|
|
||||||
titleText: msg
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock customJS %}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1020,6 +1020,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="tab-pane fade"
|
<div class="tab-pane fade"
|
||||||
id="tab-activity"
|
id="tab-activity"
|
||||||
|
hx-get="{% url 'lead_detail' request.dealer.slug lead.slug %}"
|
||||||
|
hx-trigger="htmx:afterRequest from:"
|
||||||
|
hx-select="#tab-activity"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="outerHTML"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-labelledby="activity-tab">
|
aria-labelledby="activity-tab">
|
||||||
<h2 class="mb-4">Activity</h2>
|
<h2 class="mb-4">Activity</h2>
|
||||||
|
|||||||
@ -307,13 +307,13 @@
|
|||||||
<div class="parent-wrapper label-1">
|
<div class="parent-wrapper label-1">
|
||||||
<ul class="nav collapse parent" data-bs-parent="#navbarVerticalCollapse" id="nv-reports">
|
<ul class="nav collapse parent" data-bs-parent="#navbarVerticalCollapse" id="nv-reports">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
{% if request.user.is_authenticated and request.is_dealer %}
|
{% comment %} {% if request.user.is_authenticated and request.is_dealer %}
|
||||||
<a class="nav-link" href="{% url 'entity-dashboard' request.dealer.entity.slug %}">
|
<a class="nav-link" href="{% url 'entity-dashboard' request.dealer.entity.slug %}">
|
||||||
{% elif request.user.is_authenticated and request.is_staff %}
|
{% elif request.user.is_authenticated and request.is_staff %}
|
||||||
<a class="nav-link" href="{% url 'entity-dashboard' request.user.staffmember.staff.dealer.entity.slug %}">
|
<a class="nav-link" href="{% url 'entity-dashboard' request.user.staffmember.staff.dealer.entity.slug %}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="nav-link" href="#">
|
<a class="nav-link" href="#">
|
||||||
{% endif %}
|
{% endif %} {% endcomment %}
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
{% comment %} <i class="fa-solid fa-chart-line"></i><span class="nav-link-text">{% trans 'Dashboard'|capfirst %}</span> {% endcomment %}
|
{% comment %} <i class="fa-solid fa-chart-line"></i><span class="nav-link-text">{% trans 'Dashboard'|capfirst %}</span> {% endcomment %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -331,18 +331,18 @@
|
|||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
{% block customJS %}
|
{% block customJS %}
|
||||||
<script>
|
<script>
|
||||||
// Global variables
|
/*
|
||||||
|
// Global variables
|
||||||
|
let codeReader;
|
||||||
|
let currentStream = null;
|
||||||
|
const csrfToken = getCookie("csrftoken");
|
||||||
|
const ajaxUrl = "{% url 'ajax_handler' request.dealer.slug %}";
|
||||||
|
|
||||||
// Initialize when page loads and after HTMX swaps
|
// Initialize when page loads and after HTMX swaps
|
||||||
document.addEventListener('DOMContentLoaded', initPage);
|
document.addEventListener('DOMContentLoaded', initPage);
|
||||||
document.addEventListener('htmx:afterSwap', initPage);
|
document.addEventListener('htmx:afterRequest', initPage);
|
||||||
|
|
||||||
function initPage() {
|
function initPage() {
|
||||||
let codeReader;
|
|
||||||
let currentStream = null;
|
|
||||||
const csrfToken = getCookie("csrftoken");
|
|
||||||
const ajaxUrl = "{% url 'ajax_handler' request.dealer.slug %}";
|
|
||||||
|
|
||||||
// Get DOM elements
|
// Get DOM elements
|
||||||
const elements = {
|
const elements = {
|
||||||
vinInput: document.getElementById("{{ form.vin.id_for_label }}"),
|
vinInput: document.getElementById("{{ form.vin.id_for_label }}"),
|
||||||
@ -758,6 +758,506 @@ function notify(tag, msg) {
|
|||||||
icon: tag,
|
icon: tag,
|
||||||
titleText: msg,
|
titleText: msg,
|
||||||
});
|
});
|
||||||
}
|
}/
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Helper function to get CSRF token from cookies
|
||||||
|
function getCookie(name) {
|
||||||
|
let cookieValue = null;
|
||||||
|
if (document.cookie && document.cookie !== "") {
|
||||||
|
const cookies = document.cookie.split(";");
|
||||||
|
for (let cookie of cookies) {
|
||||||
|
cookie = cookie.trim();
|
||||||
|
if (cookie.substring(0, name.length + 1) === name + "=") {
|
||||||
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookieValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SweetAlert loading and notification functions
|
||||||
|
function showLoading() {
|
||||||
|
Swal.fire({
|
||||||
|
title: "{% trans 'Please Wait' %}",
|
||||||
|
text: "{% trans 'Loading' %}...",
|
||||||
|
allowOutsideClick: false,
|
||||||
|
didOpen: () => {
|
||||||
|
Swal.showLoading();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideLoading() {
|
||||||
|
Swal.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(tag, msg) {
|
||||||
|
Swal.fire({
|
||||||
|
icon: tag,
|
||||||
|
titleText: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flag to control programmatic changes vs. user-initiated changes
|
||||||
|
let isProgrammaticChange = false;
|
||||||
|
|
||||||
|
// Main initialization function for VIN Decoder logic
|
||||||
|
function initVinDecoder() {
|
||||||
|
// Get CSRF token
|
||||||
|
const csrfToken = getCookie("csrftoken");
|
||||||
|
|
||||||
|
// Get DOM elements
|
||||||
|
const vinInput = document.getElementById("{{ form.vin.id_for_label }}");
|
||||||
|
const decodeVinBtn = document.getElementById("decodeVinBtn");
|
||||||
|
const makeSelect = document.getElementById("{{ form.id_car_make.id_for_label }}");
|
||||||
|
const modelSelect = document.getElementById("{{ form.id_car_model.id_for_label }}");
|
||||||
|
const yearSelect = document.getElementById("{{ form.year.id_for_label }}");
|
||||||
|
const serieSelect = document.getElementById("{{ form.id_car_serie.id_for_label }}");
|
||||||
|
const trimSelect = document.getElementById("{{ form.id_car_trim.id_for_label }}");
|
||||||
|
const equipmentSelect = document.getElementById("equipment_id");
|
||||||
|
const showSpecificationButton = document.getElementById("specification-btn");
|
||||||
|
const showEquipmentButton = document.getElementById("options-btn");
|
||||||
|
const specificationsContent = document.getElementById("specificationsContent");
|
||||||
|
const optionsContent = document.getElementById("optionsContent");
|
||||||
|
const generationContainer = document.getElementById("generation-div");
|
||||||
|
const closeButton = document.querySelector(".btn-close");
|
||||||
|
const scanVinBtn = document.getElementById("scan-vin-btn");
|
||||||
|
const videoElement = document.getElementById("video");
|
||||||
|
const resultDisplay = document.getElementById("result");
|
||||||
|
const fallbackButton = document.getElementById("ocr-fallback-btn");
|
||||||
|
|
||||||
|
// Define AJAX URL
|
||||||
|
const ajaxUrl = "{% url 'ajax_handler' request.dealer.slug %}";
|
||||||
|
|
||||||
|
// ZXing and OCR setup
|
||||||
|
let codeReader = new ZXing.BrowserMultiFormatReader();
|
||||||
|
let currentStream = null;
|
||||||
|
|
||||||
|
// --- Helper Functions for VIN Decoder Logic ---
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
stopScanner();
|
||||||
|
try {
|
||||||
|
const scannerModal = document.getElementById("scannerModal");
|
||||||
|
if (scannerModal) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
scannerModal.setAttribute("inert", "true");
|
||||||
|
const modalInstance = bootstrap.Modal.getInstance(scannerModal);
|
||||||
|
if (modalInstance) modalInstance.hide();
|
||||||
|
if (scanVinBtn) scanVinBtn.focus();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error closing scanner modal:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decodeVin() {
|
||||||
|
const vinNumber = vinInput.value.trim();
|
||||||
|
if (vinNumber.length !== 17) {
|
||||||
|
Swal.fire("error", "{% trans 'Please enter a valid VIN.' %}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showLoading();
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${ajaxUrl}?action=decode_vin&vin_no=${vinNumber}`, {
|
||||||
|
headers: {
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"X-CSRFToken": csrfToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
hideLoading();
|
||||||
|
await updateFields(data.data);
|
||||||
|
} else {
|
||||||
|
hideLoading();
|
||||||
|
Swal.fire("{% trans 'error' %}", data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error decoding VIN:", error);
|
||||||
|
hideLoading();
|
||||||
|
Swal.fire("error", "{% trans 'An error occurred while decoding the VIN.' %}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrates the updates of fields and subsequent dropdowns based on VIN data.
|
||||||
|
* Sets `isProgrammaticChange` to prevent recursive event firing.
|
||||||
|
*/
|
||||||
|
async function updateFields(vinData) {
|
||||||
|
console.log(vinData);
|
||||||
|
isProgrammaticChange = true; // Set flag to prevent immediate cascade
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (vinData.make_id) {
|
||||||
|
makeSelect.value = vinData.make_id;
|
||||||
|
document.getElementById("make-check").innerHTML = "✓";
|
||||||
|
// Loading models will trigger its change listener, but `isProgrammaticChange`
|
||||||
|
// will prevent immediate cascade to series.
|
||||||
|
await loadModels(vinData.make_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vinData.model_id) {
|
||||||
|
modelSelect.value = vinData.model_id;
|
||||||
|
document.getElementById("model-check").innerHTML = "✓";
|
||||||
|
// Manually trigger the next step: load series
|
||||||
|
await loadSeries(vinData.model_id, vinData.year, vinData.serie_id); // Pass expected serie_id for selection
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vinData.year) {
|
||||||
|
yearSelect.value = vinData.year;
|
||||||
|
document.getElementById("year-check").innerHTML = "✓";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isProgrammaticChange = false; // Reset flag after programmatic updates are done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function startScanner() {
|
||||||
|
codeReader
|
||||||
|
.decodeFromVideoDevice(null, videoElement, async (result, err) => {
|
||||||
|
let res = await result;
|
||||||
|
if (result) {
|
||||||
|
vinInput.value = result.text;
|
||||||
|
closeModal();
|
||||||
|
await decodeVin();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureAndOCR() {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
canvas.width = videoElement.videoWidth;
|
||||||
|
canvas.height = videoElement.videoHeight;
|
||||||
|
context.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
|
||||||
|
Tesseract.recognize(canvas.toDataURL("image/png"), "eng")
|
||||||
|
.then(({ data: { text } }) => {
|
||||||
|
const vin = text.match(/[A-HJ-NPR-Z0-9]{17}/);
|
||||||
|
if (vin) vinInput.value = vin[0];
|
||||||
|
closeModal();
|
||||||
|
decodeVin();
|
||||||
|
})
|
||||||
|
.catch((err) => console.error("OCR Error:", err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopScanner() {
|
||||||
|
if (currentStream) {
|
||||||
|
currentStream.getTracks().forEach((track) => track.stop());
|
||||||
|
currentStream = null;
|
||||||
|
}
|
||||||
|
codeReader.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDropdown(dropdown, placeholder) {
|
||||||
|
dropdown.innerHTML = `<option value="">${placeholder}</option>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModels(makeId) {
|
||||||
|
resetDropdown(modelSelect, '{% trans "Select" %}');
|
||||||
|
const response = await fetch(`${ajaxUrl}?action=get_models&make_id=${makeId}`, {
|
||||||
|
headers: {
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"X-CSRFToken": csrfToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
data.forEach((model) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = model.id_car_model;
|
||||||
|
option.textContent = document.documentElement.lang === "en" ? model.name : model.arabic_name;
|
||||||
|
modelSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads series and optionally selects a specific serie_id, then triggers trim loading.
|
||||||
|
* @param {string} modelId
|
||||||
|
* @param {string} year
|
||||||
|
* @param {string} [selectSerieId] - Optional ID of the serie to select after loading.
|
||||||
|
*/
|
||||||
|
async function loadSeries(modelId, year, selectSerieId = null) {
|
||||||
|
resetDropdown(serieSelect, '{% trans "Select" %}');
|
||||||
|
resetDropdown(trimSelect, '{% trans "Select" %}');
|
||||||
|
specificationsContent.innerHTML = "";
|
||||||
|
optionsContent.innerHTML = ""; // Clear options content too
|
||||||
|
showSpecificationButton.disabled = true;
|
||||||
|
showEquipmentButton.disabled = true;
|
||||||
|
|
||||||
|
const response = await fetch(`${ajaxUrl}?action=get_series&model_id=${modelId}&year=${year}`, {
|
||||||
|
headers: {
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"X-CSRFToken": csrfToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(data);
|
||||||
|
data.forEach((serie) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = serie.id_car_serie;
|
||||||
|
option.textContent = document.documentElement.lang === "en" ? serie.name : serie.name;
|
||||||
|
serieSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectSerieId && serieSelect.querySelector(`option[value="${selectSerieId}"]`)) {
|
||||||
|
serieSelect.value = selectSerieId;
|
||||||
|
const selectedSerie = data.find(s => s.id_car_serie === selectSerieId);
|
||||||
|
if (selectedSerie) {
|
||||||
|
generationContainer.innerHTML = selectedSerie.generation_name;
|
||||||
|
}
|
||||||
|
// If a specific series was selected, now manually load its trims
|
||||||
|
await loadTrims(selectSerieId, modelId, null); // Pass null for selectTrimId, as we only have serie_id from VIN
|
||||||
|
} else if (data.length > 0) {
|
||||||
|
// If no specific serie to select, but there are series, select the first one
|
||||||
|
serieSelect.value = data[0].id_car_serie;
|
||||||
|
generationContainer.innerHTML = data[0].generation_name || '';
|
||||||
|
await loadTrims(data[0].id_car_serie, modelId, null);
|
||||||
|
} else {
|
||||||
|
generationContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads trims and optionally selects a specific trim_id, then triggers specs/equipment loading.
|
||||||
|
* @param {string} serie_id
|
||||||
|
* @param {string} model_id
|
||||||
|
* @param {string} [selectTrimId] - Optional ID of the trim to select after loading.
|
||||||
|
*/
|
||||||
|
async function loadTrims(serie_id, model_id, selectTrimId = null) {
|
||||||
|
resetDropdown(trimSelect, '{% trans "Select" %}');
|
||||||
|
specificationsContent.innerHTML = "";
|
||||||
|
optionsContent.innerHTML = "";
|
||||||
|
showSpecificationButton.disabled = true;
|
||||||
|
showEquipmentButton.disabled = true;
|
||||||
|
|
||||||
|
const response = await fetch(`${ajaxUrl}?action=get_trims&serie_id=${serie_id}&model_id=${model_id}`, {
|
||||||
|
headers: {
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"X-CSRFToken": csrfToken,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
data.forEach((trim) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = trim.id_car_trim;
|
||||||
|
option.textContent = document.documentElement.lang === "en" ? trim.name : trim.name;
|
||||||
|
trimSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectTrimId && trimSelect.querySelector(`option[value="${selectTrimId}"]`)) {
|
||||||
|
trimSelect.value = selectTrimId;
|
||||||
|
// If a specific trim was selected, now manually load its specs and equipment
|
||||||
|
await loadSpecifications(selectTrimId);
|
||||||
|
await loadEquipment(selectTrimId);
|
||||||
|
} else if (data.length > 0) {
|
||||||
|
// If no specific trim to select, but there are trims, select the first one
|
||||||
|
trimSelect.value = data[0].id_car_trim;
|
||||||
|
await loadSpecifications(data[0].id_car_trim);
|
||||||
|
await loadEquipment(data[0].id_car_trim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEquipment(trimId){
|
||||||
|
resetDropdown(equipmentSelect, '{% trans "Select" %}');
|
||||||
|
optionsContent.innerHTML = ""; // Clear options content
|
||||||
|
showEquipmentButton.disabled = !trimId; // Enable/disable based on trimId presence
|
||||||
|
|
||||||
|
if (!trimId) return; // No trim ID, no equipment to load
|
||||||
|
|
||||||
|
const response = await fetch(`${ajaxUrl}?action=get_equipments&trim_id=${trimId}`, {
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-CSRFToken': csrfToken
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
data.forEach((equipment) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = equipment.id_car_equipment;
|
||||||
|
option.textContent = equipment.name;
|
||||||
|
equipmentSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSpecifications(trimId) {
|
||||||
|
specificationsContent.innerHTML = "";
|
||||||
|
showSpecificationButton.disabled = !trimId; // Enable/disable based on trimId presence
|
||||||
|
|
||||||
|
if (!trimId) return; // No trim ID, no specifications to load
|
||||||
|
|
||||||
|
const response = await fetch(`${ajaxUrl}?action=get_specifications&trim_id=${trimId}`, {
|
||||||
|
headers: {
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"X-CSRFToken": csrfToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
data.forEach((spec) => {
|
||||||
|
const parentDiv = document.createElement("div");
|
||||||
|
parentDiv.innerHTML = `<strong>${spec.parent_name}</strong>`;
|
||||||
|
spec.specifications.forEach((s) => {
|
||||||
|
const specDiv = document.createElement("div");
|
||||||
|
specDiv.innerHTML = `• ${s.s_name}: ${s.s_value} ${s.s_unit}`;
|
||||||
|
parentDiv.appendChild(specDiv);
|
||||||
|
});
|
||||||
|
specificationsContent.appendChild(parentDiv);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOptions(equipmentId) {
|
||||||
|
optionsContent.innerHTML = "";
|
||||||
|
if (!equipmentId) return; // No equipment ID, no options to load
|
||||||
|
|
||||||
|
const response = await fetch(`${ajaxUrl}?action=get_options&equipment_id=${equipmentId}`, {
|
||||||
|
headers: {
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"X-CSRFToken": csrfToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
data.forEach((parent) => {
|
||||||
|
const parentDiv = document.createElement("div");
|
||||||
|
parentDiv.innerHTML = `<strong>${parent.parent_name}</strong>`;
|
||||||
|
parent.options.forEach((option) => {
|
||||||
|
const optDiv = document.createElement("div");
|
||||||
|
optDiv.innerHTML = `• ${option.option_name}`;
|
||||||
|
parentDiv.appendChild(optDiv);
|
||||||
|
});
|
||||||
|
optionsContent.appendChild(parentDiv);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event Listeners ---
|
||||||
|
|
||||||
|
if (decodeVinBtn) {
|
||||||
|
decodeVinBtn.addEventListener("click", decodeVin);
|
||||||
|
}
|
||||||
|
if (scanVinBtn) {
|
||||||
|
scanVinBtn.addEventListener("click", () => {
|
||||||
|
resultDisplay.textContent = "";
|
||||||
|
startScanner();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (fallbackButton) {
|
||||||
|
fallbackButton.addEventListener("click", () => {
|
||||||
|
captureAndOCR();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modified modelSelect listener: ONLY triggers loadSeries.
|
||||||
|
// The chain will be managed by explicit calls in loadSeries/loadTrims.
|
||||||
|
if (modelSelect) {
|
||||||
|
modelSelect.addEventListener("change", async (e) => {
|
||||||
|
if (isProgrammaticChange) return; // Prevent user-initiated event during programmatic updates
|
||||||
|
|
||||||
|
const selectedModelId = e.target.value;
|
||||||
|
const selectedYear = yearSelect.value;
|
||||||
|
if (selectedModelId) {
|
||||||
|
await loadSeries(selectedModelId, selectedYear);
|
||||||
|
} else {
|
||||||
|
// Reset all dependent dropdowns if model is cleared
|
||||||
|
resetDropdown(serieSelect, '{% trans "Select" %}');
|
||||||
|
resetDropdown(trimSelect, '{% trans "Select" %}');
|
||||||
|
resetDropdown(equipmentSelect, '{% trans "Select" %}');
|
||||||
|
specificationsContent.innerHTML = "";
|
||||||
|
optionsContent.innerHTML = "";
|
||||||
|
generationContainer.innerHTML = '';
|
||||||
|
showSpecificationButton.disabled = true;
|
||||||
|
showEquipmentButton.disabled = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modified serieSelect listener: ONLY triggers loadTrims.
|
||||||
|
// The chain will be managed by explicit calls in loadTrims.
|
||||||
|
if (serieSelect) {
|
||||||
|
serieSelect.addEventListener("change", async () => {
|
||||||
|
if (isProgrammaticChange) return; // Prevent user-initiated event during programmatic updates
|
||||||
|
|
||||||
|
const serie_id = serieSelect.value;
|
||||||
|
const model_id = modelSelect.value; // Get model_id from modelSelect
|
||||||
|
if (serie_id && model_id) {
|
||||||
|
await loadTrims(serie_id, model_id); // Only load trims
|
||||||
|
} else {
|
||||||
|
// Reset dependent dropdowns and content
|
||||||
|
resetDropdown(trimSelect, '{% trans "Select" %}');
|
||||||
|
resetDropdown(equipmentSelect, '{% trans "Select" %}');
|
||||||
|
specificationsContent.innerHTML = "";
|
||||||
|
optionsContent.innerHTML = "";
|
||||||
|
showSpecificationButton.disabled = true;
|
||||||
|
showEquipmentButton.disabled = true;
|
||||||
|
}
|
||||||
|
// Update generation text immediately on user change
|
||||||
|
const currentSerie = serieSelect.options[serieSelect.selectedIndex];
|
||||||
|
generationContainer.innerHTML = currentSerie ? currentSerie.dataset.generationName || '' : ''; // Assuming you add data-generation-name to options later
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimSelect listener: Triggers loadSpecifications and loadEquipment
|
||||||
|
if (trimSelect) {
|
||||||
|
trimSelect.addEventListener("change", async () => {
|
||||||
|
if (isProgrammaticChange) return; // Prevent user-initiated event during programmatic updates
|
||||||
|
|
||||||
|
const trimId = trimSelect.value;
|
||||||
|
if (trimId) {
|
||||||
|
await loadSpecifications(trimId);
|
||||||
|
await loadEquipment(trimId);
|
||||||
|
} else {
|
||||||
|
// Reset dependent content and equipment dropdown
|
||||||
|
specificationsContent.innerHTML = "";
|
||||||
|
optionsContent.innerHTML = "";
|
||||||
|
resetDropdown(equipmentSelect, '{% trans "Select" %}');
|
||||||
|
showSpecificationButton.disabled = true;
|
||||||
|
showEquipmentButton.disabled = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// equipmentSelect listener: Triggers loadOptions
|
||||||
|
if (equipmentSelect) {
|
||||||
|
equipmentSelect.addEventListener("change", async () => {
|
||||||
|
if (isProgrammaticChange) return; // Prevent user-initiated event during programmatic updates
|
||||||
|
|
||||||
|
const equipmentId = equipmentSelect.value;
|
||||||
|
if (equipmentId) {
|
||||||
|
await loadOptions(equipmentId);
|
||||||
|
} else {
|
||||||
|
optionsContent.innerHTML = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closeButton) {
|
||||||
|
closeButton.addEventListener("click", closeModal);
|
||||||
|
}
|
||||||
|
if (makeSelect) {
|
||||||
|
makeSelect.addEventListener("change", (e) => {
|
||||||
|
// When make changes, only load models. No need to reset programmatically
|
||||||
|
// because modelSelect's own listener will handle the cascade if a model is selected.
|
||||||
|
loadModels(e.target.value);
|
||||||
|
|
||||||
|
// However, clear everything downstream immediately for user clarity.
|
||||||
|
resetDropdown(modelSelect, '{% trans "Select" %}');
|
||||||
|
resetDropdown(serieSelect, '{% trans "Select" %}');
|
||||||
|
resetDropdown(trimSelect, '{% trans "Select" %}');
|
||||||
|
resetDropdown(equipmentSelect, '{% trans "Select" %}');
|
||||||
|
specificationsContent.innerHTML = "";
|
||||||
|
optionsContent.innerHTML = "";
|
||||||
|
generationContainer.innerHTML = '';
|
||||||
|
showSpecificationButton.disabled = true;
|
||||||
|
showEquipmentButton.disabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the VIN decoder when the DOM is fully loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', initVinDecoder);
|
||||||
|
|
||||||
|
// Reinitialize after HTMX swaps
|
||||||
|
document.addEventListener('htmx:afterSwap', initVinDecoder);
|
||||||
</script>
|
</script>
|
||||||
{% endblock customJS %}
|
{% endblock customJS %}
|
||||||
Loading…
x
Reference in New Issue
Block a user