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.dispatch import receiver
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", ""),
]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CSRF_TRUSTED_ORIGINS = [
origin for origin in [
os.getenv("HOST_FQDN", ""),
@ -37,11 +39,13 @@ 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
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "None"
CSRF_COOKIE_SAMESITE = "None"
LANGUAGE_COOKIE_SECURE = True
LANGUAGE_COOKIE_SAMESITE = "None"
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
@ -85,6 +89,7 @@ TEMPLATES = [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'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
'core.context_processors.project_context',
'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)
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):
return self.business_name

View File

@ -386,6 +386,27 @@
}
});
{% 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>
{% block scripts %}{% endblock %}
</body>

View File

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

View File

@ -1,5 +1,5 @@
{% extends 'base.html' %}
{% load i18n %}
{% load i18n l10n %}
{% block title %}{% trans "Edit Invoice" %} | {{ site_settings.business_name }}{% endblock %}
@ -82,7 +82,7 @@
<td>
<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 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">
<button class="btn btn-link text-danger p-0" @click="removeItem(index)"><i class="bi bi-x-circle"></i></button>
</td>
@ -107,7 +107,7 @@
<div class="d-flex justify-content-between mb-2">
<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 class="d-flex justify-content-between mb-2">
@ -121,7 +121,7 @@
<div class="d-flex justify-content-between mb-4">
<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>
<!-- Payment Details -->
@ -173,6 +173,7 @@
<!-- Vue.js 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
{% localize off %}
<script>
const { createApp } = Vue;
@ -183,12 +184,12 @@
products: [
{% for p in products %}
{
id: {{ p.id }},
name_en: "{{ p.name_en }}",
name_ar: "{{ p.name_ar }}",
sku: "{{ p.sku }}",
price: {{ p.price }},
stock: {{ p.stock_quantity }}
id: {{ p.id|default:0 }},
name_en: "{{ p.name_en|escapejs }}",
name_ar: "{{ p.name_ar|escapejs }}",
sku: "{{ p.sku|escapejs }}",
price: {{ p.price|default:0 }},
stock: {{ p.stock_quantity|default:0 }}
},
{% endfor %}
],
@ -196,14 +197,15 @@
filteredProducts: [],
cart: {{ cart_json|safe }},
customerId: '{{ sale.customer_id|default:"" }}',
invoiceNumber: '{{ sale.invoice_number|default:"" }}',
paymentType: '{{ sale.payment_type }}',
paymentMethodId: '{{ payment_method_id }}',
invoiceNumber: '{{ sale.invoice_number|escapejs|default:"" }}',
paymentType: '{{ sale.payment_type|escapejs }}',
paymentMethodId: '{{ payment_method_id|default:"" }}',
paidAmount: {{ sale.paid_amount|default:0 }},
discount: {{ sale.discount|default:0 }},
dueDate: '{{ sale.due_date|date:"Y-m-d" }}',
notes: `{{ sale.notes|escapejs }}`,
currencySymbol: '{{ site_settings.currency_symbol }}',
currencySymbol: '{{ site_settings.currency_symbol|escapejs }}',
decimalPlaces: {{ decimal_places|default:3 }},
isProcessing: false
}
},
@ -217,7 +219,7 @@
},
methods: {
calculateTotal() {
// Computed properties handle this, but we can trigger other logic here if needed
// Computed properties handle this
},
filterProducts() {
if (this.searchQuery.length > 1) {
@ -251,6 +253,7 @@
this.cart.splice(index, 1);
},
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; }
let actualPaidAmount = 0;
@ -282,6 +285,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify(payload)
})
@ -303,4 +307,5 @@
}
}).mount('#saleApp');
</script>
{% endlocalize %}
{% endblock %}

View File

