Autosave: 20260203-052143

This commit is contained in:
Flatlogic Bot 2026-02-03 05:21:43 +00:00
parent 573f45e183
commit f19ade40ee
28 changed files with 533 additions and 101 deletions

View File

@ -1,3 +1,4 @@
from django.utils import timezone
from django.db.models.signals import post_save, post_delete from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver from django.dispatch import receiver
from core.models import Sale, SalePayment, Purchase, PurchasePayment, Expense, SaleReturn, PurchaseReturn from core.models import Sale, SalePayment, Purchase, PurchasePayment, Expense, SaleReturn, PurchaseReturn

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -26,6 +26,8 @@ ALLOWED_HOSTS = [
os.getenv("HOST_FQDN", ""), os.getenv("HOST_FQDN", ""),
] ]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CSRF_TRUSTED_ORIGINS = [ CSRF_TRUSTED_ORIGINS = [
origin for origin in [ origin for origin in [
os.getenv("HOST_FQDN", ""), os.getenv("HOST_FQDN", ""),
@ -37,11 +39,13 @@ CSRF_TRUSTED_ORIGINS = [
for host in CSRF_TRUSTED_ORIGINS for host in CSRF_TRUSTED_ORIGINS
] ]
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy. # Cookies must always be HTTPS-only; SameSite=None is required for iframes.
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "None" SESSION_COOKIE_SAMESITE = "None"
CSRF_COOKIE_SAMESITE = "None" CSRF_COOKIE_SAMESITE = "None"
LANGUAGE_COOKIE_SECURE = True
LANGUAGE_COOKIE_SAMESITE = "None"
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
@ -85,6 +89,7 @@ TEMPLATES = [
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'django.template.context_processors.i18n',
# IMPORTANT: do not remove injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp # IMPORTANT: do not remove injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp
'core.context_processors.project_context', 'core.context_processors.project_context',
'core.context_processors.global_settings', 'core.context_processors.global_settings',

View File

@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2026-02-03 05:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0017_expensecategory_accounting_account'),
]
operations = [
migrations.AddField(
model_name='systemsetting',
name='wablas_enabled',
field=models.BooleanField(default=False, verbose_name='Enable WhatsApp Gateway'),
),
migrations.AddField(
model_name='systemsetting',
name='wablas_server_url',
field=models.URLField(blank=True, help_text='Example: https://console.wablas.com', verbose_name='Wablas Server URL'),
),
migrations.AddField(
model_name='systemsetting',
name='wablas_token',
field=models.CharField(blank=True, max_length=255, verbose_name='Wablas API Token'),
),
]

View File

@ -361,6 +361,11 @@ class SystemSetting(models.Model):
currency_per_point = models.DecimalField(_("Currency Value per Point"), max_digits=10, decimal_places=3, default=0.010) currency_per_point = models.DecimalField(_("Currency Value per Point"), max_digits=10, decimal_places=3, default=0.010)
min_points_to_redeem = models.PositiveIntegerField(_("Minimum Points to Redeem"), default=100) min_points_to_redeem = models.PositiveIntegerField(_("Minimum Points to Redeem"), default=100)
# WhatsApp (Wablas) Settings
wablas_enabled = models.BooleanField(_("Enable WhatsApp Gateway"), default=False)
wablas_token = models.CharField(_("Wablas API Token"), max_length=255, blank=True)
wablas_server_url = models.URLField(_("Wablas Server URL"), blank=True, help_text="Example: https://console.wablas.com")
def __str__(self): def __str__(self):
return self.business_name return self.business_name

View File

@ -386,6 +386,27 @@
} }
}); });
{% endif %} {% endif %}
// Prevent double form submission
document.addEventListener('submit', function (e) {
const form = e.target;
if (form.tagName === 'FORM') {
if (form.getAttribute('data-submitted') === 'true') {
e.preventDefault();
return;
}
form.setAttribute('data-submitted', 'true');
const submitButtons = form.querySelectorAll('button[type="submit"], input[type="submit"]');
submitButtons.forEach(btn => {
btn.disabled = true;
// Add spinner if it is a button and not already there
if (btn.tagName === 'BUTTON' && !btn.querySelector('.spinner-border')) {
const originalText = btn.innerHTML;
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${originalText}`;
}
});
}
});
</script> </script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>

View File

@ -1,5 +1,5 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load i18n %} {% load i18n l10n %}
{% block title %}{% trans "New Invoice" %} | {{ site_settings.business_name }}{% endblock %} {% block title %}{% trans "New Invoice" %} | {{ site_settings.business_name }}{% endblock %}
@ -77,7 +77,7 @@
<td> <td>
<input type="number" class="form-control form-control-sm text-center border-0 border-bottom rounded-0" v-model="item.quantity" @input="calculateTotal"> <input type="number" class="form-control form-control-sm text-center border-0 border-bottom rounded-0" v-model="item.quantity" @input="calculateTotal">
</td> </td>
<td class="text-end fw-bold">[[ currencySymbol ]][[ (item.price * item.quantity).toFixed(3) ]]</td> <td class="text-end fw-bold">[[ currencySymbol ]][[ (item.price * item.quantity).toFixed(decimalPlaces) ]]</td>
<td class="text-end"> <td class="text-end">
<button class="btn btn-link text-danger p-0" @click="removeItem(index)"><i class="bi bi-x-circle"></i></button> <button class="btn btn-link text-danger p-0" @click="removeItem(index)"><i class="bi bi-x-circle"></i></button>
</td> </td>
@ -102,7 +102,7 @@
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mb-2">
<span class="text-muted">{% trans "Subtotal" %}</span> <span class="text-muted">{% trans "Subtotal" %}</span>
<span class="fw-bold">[[ currencySymbol ]][[ subtotal.toFixed(3) ]]</span> <span class="fw-bold">[[ currencySymbol ]][[ subtotal.toFixed(decimalPlaces) ]]</span>
</div> </div>
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mb-2">
@ -116,7 +116,7 @@
<div class="d-flex justify-content-between mb-4"> <div class="d-flex justify-content-between mb-4">
<h4 class="fw-bold mb-0">{% trans "Grand Total" %}</h4> <h4 class="fw-bold mb-0">{% trans "Grand Total" %}</h4>
<h4 class="fw-bold text-primary mb-0">[[ currencySymbol ]][[ grandTotal.toFixed(3) ]]</h4> <h4 class="fw-bold text-primary mb-0">[[ currencySymbol ]][[ grandTotal.toFixed(decimalPlaces) ]]</h4>
</div> </div>
<!-- Payment Details --> <!-- Payment Details -->
@ -168,6 +168,7 @@
<!-- Vue.js 3 --> <!-- Vue.js 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
{% localize off %}
<script> <script>
const { createApp } = Vue; const { createApp } = Vue;
@ -178,12 +179,12 @@
products: [ products: [
{% for p in products %} {% for p in products %}
{ {
id: {{ p.id }}, id: {{ p.id|default:0 }},
name_en: "{{ p.name_en }}", name_en: "{{ p.name_en|escapejs }}",
name_ar: "{{ p.name_ar }}", name_ar: "{{ p.name_ar|escapejs }}",
sku: "{{ p.sku }}", sku: "{{ p.sku|escapejs }}",
price: {{ p.price }}, price: {{ p.price|default:0 }},
stock: {{ p.stock_quantity }} stock: {{ p.stock_quantity|default:0 }}
}, },
{% endfor %} {% endfor %}
], ],
@ -198,7 +199,8 @@
discount: 0, discount: 0,
dueDate: '', dueDate: '',
notes: '', notes: '',
currencySymbol: '{{ site_settings.currency_symbol }}', currencySymbol: '{{ site_settings.currency_symbol|escapejs }}',
decimalPlaces: {{ decimal_places|default:3 }},
isProcessing: false isProcessing: false
} }
}, },
@ -243,6 +245,7 @@
this.cart.splice(index, 1); this.cart.splice(index, 1);
}, },
saveSale() { saveSale() {
if (this.isProcessing) return;
this.isProcessing = true; this.isProcessing = true;
let actualPaidAmount = 0; let actualPaidAmount = 0;
@ -274,6 +277,7 @@
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
}, },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}) })
@ -295,4 +299,5 @@
} }
}).mount('#saleApp'); }).mount('#saleApp');
</script> </script>
{% endlocalize %}
{% endblock %} {% endblock %}

View File

@ -1,5 +1,5 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load i18n %} {% load i18n l10n %}
{% block title %}{% trans "Edit Invoice" %} | {{ site_settings.business_name }}{% endblock %} {% block title %}{% trans "Edit Invoice" %} | {{ site_settings.business_name }}{% endblock %}
@ -82,7 +82,7 @@
<td> <td>
<input type="number" class="form-control form-control-sm text-center border-0 border-bottom rounded-0" v-model="item.quantity" @input="calculateTotal"> <input type="number" class="form-control form-control-sm text-center border-0 border-bottom rounded-0" v-model="item.quantity" @input="calculateTotal">
</td> </td>
<td class="text-end fw-bold">[[ currencySymbol ]][[ (item.price * item.quantity).toFixed(3) ]]</td> <td class="text-end fw-bold">[[ currencySymbol ]][[ (parseFloat(item.price) * parseFloat(item.quantity)).toFixed(decimalPlaces) ]]</td>
<td class="text-end"> <td class="text-end">
<button class="btn btn-link text-danger p-0" @click="removeItem(index)"><i class="bi bi-x-circle"></i></button> <button class="btn btn-link text-danger p-0" @click="removeItem(index)"><i class="bi bi-x-circle"></i></button>
</td> </td>
@ -107,7 +107,7 @@
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mb-2">
<span class="text-muted">{% trans "Subtotal" %}</span> <span class="text-muted">{% trans "Subtotal" %}</span>
<span class="fw-bold">[[ currencySymbol ]][[ subtotal.toFixed(3) ]]</span> <span class="fw-bold">[[ currencySymbol ]][[ subtotal.toFixed(decimalPlaces) ]]</span>
</div> </div>
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mb-2">
@ -121,7 +121,7 @@
<div class="d-flex justify-content-between mb-4"> <div class="d-flex justify-content-between mb-4">
<h4 class="fw-bold mb-0">{% trans "Grand Total" %}</h4> <h4 class="fw-bold mb-0">{% trans "Grand Total" %}</h4>
<h4 class="fw-bold text-primary mb-0">[[ currencySymbol ]][[ grandTotal.toFixed(3) ]]</h4> <h4 class="fw-bold text-primary mb-0">[[ currencySymbol ]][[ grandTotal.toFixed(decimalPlaces) ]]</h4>
</div> </div>
<!-- Payment Details --> <!-- Payment Details -->
@ -173,6 +173,7 @@
<!-- Vue.js 3 --> <!-- Vue.js 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
{% localize off %}
<script> <script>
const { createApp } = Vue; const { createApp } = Vue;
@ -183,12 +184,12 @@
products: [ products: [
{% for p in products %} {% for p in products %}
{ {
id: {{ p.id }}, id: {{ p.id|default:0 }},
name_en: "{{ p.name_en }}", name_en: "{{ p.name_en|escapejs }}",
name_ar: "{{ p.name_ar }}", name_ar: "{{ p.name_ar|escapejs }}",
sku: "{{ p.sku }}", sku: "{{ p.sku|escapejs }}",
price: {{ p.price }}, price: {{ p.price|default:0 }},
stock: {{ p.stock_quantity }} stock: {{ p.stock_quantity|default:0 }}
}, },
{% endfor %} {% endfor %}
], ],
@ -196,14 +197,15 @@
filteredProducts: [], filteredProducts: [],
cart: {{ cart_json|safe }}, cart: {{ cart_json|safe }},
customerId: '{{ sale.customer_id|default:"" }}', customerId: '{{ sale.customer_id|default:"" }}',
invoiceNumber: '{{ sale.invoice_number|default:"" }}', invoiceNumber: '{{ sale.invoice_number|escapejs|default:"" }}',
paymentType: '{{ sale.payment_type }}', paymentType: '{{ sale.payment_type|escapejs }}',
paymentMethodId: '{{ payment_method_id }}', paymentMethodId: '{{ payment_method_id|default:"" }}',
paidAmount: {{ sale.paid_amount|default:0 }}, paidAmount: {{ sale.paid_amount|default:0 }},
discount: {{ sale.discount|default:0 }}, discount: {{ sale.discount|default:0 }},
dueDate: '{{ sale.due_date|date:"Y-m-d" }}', dueDate: '{{ sale.due_date|date:"Y-m-d" }}',
notes: `{{ sale.notes|escapejs }}`, notes: `{{ sale.notes|escapejs }}`,
currencySymbol: '{{ site_settings.currency_symbol }}', currencySymbol: '{{ site_settings.currency_symbol|escapejs }}',
decimalPlaces: {{ decimal_places|default:3 }},
isProcessing: false isProcessing: false
} }
}, },
@ -217,7 +219,7 @@
}, },
methods: { methods: {
calculateTotal() { calculateTotal() {
// Computed properties handle this, but we can trigger other logic here if needed // Computed properties handle this
}, },
filterProducts() { filterProducts() {
if (this.searchQuery.length > 1) { if (this.searchQuery.length > 1) {
@ -251,6 +253,7 @@
this.cart.splice(index, 1); this.cart.splice(index, 1);
}, },
saveSale() { saveSale() {
if (this.isProcessing) return;
this.isProcessing = true; if (!this.customerId && this.paymentType !== 'cash') { alert('{% trans "Credit or Partial payments are not allowed for Guest customers." %}'); this.isProcessing = false; return; } this.isProcessing = true; if (!this.customerId && this.paymentType !== 'cash') { alert('{% trans "Credit or Partial payments are not allowed for Guest customers." %}'); this.isProcessing = false; return; }
let actualPaidAmount = 0; let actualPaidAmount = 0;
@ -282,6 +285,7 @@
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
}, },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}) })
@ -303,4 +307,5 @@
} }
}).mount('#saleApp'); }).mount('#saleApp');
</script> </script>
{% endlocalize %}
{% endblock %} {% endblock %}

View File

@ -1,5 +1,5 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load i18n static %} {% load i18n static l10n %}
{% block title %}{% trans "POS" %} | {{ site_settings.business_name }}{% endblock %} {% block title %}{% trans "POS" %} | {{ site_settings.business_name }}{% endblock %}
@ -146,7 +146,7 @@
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-2" id="productGrid"> <div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-2" id="productGrid">
{% for product in products %} {% for product in products %}
<div class="col product-item" data-category="{{ product.category.id }}" data-name-en="{{ product.name_en|lower }}" data-name-ar="{{ product.name_ar }}"> <div class="col product-item" data-category="{{ product.category.id }}" data-name-en="{{ product.name_en|lower }}" data-name-ar="{{ product.name_ar }}">
<div class="card h-100 shadow-sm product-card p-1" onclick="addToCart({{ product.id }}, '{{ product.name_en|escapejs }}', '{{ product.name_ar|escapejs }}', {{ product.price }})"> <div class="card h-100 shadow-sm product-card p-1" onclick="addToCart({{ product.id|unlocalize }}, '{{ product.name_en|escapejs }}', '{{ product.name_ar|escapejs }}', {{ product.price|unlocalize }})">
{% if product.image %} {% if product.image %}
<img src="{{ product.image.url }}" class="card-img-top rounded-3" alt="{{ product.name_en }}" style="height: 80px; object-fit: cover;"> <img src="{{ product.image.url }}" class="card-img-top rounded-3" alt="{{ product.name_en }}" style="height: 80px; object-fit: cover;">
{% else %} {% else %}
@ -351,7 +351,7 @@
<div class="col"> <div class="col">
<button class="btn btn-outline-primary payment-method-btn w-100 py-2 py-md-3 {% if forloop.first %}active{% endif %}" <button class="btn btn-outline-primary payment-method-btn w-100 py-2 py-md-3 {% if forloop.first %}active{% endif %}"
data-id="{{ method.id }}" data-name-en="{{ method.name_en|lower }}" data-id="{{ method.id }}" data-name-en="{{ method.name_en|lower }}"
onclick="selectPaymentMethod(this, '{{ method.id }}')"> onclick="selectPaymentMethod(this, '{{ method.id|unlocalize }}')">
<span class="d-md-block">{{ method.name_ar }}</span> <span class="d-md-block">{{ method.name_ar }}</span>
<small class="fw-normal d-none d-md-block">{{ method.name_en }}</small> <small class="fw-normal d-none d-md-block">{{ method.name_en }}</small>
</button> </button>
@ -493,13 +493,14 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{% localize off %}
<script> <script>
let cart = []; let cart = [];
let lastSaleData = null; let lastSaleData = null;
let selectedPaymentMethodId = null; let selectedPaymentMethodId = null;
let customerLoyalty = null; let customerLoyalty = null;
const lang = '{{ LANGUAGE_CODE }}'; const lang = '{{ LANGUAGE_CODE|escapejs }}';
const currency = '{{ site_settings.currency_symbol }}'; const currency = '{{ site_settings.currency_symbol|escapejs }}';
const decimalPlaces = {{ site_settings.decimal_places|default:3 }}; const decimalPlaces = {{ site_settings.decimal_places|default:3 }};
const loyaltyEnabled = {{ settings.loyalty_enabled|yesno:"true,false" }}; const loyaltyEnabled = {{ settings.loyalty_enabled|yesno:"true,false" }};
@ -743,6 +744,7 @@
} }
function processPayment() { function processPayment() {
if (document.getElementById("confirmPaymentBtn").disabled && document.getElementById("confirmPaymentBtn").innerText === "{% trans "Processing..." %}") return;
const confirmBtn = document.getElementById('confirmPaymentBtn'); const confirmBtn = document.getElementById('confirmPaymentBtn');
const originalText = confirmBtn.innerText; const originalText = confirmBtn.innerText;
confirmBtn.disabled = true; confirmBtn.disabled = true;
@ -1070,4 +1072,5 @@
}); });
} }
</script> </script>
{% endlocalize %}
{% endblock %} {% endblock %}

View File

@ -1,5 +1,5 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load i18n %} {% load i18n l10n %}
{% block title %}{% trans "New Purchase" %} | {{ site_settings.business_name }}{% endblock %} {% block title %}{% trans "New Purchase" %} | {{ site_settings.business_name }}{% endblock %}
@ -77,7 +77,7 @@
<td> <td>
<input type="number" class="form-control form-control-sm text-center border-0 border-bottom rounded-0" v-model="item.quantity" @input="calculateTotal"> <input type="number" class="form-control form-control-sm text-center border-0 border-bottom rounded-0" v-model="item.quantity" @input="calculateTotal">
</td> </td>
<td class="text-end fw-bold">[[ currencySymbol ]][[ (item.price * item.quantity).toFixed(3) ]]</td> <td class="text-end fw-bold">[[ currencySymbol ]][[ (item.price * item.quantity).toFixed(decimalPlaces) ]]</td>
<td class="text-end"> <td class="text-end">
<button class="btn btn-link text-danger p-0" @click="removeItem(index)"><i class="bi bi-x-circle"></i></button> <button class="btn btn-link text-danger p-0" @click="removeItem(index)"><i class="bi bi-x-circle"></i></button>
</td> </td>
@ -102,14 +102,14 @@
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mb-2">
<span class="text-muted">{% trans "Subtotal" %}</span> <span class="text-muted">{% trans "Subtotal" %}</span>
<span class="fw-bold">[[ currencySymbol ]][[ subtotal.toFixed(3) ]]</span> <span class="fw-bold">[[ currencySymbol ]][[ subtotal.toFixed(decimalPlaces) ]]</span>
</div> </div>
<hr class="my-4"> <hr class="my-4">
<div class="d-flex justify-content-between mb-4"> <div class="d-flex justify-content-between mb-4">
<h4 class="fw-bold mb-0">{% trans "Grand Total" %}</h4> <h4 class="fw-bold mb-0">{% trans "Grand Total" %}</h4>
<h4 class="fw-bold text-primary mb-0">[[ currencySymbol ]][[ subtotal.toFixed(3) ]]</h4> <h4 class="fw-bold text-primary mb-0">[[ currencySymbol ]][[ subtotal.toFixed(decimalPlaces) ]]</h4>
</div> </div>
<!-- Payment Details --> <!-- Payment Details -->
@ -161,6 +161,7 @@
<!-- Vue.js 3 --> <!-- Vue.js 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
{% localize off %}
<script> <script>
const { createApp } = Vue; const { createApp } = Vue;
@ -171,12 +172,12 @@
products: [ products: [
{% for p in products %} {% for p in products %}
{ {
id: {{ p.id }}, id: {{ p.id|default:0 }},
name_en: "{{ p.name_en }}", name_en: "{{ p.name_en|escapejs }}",
name_ar: "{{ p.name_ar }}", name_ar: "{{ p.name_ar|escapejs }}",
sku: "{{ p.sku }}", sku: "{{ p.sku|escapejs }}",
cost_price: {{ p.cost_price }}, cost_price: {{ p.cost_price|default:0 }},
stock: {{ p.stock_quantity }} stock: {{ p.stock_quantity|default:0 }}
}, },
{% endfor %} {% endfor %}
], ],
@ -190,7 +191,8 @@
paidAmount: 0, paidAmount: 0,
dueDate: '', dueDate: '',
notes: '', notes: '',
currencySymbol: '{{ site_settings.currency_symbol }}', currencySymbol: '{{ site_settings.currency_symbol|escapejs }}',
decimalPlaces: {{ decimal_places|default:3 }},
isProcessing: false isProcessing: false
} }
}, },
@ -232,6 +234,7 @@
this.cart.splice(index, 1); this.cart.splice(index, 1);
}, },
savePurchase() { savePurchase() {
if (this.isProcessing) return;
this.isProcessing = true; this.isProcessing = true;
let actualPaidAmount = 0; let actualPaidAmount = 0;
@ -262,6 +265,7 @@
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
}, },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}) })
@ -283,4 +287,5 @@
} }
}).mount('#purchaseApp'); }).mount('#purchaseApp');
</script> </script>
{% endlocalize %}
{% endblock %} {% endblock %}

View File

@ -1,5 +1,5 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load i18n %} {% load i18n l10n %}
{% block title %}{% trans "New Purchase Return" %} | {{ site_settings.business_name }}{% endblock %} {% block title %}{% trans "New Purchase Return" %} | {{ site_settings.business_name }}{% endblock %}
@ -86,7 +86,7 @@
<td> <td>
<input type="number" class="form-control form-control-sm text-center border-0 border-bottom rounded-0" v-model="item.quantity" @input="calculateTotal"> <input type="number" class="form-control form-control-sm text-center border-0 border-bottom rounded-0" v-model="item.quantity" @input="calculateTotal">
</td> </td>
<td class="text-end fw-bold">[[ currencySymbol ]][[ (item.price * item.quantity).toFixed(3) ]]</td> <td class="text-end fw-bold">[[ currencySymbol ]][[ (item.price * item.quantity).toFixed(decimalPlaces) ]]</td>
<td class="text-end"> <td class="text-end">
<button class="btn btn-link text-danger p-0" @click="removeItem(index)"><i class="bi bi-x-circle"></i></button> <button class="btn btn-link text-danger p-0" @click="removeItem(index)"><i class="bi bi-x-circle"></i></button>
</td> </td>
@ -111,7 +111,7 @@
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mb-2">
<span class="text-muted">{% trans "Total Amount" %}</span> <span class="text-muted">{% trans "Total Amount" %}</span>
<h4 class="fw-bold text-primary mb-0">[[ currencySymbol ]][[ subtotal.toFixed(3) ]]</h4> <h4 class="fw-bold text-primary mb-0">[[ currencySymbol ]][[ subtotal.toFixed(decimalPlaces) ]]</h4>
</div> </div>
<hr class="my-4"> <hr class="my-4">
@ -141,6 +141,7 @@
<!-- Vue.js 3 --> <!-- Vue.js 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
{% localize off %}
<script> <script>
const { createApp } = Vue; const { createApp } = Vue;
@ -151,12 +152,12 @@
products: [ products: [
{% for p in products %} {% for p in products %}
{ {
id: {{ p.id }}, id: {{ p.id|default:0 }},
name_en: "{{ p.name_en }}", name_en: "{{ p.name_en|escapejs }}",
name_ar: "{{ p.name_ar }}", name_ar: "{{ p.name_ar|escapejs }}",
sku: "{{ p.sku }}", sku: "{{ p.sku|escapejs }}",
cost_price: {{ p.cost_price }}, cost_price: {{ p.cost_price|default:0 }},
stock: {{ p.stock_quantity }} stock: {{ p.stock_quantity|default:0 }}
}, },
{% endfor %} {% endfor %}
], ],
@ -167,7 +168,8 @@
purchaseId: '', purchaseId: '',
returnNumber: '', returnNumber: '',
notes: '', notes: '',
currencySymbol: '{{ site_settings.currency_symbol }}', currencySymbol: '{{ site_settings.currency_symbol|escapejs }}',
decimalPlaces: {{ decimal_places|default:3 }},
isProcessing: false isProcessing: false
} }
}, },
@ -209,6 +211,7 @@
this.cart.splice(index, 1); this.cart.splice(index, 1);
}, },
saveReturn() { saveReturn() {
if (this.isProcessing) return;
if (!confirm("{% trans 'Are you sure you want to process this purchase return? This will deduct from stock.' %}")) return; if (!confirm("{% trans 'Are you sure you want to process this purchase return? This will deduct from stock.' %}")) return;
this.isProcessing = true; this.isProcessing = true;
@ -231,6 +234,7 @@
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
}, },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}) })
@ -252,4 +256,5 @@
} }
}).mount('#returnApp'); }).mount('#returnApp');
</script> </script>
{% endlocalize %}
{% endblock %} {% endblock %}

View File

@ -1,5 +1,5 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load i18n %} {% load i18n l10n %}
{% block title %}{% trans "New Quotation" %} | {{ site_settings.business_name }}{% endblock %} {% block title %}{% trans "New Quotation" %} | {{ site_settings.business_name }}{% endblock %}
@ -77,7 +77,7 @@
<td> <td>
<input type="number" class="form-control form-control-sm text-center border-0 border-bottom rounded-0" v-model="item.quantity" @input="calculateTotal"> <input type="number" class="form-control form-control-sm text-center border-0 border-bottom rounded-0" v-model="item.quantity" @input="calculateTotal">
</td> </td>
<td class="text-end fw-bold">[[ currencySymbol ]][[ (item.price * item.quantity).toFixed(3) ]]</td> <td class="text-end fw-bold">[[ currencySymbol ]][[ (item.price * item.quantity).toFixed(decimalPlaces) ]]</td>
<td class="text-end"> <td class="text-end">
<button class="btn btn-link text-danger p-0" @click="removeItem(index)"><i class="bi bi-x-circle"></i></button> <button class="btn btn-link text-danger p-0" @click="removeItem(index)"><i class="bi bi-x-circle"></i></button>
</td> </td>
@ -111,7 +111,7 @@
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mb-2">
<span class="text-muted">{% trans "Subtotal" %}</span> <span class="text-muted">{% trans "Subtotal" %}</span>
<span class="fw-bold">[[ currencySymbol ]][[ subtotal.toFixed(3) ]]</span> <span class="fw-bold">[[ currencySymbol ]][[ subtotal.toFixed(decimalPlaces) ]]</span>
</div> </div>
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mb-2">
@ -125,7 +125,7 @@
<div class="d-flex justify-content-between mb-4"> <div class="d-flex justify-content-between mb-4">
<h4 class="fw-bold mb-0">{% trans "Grand Total" %}</h4> <h4 class="fw-bold mb-0">{% trans "Grand Total" %}</h4>
<h4 class="fw-bold text-primary mb-0">[[ currencySymbol ]][[ grandTotal.toFixed(3) ]]</h4> <h4 class="fw-bold text-primary mb-0">[[ currencySymbol ]][[ grandTotal.toFixed(decimalPlaces) ]]</h4>
</div> </div>
<div class="mb-4"> <div class="mb-4">
@ -153,6 +153,7 @@
<!-- Vue.js 3 --> <!-- Vue.js 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
{% localize off %}
<script> <script>
const { createApp } = Vue; const { createApp } = Vue;
@ -163,12 +164,12 @@
products: [ products: [
{% for p in products %} {% for p in products %}
{ {
id: {{ p.id }}, id: {{ p.id|default:0 }},
name_en: "{{ p.name_en }}", name_en: "{{ p.name_en|escapejs }}",
name_ar: "{{ p.name_ar }}", name_ar: "{{ p.name_ar|escapejs }}",
sku: "{{ p.sku }}", sku: "{{ p.sku|escapejs }}",
price: {{ p.price }}, price: {{ p.price|default:0 }},
stock: {{ p.stock_quantity }} stock: {{ p.stock_quantity|default:0 }}
}, },
{% endfor %} {% endfor %}
], ],
@ -181,7 +182,8 @@
validUntil: '', validUntil: '',
termsAndConditions: '1. Prices are valid for 7 days.\n2. Delivery within 3-5 working days.\n3. Payment: 50% advance, 50% on delivery.', termsAndConditions: '1. Prices are valid for 7 days.\n2. Delivery within 3-5 working days.\n3. Payment: 50% advance, 50% on delivery.',
notes: '', notes: '',
currencySymbol: '{{ site_settings.currency_symbol }}', currencySymbol: '{{ site_settings.currency_symbol|escapejs }}',
decimalPlaces: {{ decimal_places|default:3 }},
isProcessing: false isProcessing: false
} }
}, },
@ -226,6 +228,7 @@
this.cart.splice(index, 1); this.cart.splice(index, 1);
}, },
saveQuotation() { saveQuotation() {
if (this.isProcessing) return;
this.isProcessing = true; this.isProcessing = true;
const payload = { const payload = {
@ -248,6 +251,7 @@
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
}, },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}) })
@ -269,4 +273,5 @@
} }
}).mount('#quotationApp'); }).mount('#quotationApp');
</script> </script>
{% endlocalize %}
{% endblock %} {% endblock %}

View File

@ -1,5 +1,5 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load i18n %} {% load i18n l10n %}
{% block title %}{% trans "New Sales Return" %} | {{ site_settings.business_name }}{% endblock %} {% block title %}{% trans "New Sales Return" %} | {{ site_settings.business_name }}{% endblock %}
@ -86,7 +86,7 @@
<td> <td>
<input type="number" class="form-control form-control-sm text-center border-0 border-bottom rounded-0" v-model="item.quantity" @input="calculateTotal"> <input type="number" class="form-control form-control-sm text-center border-0 border-bottom rounded-0" v-model="item.quantity" @input="calculateTotal">
</td> </td>
<td class="text-end fw-bold">[[ currencySymbol ]][[ (item.price * item.quantity).toFixed(3) ]]</td> <td class="text-end fw-bold">[[ currencySymbol ]][[ (item.price * item.quantity).toFixed(decimalPlaces) ]]</td>
<td class="text-end"> <td class="text-end">
<button class="btn btn-link text-danger p-0" @click="removeItem(index)"><i class="bi bi-x-circle"></i></button> <button class="btn btn-link text-danger p-0" @click="removeItem(index)"><i class="bi bi-x-circle"></i></button>
</td> </td>
@ -111,7 +111,7 @@
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mb-2">
<span class="text-muted">{% trans "Total Amount" %}</span> <span class="text-muted">{% trans "Total Amount" %}</span>
<h4 class="fw-bold text-primary mb-0">[[ currencySymbol ]][[ subtotal.toFixed(3) ]]</h4> <h4 class="fw-bold text-primary mb-0">[[ currencySymbol ]][[ subtotal.toFixed(decimalPlaces) ]]</h4>
</div> </div>
<hr class="my-4"> <hr class="my-4">
@ -141,6 +141,7 @@
<!-- Vue.js 3 --> <!-- Vue.js 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
{% localize off %}
<script> <script>
const { createApp } = Vue; const { createApp } = Vue;
@ -151,12 +152,12 @@
products: [ products: [
{% for p in products %} {% for p in products %}
{ {
id: {{ p.id }}, id: {{ p.id|default:0 }},
name_en: "{{ p.name_en }}", name_en: "{{ p.name_en|escapejs }}",
name_ar: "{{ p.name_ar }}", name_ar: "{{ p.name_ar|escapejs }}",
sku: "{{ p.sku }}", sku: "{{ p.sku|escapejs }}",
price: {{ p.price }}, price: {{ p.price|default:0 }},
stock: {{ p.stock_quantity }} stock: {{ p.stock_quantity|default:0 }}
}, },
{% endfor %} {% endfor %}
], ],
@ -167,7 +168,8 @@
saleId: '', saleId: '',
returnNumber: '', returnNumber: '',
notes: '', notes: '',
currencySymbol: '{{ site_settings.currency_symbol }}', currencySymbol: '{{ site_settings.currency_symbol|escapejs }}',
decimalPlaces: {{ decimal_places|default:3 }},
isProcessing: false isProcessing: false
} }
}, },
@ -209,6 +211,7 @@
this.cart.splice(index, 1); this.cart.splice(index, 1);
}, },
saveReturn() { saveReturn() {
if (this.isProcessing) return;
if (!confirm("{% trans 'Are you sure you want to process this return? This will update stock.' %}")) return; if (!confirm("{% trans 'Are you sure you want to process this return? This will update stock.' %}")) return;
this.isProcessing = true; this.isProcessing = true;
@ -231,6 +234,7 @@
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
}, },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}) })
@ -252,4 +256,5 @@
} }
}).mount('#returnApp'); }).mount('#returnApp');
</script> </script>
{% endlocalize %}
{% endblock %} {% endblock %}

View File

@ -40,6 +40,11 @@
<i class="bi bi-star me-2"></i>{% trans "Loyalty System" %} <i class="bi bi-star me-2"></i>{% trans "Loyalty System" %}
</button> </button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-bold px-4" id="whatsapp-tab" data-bs-toggle="pill" data-bs-target="#whatsapp" type="button" role="tab">
<i class="bi bi-whatsapp me-2"></i>{% trans "WhatsApp Gateway" %}
</button>
</li>
</ul> </ul>
<div class="tab-content" id="settingsTabsContent"> <div class="tab-content" id="settingsTabsContent">
@ -389,6 +394,82 @@
</div> </div>
</div> </div>
</div> </div>
<!-- WhatsApp Tab -->
<div class="tab-pane fade" id="whatsapp" role="tabpanel">
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm border-0 glassmorphism mb-4">
<div class="card-header bg-transparent border-0 py-3">
<h5 class="card-title mb-0 fw-bold">{% trans "Wablas WhatsApp Integration" %}</h5>
</div>
<div class="card-body">
<form method="post" action="{% url 'settings' %}">
{% csrf_token %}
<div class="row g-3">
<div class="col-md-12">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" name="wablas_enabled" {% if settings.wablas_enabled %}checked{% endif %}>
<label class="form-check-label fw-bold">{% trans "Enable WhatsApp Gateway" %}</label>
</div>
<p class="text-muted small">{% trans "When enabled, you can send automated messages, invoices, and alerts via WhatsApp using the Wablas gateway." %}</p>
</div>
<div class="col-md-12">
<label class="form-label fw-semibold">{% trans "Wablas API Token" %}</label>
<input type="text" name="wablas_token" class="form-control" value="{{ settings.wablas_token }}" placeholder="e.g. your_api_token_here">
<div class="form-text">{% trans "Get your token from your Wablas dashboard." %}</div>
</div>
<div class="col-md-12">
<label class="form-label fw-semibold">{% trans "Wablas Server URL" %}</label>
<input type="url" name="wablas_server_url" class="form-control" value="{{ settings.wablas_server_url }}" placeholder="https://console.wablas.com">
<div class="form-text">{% trans "Ensure it starts with https://. Example: https://console.wablas.com or your custom domain." %}</div>
</div>
</div>
<div class="mt-4 pt-3 border-top d-flex justify-content-end">
<button type="submit" class="btn btn-success px-4 py-2">
<i class="bi bi-whatsapp me-2"></i> {% trans "Save WhatsApp Settings" %}
</button>
</div>
</form>
</div>
</div>
<div class="card shadow-sm border-0 glassmorphism bg-light border-start border-4 border-success">
<div class="card-body">
<h6 class="fw-bold text-success"><i class="bi bi-info-circle me-2"></i>{% trans "How to use" %}</h6>
<ul class="mb-0 small text-muted">
<li>{% trans "Register an account at Wablas.com" %}</li>
<li>{% trans "Scan your WhatsApp QR code in the Wablas dashboard." %}</li>
<li>{% trans "Copy the API Token and Server URL into the fields above." %}</li>
<li>{% trans "Test by sending a sample message to your own number." %}</li>
</ul>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm border-0 glassmorphism mb-4">
<div class="card-header bg-transparent border-0 py-3">
<h5 class="card-title mb-0 fw-bold">{% trans "Test Connection" %}</h5>
</div>
<div class="card-body">
<p class="text-muted small">{% trans "Verify your WhatsApp gateway is working correctly." %}</p>
<div class="mb-3">
<label class="form-label small fw-bold">{% trans "Test Phone Number" %}</label>
<input type="text" id="test_phone" class="form-control form-control-sm" placeholder="e.g. 96891234567">
</div>
<button type="button" id="btn_test_whatsapp" class="btn btn-outline-success w-100 btn-sm">
<i class="bi bi-send me-1"></i> {% trans "Send Test Message" %}
</button>
<div id="test_result" class="mt-3 d-none"></div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
@ -508,6 +589,7 @@
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
}, },
body: JSON.stringify({ body: JSON.stringify({
name_en: nameEn, name_en: nameEn,
@ -556,8 +638,58 @@
} }
} }
document.getElementById('savePm').addEventListener('click', () => savePaymentMethod(false)); const savePmBtn = document.getElementById('savePm');
document.getElementById('saveAndAddAnotherPm').addEventListener('click', () => savePaymentMethod(true)); if (savePmBtn) savePmBtn.addEventListener('click', () => savePaymentMethod(false));
const saveAndAddAnotherPmBtn = document.getElementById('saveAndAddAnotherPm');
if (saveAndAddAnotherPmBtn) saveAndAddAnotherPmBtn.addEventListener('click', () => savePaymentMethod(true));
// WhatsApp Test
const btnTestWhatsapp = document.getElementById('btn_test_whatsapp');
if (btnTestWhatsapp) {
btnTestWhatsapp.addEventListener('click', async function() {
const phone = document.getElementById('test_phone').value;
const resultDiv = document.getElementById('test_result');
if (!phone) {
alert('{% trans "Please enter a test phone number" %}');
return;
}
btnTestWhatsapp.disabled = true;
btnTestWhatsapp.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> {% trans "Sending..." %}';
resultDiv.classList.add('d-none');
try {
const response = await fetch('{% url "test_whatsapp_connection" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({ phone: phone })
});
const data = await response.json();
resultDiv.classList.remove('d-none');
if (data.success) {
resultDiv.className = 'mt-3 alert alert-success small py-2';
resultDiv.innerHTML = '<i class="bi bi-check-circle me-1"></i>' + data.message;
} else {
resultDiv.className = 'mt-3 alert alert-danger small py-2';
resultDiv.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>' + data.error;
}
} catch (error) {
console.error('Error:', error);
resultDiv.classList.remove('d-none');
resultDiv.className = 'mt-3 alert alert-danger small py-2';
resultDiv.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i> {% trans "An error occurred." %}';
} finally {
btnTestWhatsapp.disabled = false;
btnTestWhatsapp.innerHTML = '<i class="bi bi-send me-1"></i> {% trans "Send Test Message" %}';
}
});
}
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -3,6 +3,42 @@
{% block title %}{% trans "User & Role Management" %} - {{ site_settings.business_name }}{% endblock %} {% block title %}{% trans "User & Role Management" %} - {{ site_settings.business_name }}{% endblock %}
{% block head %}
<style>
.permissions-container {
scrollbar-width: thin;
scrollbar-color: #dee2e6 #f8f9fa;
}
.permissions-container::-webkit-scrollbar {
width: 6px;
}
.permissions-container::-webkit-scrollbar-track {
background: #f8f9fa;
}
.permissions-container::-webkit-scrollbar-thumb {
background-color: #dee2e6;
border-radius: 10px;
}
.permissions-table thead th {
position: sticky;
top: 0;
z-index: 10;
background-color: #f8f9fa !important;
box-shadow: inset 0 -1px 0 rgba(0,0,0,.1);
padding-top: 1rem;
padding-bottom: 1rem;
}
.permissions-table tbody tr:hover {
background-color: rgba(0, 123, 255, 0.02);
}
.form-check-input {
cursor: pointer;
width: 1.25em;
height: 1.25em;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h4 mb-0 fw-bold">{% trans "User & Role Management" %}</h2> <h2 class="h4 mb-0 fw-bold">{% trans "User & Role Management" %}</h2>
@ -138,7 +174,7 @@
data-id="{{ g.id }}" data-id="{{ g.id }}"
data-name="{{ g.name }}" data-name="{{ g.name }}"
data-bs-toggle="modal" data-bs-target="#editGroupModal"> data-bs-toggle="modal" data-bs-target="#editGroupModal">
<i class="bi bi-shield-lock"></i> Edit Perms <i class="bi bi-shield-lock me-1"></i> {% trans "Edit Permissions" %}
</button> </button>
<form action="{% url 'user_management' %}" method="post" class="d-inline" onsubmit="return confirm('{% trans "Are you sure you want to delete this group?" %}');"> <form action="{% url 'user_management' %}" method="post" class="d-inline" onsubmit="return confirm('{% trans "Are you sure you want to delete this group?" %}');">
{% csrf_token %} {% csrf_token %}
@ -260,17 +296,67 @@
<input type="text" name="name" class="form-control rounded-3" required placeholder="e.g., Inventory Manager"> <input type="text" name="name" class="form-control rounded-3" required placeholder="e.g., Inventory Manager">
</div> </div>
<h6 class="fw-bold mb-3 small text-uppercase text-muted">{% trans "Assign Permissions" %}</h6> <h6 class="fw-bold mb-3 small text-uppercase text-muted">{% trans "Assign Permissions" %}</h6>
<div class="row g-3" style="max-height: 400px; overflow-y: auto;"> <div class="permissions-container px-2" style="max-height: 500px; overflow-y: auto;">
{% for perm in permissions %} <div class="table-responsive">
<div class="col-md-6 col-lg-4"> <table class="table table-hover align-middle mb-0 permissions-table">
<div class="form-check card p-2 border-light shadow-none"> <thead class="bg-light">
<input class="form-check-input ms-0 me-2" type="checkbox" name="permissions" value="{{ perm.id }}" id="perm_{{ perm.id }}"> <tr class="small text-uppercase fw-bold text-muted">
<label class="form-check-label small d-block" for="perm_{{ perm.id }}"> <th class="ps-3" style="width: 40%;">{% trans "Module" %}</th>
<span class="text-primary fw-bold">{{ perm.content_type.app_label }}</span> | {{ perm.name }} <th class="text-center">{% trans "View" %}</th>
</label> <th class="text-center">{% trans "Add" %}</th>
</div> <th class="text-center">{% trans "Edit" %}</th>
<th class="text-center">{% trans "Delete" %}</th>
</tr>
</thead>
<tbody>
{% regroup permissions by content_type as perm_list %}
{% for g in perm_list %}
<tr>
<td class="ps-3 py-3">
<div class="fw-bold text-dark">{{ g.grouper.name|capfirst }}</div>
<div class="text-muted small">{{ g.grouper.app_label }}</div>
</td>
<td class="text-center">
{% for perm in g.list %}
{% if "view" in perm.codename %}
<div class="form-check d-inline-block">
<input class="form-check-input" type="checkbox" name="permissions" value="{{ perm.id }}" id="perm_{{ perm.id }}">
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
</td>
<td class="text-center">
{% for perm in g.list %}
{% if "add" in perm.codename %}
<div class="form-check d-inline-block">
<input class="form-check-input" type="checkbox" name="permissions" value="{{ perm.id }}" id="perm_{{ perm.id }}">
</div>
{% endif %}
{% endfor %}
</td>
<td class="text-center">
{% for perm in g.list %}
{% if "change" in perm.codename %}
<div class="form-check d-inline-block">
<input class="form-check-input" type="checkbox" name="permissions" value="{{ perm.id }}" id="perm_{{ perm.id }}">
</div>
{% endif %}
{% endfor %}
</td>
<td class="text-center">
{% for perm in g.list %}
{% if "delete" in perm.codename %}
<div class="form-check d-inline-block">
<input class="form-check-input" type="checkbox" name="permissions" value="{{ perm.id }}" id="perm_{{ perm.id }}">
</div>
{% endif %}
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
<div class="modal-footer border-0 pt-0"> <div class="modal-footer border-0 pt-0">
@ -300,17 +386,67 @@
<input type="text" name="name" id="editGroupName" class="form-control rounded-3" required> <input type="text" name="name" id="editGroupName" class="form-control rounded-3" required>
</div> </div>
<h6 class="fw-bold mb-3 small text-uppercase text-muted">{% trans "Permissions" %}</h6> <h6 class="fw-bold mb-3 small text-uppercase text-muted">{% trans "Permissions" %}</h6>
<div class="row g-3" style="max-height: 400px; overflow-y: auto;"> <div class="permissions-container px-2" style="max-height: 500px; overflow-y: auto;">
{% for perm in permissions %} <div class="table-responsive">
<div class="col-md-6 col-lg-4"> <table class="table table-hover align-middle mb-0 permissions-table">
<div class="form-check card p-2 border-light shadow-none"> <thead class="bg-light">
<input class="form-check-input edit-group-perm ms-0 me-2" type="checkbox" name="permissions" value="{{ perm.id }}" id="edit_perm_{{ perm.id }}"> <tr class="small text-uppercase fw-bold text-muted">
<label class="form-check-label small d-block" for="edit_perm_{{ perm.id }}"> <th class="ps-3" style="width: 40%;">{% trans "Module" %}</th>
<span class="text-primary fw-bold">{{ perm.content_type.app_label }}</span> | {{ perm.name }} <th class="text-center">{% trans "View" %}</th>
</label> <th class="text-center">{% trans "Add" %}</th>
</div> <th class="text-center">{% trans "Edit" %}</th>
<th class="text-center">{% trans "Delete" %}</th>
</tr>
</thead>
<tbody>
{% regroup permissions by content_type as perm_list %}
{% for g in perm_list %}
<tr>
<td class="ps-3 py-3">
<div class="fw-bold text-dark">{{ g.grouper.name|capfirst }}</div>
<div class="text-muted small">{{ g.grouper.app_label }}</div>
</td>
<td class="text-center">
{% for perm in g.list %}
{% if "view" in perm.codename %}
<div class="form-check d-inline-block">
<input class="form-check-input edit-group-perm" type="checkbox" name="permissions" value="{{ perm.id }}" id="edit_perm_{{ perm.id }}">
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
</td>
<td class="text-center">
{% for perm in g.list %}
{% if "add" in perm.codename %}
<div class="form-check d-inline-block">
<input class="form-check-input edit-group-perm" type="checkbox" name="permissions" value="{{ perm.id }}" id="edit_perm_{{ perm.id }}">
</div>
{% endif %}
{% endfor %}
</td>
<td class="text-center">
{% for perm in g.list %}
{% if "change" in perm.codename %}
<div class="form-check d-inline-block">
<input class="form-check-input edit-group-perm" type="checkbox" name="permissions" value="{{ perm.id }}" id="edit_perm_{{ perm.id }}">
</div>
{% endif %}
{% endfor %}
</td>
<td class="text-center">
{% for perm in g.list %}
{% if "delete" in perm.codename %}
<div class="form-check d-inline-block">
<input class="form-check-input edit-group-perm" type="checkbox" name="permissions" value="{{ perm.id }}" id="edit_perm_{{ perm.id }}">
</div>
{% endif %}
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
<div class="modal-footer border-0 pt-0"> <div class="modal-footer border-0 pt-0">

View File

@ -118,4 +118,6 @@ urlpatterns = [
path('settings/loyalty/edit/<int:pk>/', views.edit_loyalty_tier, name='edit_loyalty_tier'), path('settings/loyalty/edit/<int:pk>/', views.edit_loyalty_tier, name='edit_loyalty_tier'),
path('settings/loyalty/delete/<int:pk>/', views.delete_loyalty_tier, name='delete_loyalty_tier'), path('settings/loyalty/delete/<int:pk>/', views.delete_loyalty_tier, name='delete_loyalty_tier'),
path('api/customer-loyalty/<int:pk>/', views.get_customer_loyalty_api, name='get_customer_loyalty_api'), path('api/customer-loyalty/<int:pk>/', views.get_customer_loyalty_api, name='get_customer_loyalty_api'),
# WhatsApp
path('api/test-whatsapp/', views.test_whatsapp_connection, name='test_whatsapp_connection'),
] ]

View File

@ -62,3 +62,44 @@ def number_to_words_ar(number):
# if I can, or just use English for both if not specified. # if I can, or just use English for both if not specified.
# However, I'll try to provide a basic one if possible. # However, I'll try to provide a basic one if possible.
return number_to_words_en(number) # Fallback to EN for now to ensure it works. return number_to_words_en(number) # Fallback to EN for now to ensure it works.
import requests
def send_whatsapp_message(phone, message):
"""
Sends a WhatsApp message via Wablas gateway.
"""
from .models import SystemSetting
settings = SystemSetting.objects.first()
if not settings or not settings.wablas_enabled:
return False, "WhatsApp gateway is disabled."
if not settings.wablas_token or not settings.wablas_server_url:
return False, "Wablas configuration is incomplete."
# Clean phone number (remove non-digits)
phone = ''.join(filter(str.isdigit, str(phone)))
# Ensure URL is properly formatted
server_url = settings.wablas_server_url.rstrip('/')
url = f"{server_url}/api/send-message"
headers = {
"Authorization": settings.wablas_token
}
payload = {
"phone": phone,
"message": message
}
try:
response = requests.post(url, data=payload, headers=headers, timeout=10)
data = response.json()
if response.status_code == 200 and data.get('status') == True:
return True, "Message sent successfully."
else:
return False, data.get('message', 'Unknown error from Wablas.')
except Exception as e:
return False, str(e)

View File

@ -914,6 +914,12 @@ def settings_view(request):
settings.currency_per_point = request.POST.get('currency_per_point', 0.010) settings.currency_per_point = request.POST.get('currency_per_point', 0.010)
settings.min_points_to_redeem = request.POST.get('min_points_to_redeem', 100) settings.min_points_to_redeem = request.POST.get('min_points_to_redeem', 100)
# WhatsApp Settings
if "wablas_token" in request.POST:
settings.wablas_enabled = request.POST.get("wablas_enabled") == "on"
settings.wablas_token = request.POST.get("wablas_token", "")
settings.wablas_server_url = request.POST.get("wablas_server_url", "")
if 'logo' in request.FILES: if 'logo' in request.FILES:
settings.logo = request.FILES['logo'] settings.logo = request.FILES['logo']
@ -1336,7 +1342,8 @@ def user_management(request):
users = paginator.get_page(page_number) users = paginator.get_page(page_number)
groups = Group.objects.all().prefetch_related('permissions') groups = Group.objects.all().prefetch_related('permissions')
# Filter for relevant permissions (core and auth) # Filter for relevant permissions (core and auth)
permissions = Permission.objects.select_related('content_type').all().order_by('content_type__app_label', 'codename') excluded_apps = ['admin', 'auth', 'contenttypes', 'sessions']
permissions = Permission.objects.select_related('content_type').exclude(content_type__app_label__in=excluded_apps).order_by('content_type__app_label', 'content_type__model', 'codename')
if request.method == 'POST': if request.method == 'POST':
action = request.POST.get('action') action = request.POST.get('action')
@ -2215,3 +2222,24 @@ def cashflow_report(request):
'settings': settings 'settings': settings
} }
return render(request, 'core/cashflow_report.html', context) return render(request, 'core/cashflow_report.html', context)
@login_required
def test_whatsapp_connection(request):
"""
AJAX view to test the WhatsApp connection.
"""
if request.method == 'POST':
try:
data = json.loads(request.body)
phone = data.get('phone')
if not phone:
return JsonResponse({'success': False, 'error': _("Phone number is required.")})
from .utils import send_whatsapp_message
success, message = send_whatsapp_message(phone, _("Hello! This is a test message from your Meezan Smart Admin WhatsApp Gateway."))
return JsonResponse({'success': success, 'message': message})
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)})
return JsonResponse({'success': False, 'error': _("Invalid request method.")})

Binary file not shown.

View File

@ -3,7 +3,7 @@
# This file is distributed under the same license as the PACKAGE package. # This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
# #
#, fuzzy #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
@ -3092,7 +3092,7 @@ msgid "Group name already exists."
msgstr "" msgstr ""
#: core/views.py:1401 #: core/views.py:1401
#, fuzzy #
#| msgid "Completed" #| msgid "Completed"
msgid "Group deleted." msgid "Group deleted."
msgstr "مكتمل" msgstr "مكتمل"