@ -1,5 +1,5 @@
{% extends 'base.html' %}
{% load i18n static %}
{% load i18n static l10n %}
{% 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">
{% 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="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 %}
<img src="{{ product.image.url }}" class="card-img-top rounded-3" alt="{{ product.name_en }}" style="height: 80px; object-fit: cover;">
{% else %}
@ -351,7 +351,7 @@
<div class="col">
<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 }}"
onclick="selectPaymentMethod(this, '{{ method.id }}')">
onclick="selectPaymentMethod(this, '{{ method.id|unlocalize }}')">
<span class="d-md-block">{{ method.name_ar }}</span>
<small class="fw-normal d-none d-md-block">{{ method.name_en }}</small>
</button>
@ -493,13 +493,14 @@
{% endblock %}
{% block scripts %}
{% localize off %}
<script>
let cart = [];
let lastSaleData = null;
let selectedPaymentMethodId = null;
let customerLoyalty = null;
const lang = '{{ LANGUAGE_CODE }}';
const currency = '{{ site_settings.currency_symbol }}';
const lang = '{{ LANGUAGE_CODE|escapejs }}';
const currency = '{{ site_settings.currency_symbol|escapejs }}';
const decimalPlaces = {{ site_settings.decimal_places|default:3 }};
const loyaltyEnabled = {{ settings.loyalty_enabled|yesno:"true,false" }};
@ -743,6 +744,7 @@
}
function processPayment() {
if (document.getElementById("confirmPaymentBtn").disabled && document.getElementById("confirmPaymentBtn").innerText === "{% trans "Processing..." %}") return;
const confirmBtn = document.getElementById('confirmPaymentBtn');
const originalText = confirmBtn.innerText;
confirmBtn.disabled = true;
@ -1070,4 +1072,5 @@
});
}
</script>
{% endlocalize %}
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

@ -40,6 +40,11 @@
<i class="bi bi-star me-2"></i>{% trans "Loyalty System" %}
</button>
</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>
<div class="tab-content" id="settingsTabsContent">
@ -389,6 +394,82 @@
</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>
@ -508,6 +589,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({
name_en: nameEn,
@ -556,8 +638,58 @@
}
}
document.getElementById('savePm').addEventListener('click', () => savePaymentMethod(false));
document.getElementById('saveAndAddAnotherPm').addEventListener('click', () => savePaymentMethod(true));
const savePmBtn = document.getElementById('savePm');
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>
{% endblock %}

View File

@ -3,6 +3,42 @@
{% 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 %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h4 mb-0 fw-bold">{% trans "User & Role Management" %}</h2>
@ -138,7 +174,7 @@
data-id="{{ g.id }}"
data-name="{{ g.name }}"
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>
<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 %}
@ -260,17 +296,67 @@
<input type="text" name="name" class="form-control rounded-3" required placeholder="e.g., Inventory Manager">
</div>
<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;">
{% for perm in permissions %}
<div class="col-md-6 col-lg-4">
<div class="form-check card p-2 border-light shadow-none">
<input class="form-check-input ms-0 me-2" type="checkbox" name="permissions" value="{{ perm.id }}" id="perm_{{ perm.id }}">
<label class="form-check-label small d-block" for="perm_{{ perm.id }}">
<span class="text-primary fw-bold">{{ perm.content_type.app_label }}</span> | {{ perm.name }}
</label>
</div>
<div class="permissions-container px-2" style="max-height: 500px; overflow-y: auto;">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 permissions-table">
<thead class="bg-light">
<tr class="small text-uppercase fw-bold text-muted">
<th class="ps-3" style="width: 40%;">{% trans "Module" %}</th>
<th class="text-center">{% trans "View" %}</th>
<th class="text-center">{% trans "Add" %}</th>
<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>
{% endif %}
{% 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>
{% endfor %}
</div>
</div>
<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>
</div>
<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;">
{% for perm in permissions %}
<div class="col-md-6 col-lg-4">
<div class="form-check card p-2 border-light shadow-none">
<input class="form-check-input edit-group-perm ms-0 me-2" type="checkbox" name="permissions" value="{{ perm.id }}" id="edit_perm_{{ perm.id }}">
<label class="form-check-label small d-block" for="edit_perm_{{ perm.id }}">
<span class="text-primary fw-bold">{{ perm.content_type.app_label }}</span> | {{ perm.name }}
</label>
</div>
<div class="permissions-container px-2" style="max-height: 500px; overflow-y: auto;">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 permissions-table">
<thead class="bg-light">
<tr class="small text-uppercase fw-bold text-muted">
<th class="ps-3" style="width: 40%;">{% trans "Module" %}</th>
<th class="text-center">{% trans "View" %}</th>
<th class="text-center">{% trans "Add" %}</th>
<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>
{% endif %}
{% 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>
{% endfor %}
</div>
</div>
<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/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'),
# 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.
# 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.
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.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:
settings.logo = request.FILES['logo']
@ -1336,7 +1342,8 @@ def user_management(request):
users = paginator.get_page(page_number)
groups = Group.objects.all().prefetch_related('permissions')
# 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':
action = request.POST.get('action')
@ -2215,3 +2222,24 @@ def cashflow_report(request):
'settings': settings
}
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.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
@ -3092,7 +3092,7 @@ msgid "Group name already exists."
msgstr ""
#: core/views.py:1401
#, fuzzy
#
#| msgid "Completed"
msgid "Group deleted."
msgstr "مكتمل"