making all screens fit

This commit is contained in:
Flatlogic Bot 2026-02-02 17:09:26 +00:00
parent 0ae32328a7
commit 473f13fb08
17 changed files with 1059 additions and 55 deletions

View File

@ -6,7 +6,8 @@ from .models import (
Quotation, QuotationItem,
SaleReturn, SaleReturnItem,
PurchaseReturn, PurchaseReturnItem,
SystemSetting, PaymentMethod
SystemSetting, PaymentMethod, HeldSale,
LoyaltyTier, LoyaltyTransaction
)
@admin.register(Category)
@ -26,7 +27,7 @@ class ProductAdmin(admin.ModelAdmin):
@admin.register(Customer)
class CustomerAdmin(admin.ModelAdmin):
list_display = ('name', 'phone', 'email')
list_display = ('name', 'phone', 'email', 'loyalty_points', 'loyalty_tier')
search_fields = ('name', 'phone')
@admin.register(Supplier)
@ -72,3 +73,17 @@ class PurchaseReturnAdmin(admin.ModelAdmin):
@admin.register(SystemSetting)
class SystemSettingAdmin(admin.ModelAdmin):
list_display = ('business_name', 'phone', 'email', 'vat_number')
@admin.register(HeldSale)
class HeldSaleAdmin(admin.ModelAdmin):
list_display = ('id', 'customer', 'total_amount', 'created_at')
@admin.register(LoyaltyTier)
class LoyaltyTierAdmin(admin.ModelAdmin):
list_display = ('name_en', 'name_ar', 'min_points', 'point_multiplier', 'discount_percentage')
@admin.register(LoyaltyTransaction)
class LoyaltyTransactionAdmin(admin.ModelAdmin):
list_display = ('customer', 'transaction_type', 'points', 'created_at')
list_filter = ('transaction_type', 'created_at')
search_fields = ('customer__name',)

View File

@ -0,0 +1,78 @@
# Generated by Django 5.2.7 on 2026-02-02 16:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0013_heldsale'),
]
operations = [
migrations.CreateModel(
name='LoyaltyTier',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name_en', models.CharField(max_length=50, verbose_name='Name (English)')),
('name_ar', models.CharField(max_length=50, verbose_name='Name (Arabic)')),
('min_points', models.PositiveIntegerField(default=0, verbose_name='Minimum Points')),
('point_multiplier', models.DecimalField(decimal_places=2, default=1.0, max_digits=4, verbose_name='Point Multiplier')),
('discount_percentage', models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='Discount Percentage')),
('color_code', models.CharField(default='#6c757d', max_length=20, verbose_name='Color Code')),
],
),
migrations.AddField(
model_name='customer',
name='loyalty_points',
field=models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Loyalty Points'),
),
migrations.AddField(
model_name='sale',
name='loyalty_discount_amount',
field=models.DecimalField(decimal_places=3, default=0, max_digits=15, verbose_name='Loyalty Discount'),
),
migrations.AddField(
model_name='sale',
name='loyalty_points_redeemed',
field=models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Loyalty Points Redeemed'),
),
migrations.AddField(
model_name='systemsetting',
name='currency_per_point',
field=models.DecimalField(decimal_places=3, default=0.01, max_digits=10, verbose_name='Currency Value per Point'),
),
migrations.AddField(
model_name='systemsetting',
name='loyalty_enabled',
field=models.BooleanField(default=False, verbose_name='Enable Loyalty System'),
),
migrations.AddField(
model_name='systemsetting',
name='min_points_to_redeem',
field=models.PositiveIntegerField(default=100, verbose_name='Minimum Points to Redeem'),
),
migrations.AddField(
model_name='systemsetting',
name='points_per_currency',
field=models.DecimalField(decimal_places=2, default=1.0, max_digits=10, verbose_name='Points Earned per Currency Unit'),
),
migrations.AddField(
model_name='customer',
name='loyalty_tier',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='customers', to='core.loyaltytier'),
),
migrations.CreateModel(
name='LoyaltyTransaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('transaction_type', models.CharField(choices=[('earned', 'Earned'), ('redeemed', 'Redeemed'), ('adjusted', 'Adjusted')], max_length=20, verbose_name='Type')),
('points', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='Points')),
('notes', models.TextField(blank=True, verbose_name='Notes')),
('created_at', models.DateTimeField(auto_now_add=True)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='loyalty_transactions', to='core.customer')),
('sale', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='loyalty_transactions', to='core.sale')),
],
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 5.2.7 on 2026-02-02 16:58
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0014_loyaltytier_customer_loyalty_points_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(blank=True, null=True, upload_to='profile_pics/', verbose_name='Profile Picture')),
('phone', models.CharField(blank=True, max_length=20, verbose_name='Phone Number')),
('bio', models.TextField(blank=True, verbose_name='Bio')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -2,6 +2,8 @@ from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver
class Category(models.Model):
name_en = models.CharField(_("Name (English)"), max_length=100)
@ -42,15 +44,50 @@ class Product(models.Model):
def __str__(self):
return f"{self.name_en} ({self.sku})"
class LoyaltyTier(models.Model):
name_en = models.CharField(_("Name (English)"), max_length=50)
name_ar = models.CharField(_("Name (Arabic)"), max_length=50)
min_points = models.PositiveIntegerField(_("Minimum Points"), default=0)
point_multiplier = models.DecimalField(_("Point Multiplier"), max_digits=4, decimal_places=2, default=1.0)
discount_percentage = models.DecimalField(_("Discount Percentage"), max_digits=5, decimal_places=2, default=0)
color_code = models.CharField(_("Color Code"), max_length=20, default="#6c757d")
def __str__(self):
return f"{self.name_en} / {self.name_ar}"
class Customer(models.Model):
name = models.CharField(_("Name"), max_length=200)
phone = models.CharField(_("Phone"), max_length=20, blank=True)
email = models.EmailField(_("Email"), blank=True)
address = models.TextField(_("Address"), blank=True)
loyalty_points = models.DecimalField(_("Loyalty Points"), max_digits=15, decimal_places=2, default=0)
loyalty_tier = models.ForeignKey(LoyaltyTier, on_delete=models.SET_NULL, null=True, blank=True, related_name="customers")
def __str__(self):
return self.name
def update_tier(self):
tiers = LoyaltyTier.objects.filter(min_points__lte=self.loyalty_points).order_by('-min_points')
if tiers.exists():
self.loyalty_tier = tiers.first()
self.save()
class LoyaltyTransaction(models.Model):
TRANSACTION_TYPES = [
('earned', _('Earned')),
('redeemed', _('Redeemed')),
('adjusted', _('Adjusted')),
]
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name="loyalty_transactions")
sale = models.ForeignKey('Sale', on_delete=models.SET_NULL, null=True, blank=True, related_name="loyalty_transactions")
transaction_type = models.CharField(_("Type"), max_length=20, choices=TRANSACTION_TYPES)
points = models.DecimalField(_("Points"), max_digits=15, decimal_places=2)
notes = models.TextField(_("Notes"), blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.transaction_type} {self.points} for {self.customer.name}"
class Supplier(models.Model):
name = models.CharField(_("Name"), max_length=200)
contact_person = models.CharField(_("Contact Person"), max_length=200, blank=True)
@ -86,6 +123,8 @@ class Sale(models.Model):
paid_amount = models.DecimalField(_("Paid Amount"), max_digits=15, decimal_places=3, default=0)
balance_due = models.DecimalField(_("Balance Due"), max_digits=15, decimal_places=3, default=0)
discount = models.DecimalField(_("Discount"), max_digits=15, decimal_places=3, default=0)
loyalty_points_redeemed = models.DecimalField(_("Loyalty Points Redeemed"), max_digits=15, decimal_places=2, default=0)
loyalty_discount_amount = models.DecimalField(_("Loyalty Discount"), max_digits=15, decimal_places=3, default=0)
payment_type = models.CharField(_("Payment Type"), max_length=20, choices=PAYMENT_TYPE_CHOICES, default='cash')
status = models.CharField(_("Status"), max_length=20, choices=STATUS_CHOICES, default='paid')
due_date = models.DateField(_("Due Date"), null=True, blank=True)
@ -291,5 +330,31 @@ class SystemSetting(models.Model):
vat_number = models.CharField(_("VAT Number"), max_length=50, blank=True)
registration_number = models.CharField(_("Registration Number"), max_length=50, blank=True)
# Loyalty Settings
loyalty_enabled = models.BooleanField(_("Enable Loyalty System"), default=False)
points_per_currency = models.DecimalField(_("Points Earned per Currency Unit"), max_digits=10, decimal_places=2, default=1.0)
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)
def __str__(self):
return self.business_name
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
image = models.ImageField(_("Profile Picture"), upload_to="profile_pics/", blank=True, null=True)
phone = models.CharField(_("Phone Number"), max_length=20, blank=True)
bio = models.TextField(_("Bio"), blank=True)
def __str__(self):
return self.user.username
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
UserProfile.objects.create(user=instance)
@receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs):
if not hasattr(instance, 'profile'):
UserProfile.objects.create(user=instance)
instance.profile.save()

View File

@ -201,18 +201,39 @@
<div class="ms-auto d-flex align-items-center">
<div class="dropdown">
<button class="btn btn-light dropdown-toggle d-flex align-items-center" type="button" id="userDropdown" data-bs-toggle="dropdown">
<i class="bi bi-person-circle fs-5 me-2"></i>
<button class="btn btn-light dropdown-toggle d-flex align-items-center rounded-pill px-3" type="button" id="userDropdown" data-bs-toggle="dropdown">
{% if user.is_authenticated and user.profile.image %}
<img src="{{ user.profile.image.url }}" alt="{{ user.username }}" class="rounded-circle me-2" style="width: 28px; height: 28px; object-fit: cover;">
{% else %}
<i class="bi bi-person-circle fs-5 me-2"></i>
{% endif %}
<span>{% if user.is_authenticated %}{{ user.username }}{% else %}{% trans "Guest" %}{% endif %}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow border-0 mt-2">
<ul class="dropdown-menu dropdown-menu-end shadow-lg border-0 mt-2 rounded-4 overflow-hidden">
{% if user.is_authenticated %}
<li><a class="dropdown-item" href="{% url 'settings' %}"><i class="bi bi-person-gear me-2"></i> {% trans "Profile & Settings" %}</a></li>
<li><hr class="dropdown-divider"></li>
<li class="px-3 py-2 bg-light d-flex align-items-center">
{% if user.profile.image %}
<img src="{{ user.profile.image.url }}" alt="{{ user.username }}" class="rounded-circle me-3" style="width: 40px; height: 40px; object-fit: cover;">
{% else %}
<div class="bg-primary-subtle text-primary rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
<i class="bi bi-person-fill"></i>
</div>
{% endif %}
<div>
<div class="fw-bold small">{{ user.get_full_name|default:user.username }}</div>
<div class="text-muted" style="font-size: 0.7rem;">{{ user.email|truncatechars:20 }}</div>
</div>
</li>
<li><hr class="dropdown-divider m-0"></li>
<li><a class="dropdown-item py-2" href="{% url 'profile' %}"><i class="bi bi-person me-2"></i> {% trans "My Profile" %}</a></li>
{% if user.is_superuser or user.is_staff %}
<li><a class="dropdown-item py-2" href="{% url 'settings' %}"><i class="bi bi-gear me-2"></i> {% trans "System Settings" %}</a></li>
{% endif %}
<li><hr class="dropdown-divider m-0"></li>
<li>
<form action="{% url 'logout' %}" method="post">
{% csrf_token %}
<button type="submit" class="dropdown-item text-danger border-0 bg-transparent w-100 text-start">
<button type="submit" class="dropdown-item text-danger py-2 border-0 bg-transparent w-100 text-start">
<i class="bi bi-box-arrow-right me-2"></i> {% trans "Logout" %}
</button>
</form>
@ -235,8 +256,21 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
{% if user.is_authenticated %}
document.getElementById('sidebarCollapse').addEventListener('click', function () {
document.getElementById('sidebar').classList.toggle('active');
const sidebar = document.getElementById('sidebar');
const sidebarCollapse = document.getElementById('sidebarCollapse');
sidebarCollapse.addEventListener('click', function (e) {
e.stopPropagation();
sidebar.classList.toggle('active');
});
// Close sidebar when clicking outside on mobile
document.addEventListener('click', function (e) {
if (window.innerWidth <= 992 && sidebar.classList.contains('active')) {
if (!sidebar.contains(e.target) && e.target !== sidebarCollapse) {
sidebar.classList.remove('active');
}
}
});
{% endif %}
</script>

View File

@ -58,6 +58,14 @@
border-color: var(--bs-primary);
}
/* Loyalty Styles */
.loyalty-badge {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 10px;
font-weight: bold;
}
/* Invoice Print Styles */
@media print {
body * {
@ -114,13 +122,13 @@
{% endblock %}
{% block content %}
<div class="container-fluid px-4 no-print">
<div class="row g-4">
<div class="container-fluid px-2 px-md-4 no-print">
<div class="row g-3 g-lg-4">
<!-- Products Section -->
<div class="col-lg-8">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-4 gap-3">
<h4 class="fw-bold mb-0">{% trans "Point of Sale" %}</h4>
<div class="input-group w-50">
<div class="input-group w-100 w-md-50">
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search"></i></span>
<input type="text" id="productSearch" class="form-control border-start-0 shadow-none" placeholder="{% trans 'Search products...' %}">
</div>
@ -162,9 +170,14 @@
<!-- Cart Section -->
<div class="col-lg-4">
<div class="card border-0 shadow-sm rounded-4 cart-container">
<div class="card border-0 shadow-sm rounded-4 cart-container" id="posCart">
<div class="card-header bg-white border-0 pt-4 px-4 d-flex justify-content-between align-items-center">
<h5 class="fw-bold mb-0">{% trans "Current Order" %}</h5>
<div class="d-flex align-items-center">
<button class="btn btn-link d-lg-none text-dark p-0 me-3" onclick="toggleMobileCart()">
<i class="bi bi-chevron-left fs-4"></i>
</button>
<h5 class="fw-bold mb-0">{% trans "Current Order" %}</h5>
</div>
<div class="d-flex gap-2 align-items-center">
<button class="btn btn-sm btn-outline-warning shadow-none position-relative" onclick="loadHeldSales()" data-bs-toggle="modal" data-bs-target="#heldSalesModal">
<i class="bi bi-clock-history"></i>
@ -177,8 +190,8 @@
</div>
<div class="px-4 mt-2">
<div class="d-flex gap-2">
<select id="customerSelect" class="form-select form-select-sm shadow-none">
<div class="d-flex gap-2 mb-2">
<select id="customerSelect" class="form-select form-select-sm shadow-none" onchange="onCustomerChange()">
<option value="">{% trans "Walking Customer" %}</option>
{% for customer in customers %}
<option value="{{ customer.id }}">{{ customer.name }}</option>
@ -188,6 +201,18 @@
<i class="bi bi-person-plus"></i>
</button>
</div>
<!-- Loyalty Info Display -->
<div id="loyaltyInfo" class="d-none bg-light p-2 rounded-3 mb-2 animate__animated animate__fadeIn">
<div class="d-flex justify-content-between align-items-center">
<span class="small fw-bold text-muted"><i class="bi bi-star-fill text-warning me-1"></i> {% trans "Loyalty" %}</span>
<span id="loyaltyTierBadge" class="loyalty-badge text-white"></span>
</div>
<div class="d-flex justify-content-between mt-1">
<span class="small text-muted">{% trans "Available Points" %}</span>
<span id="loyaltyPointsDisplay" class="small fw-bold">0.00</span>
</div>
</div>
</div>
<div class="card-body px-4 py-3 cart-items">
@ -200,7 +225,7 @@
</div>
</div>
<div class="card-footer bg-light border-0 p-4 rounded-bottom-4">
<div class="card-footer bg-light border-0 p-4 rounded-bottom-4 mt-auto">
<div class="d-flex justify-content-between mb-2">
<span class="small">{% trans "Subtotal" %}</span>
<span id="subtotalAmount" class="small">{{ site_settings.currency_symbol }}0.000</span>
@ -233,6 +258,13 @@
</div>
</div>
</div>
<!-- Mobile View Cart FAB -->
<button class="btn btn-primary mobile-cart-toggle shadow-lg" onclick="toggleMobileCart()">
<i class="bi bi-cart-fill me-2"></i>
<span>{% trans "View Cart" %}</span>
<span class="badge bg-white text-primary ms-2 rounded-pill" id="mobileCartCountBadge">0</span>
</button>
</div>
<!-- Add Customer Modal -->
@ -312,16 +344,18 @@
<div class="modal-body p-4">
<div class="row g-4">
<!-- Left: Payment Types -->
<div class="col-md-3 border-end">
<div class="col-md-3 border-md-end">
<label class="small fw-bold text-muted mb-2 d-block">{% trans "Payment Method" %}</label>
<div class="d-grid gap-2" id="paymentMethodButtons">
<div class="row g-2 row-cols-3 row-cols-md-1" id="paymentMethodButtons">
{% for method in payment_methods %}
<button class="btn btn-outline-primary payment-method-btn py-3 {% if forloop.first %}active{% endif %}"
data-id="{{ method.id }}" data-name-en="{{ method.name_en|lower }}"
onclick="selectPaymentMethod(this, '{{ method.id }}')">
{{ method.name_ar }}<br>
<small class="fw-normal">{{ method.name_en }}</small>
</button>
<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 }}')">
<span class="d-md-block">{{ method.name_ar }}</span>
<small class="fw-normal d-none d-md-block">{{ method.name_en }}</small>
</button>
</div>
{% endfor %}
</div>
</div>
@ -333,6 +367,19 @@
<h2 class="fw-bold mb-0 text-primary" id="modalTotalAmount">{{ site_settings.currency_symbol }}0.000</h2>
</div>
<!-- Loyalty Redemption Section -->
<div id="modalLoyaltySection" class="d-none mb-3 p-3 border rounded-4 bg-warning-soft">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="small fw-bold text-muted">{% trans "Redeem Points" %}</label>
<span class="small">{% trans "Max" %}: <span id="modalMaxPoints" class="fw-bold">0</span></span>
</div>
<div class="input-group">
<input type="number" id="loyaltyRedeemInput" class="form-control shadow-none" placeholder="0" oninput="calculateLoyaltyDiscount()">
<span class="input-group-text bg-white border-start-0 small text-success fw-bold" id="loyaltyValueDisplay">- {{ site_settings.currency_symbol }}0.000</span>
</div>
<small class="text-muted" style="font-size: 0.65rem;">{% trans "Points to spend for a discount" %}</small>
</div>
<div class="mb-3">
<label class="small fw-bold text-muted mb-1">{% trans "Cash Received" %}</label>
<input type="number" id="cashReceivedInput" class="form-control form-control-lg fw-bold text-center shadow-none"
@ -349,11 +396,11 @@
<div class="col-md-4">
<label class="small fw-bold text-muted mb-2 d-block">{% trans "Quick Cash" %}</label>
<div class="row g-2 mb-3">
<div class="col-6"><button class="btn btn-light w-100 py-3 fw-bold border" onclick="addCash(1)">1</button></div>
<div class="col-6"><button class="btn btn-light w-100 py-3 fw-bold border" onclick="addCash(5)">5</button></div>
<div class="col-6"><button class="btn btn-light w-100 py-3 fw-bold border" onclick="addCash(10)">10</button></div>
<div class="col-6"><button class="btn btn-light w-100 py-3 fw-bold border" onclick="addCash(20)">20</button></div>
<div class="col-12"><button class="btn btn-light w-100 py-3 fw-bold border" onclick="addCash(50)">50</button></div>
<div class="col-4 col-md-6"><button class="btn btn-light w-100 py-3 fw-bold border" onclick="addCash(1)">1</button></div>
<div class="col-4 col-md-6"><button class="btn btn-light w-100 py-3 fw-bold border" onclick="addCash(5)">5</button></div>
<div class="col-4 col-md-6"><button class="btn btn-light w-100 py-3 fw-bold border" onclick="addCash(10)">10</button></div>
<div class="col-4 col-md-6"><button class="btn btn-light w-100 py-3 fw-bold border" onclick="addCash(20)">20</button></div>
<div class="col-8 col-md-12"><button class="btn btn-light w-100 py-3 fw-bold border" onclick="addCash(50)">50</button></div>
</div>
<button class="btn btn-secondary w-100 mb-2 py-2" onclick="setExactAmount()">{% trans "Exact Amount" %}</button>
<button class="btn btn-danger w-100 py-2" onclick="clearCash()">{% trans "Clear Cash" %}</button>
@ -450,14 +497,22 @@
let cart = [];
let lastSaleData = null;
let selectedPaymentMethodId = null;
let customerLoyalty = null;
const lang = '{{ LANGUAGE_CODE }}';
const currency = '{{ site_settings.currency_symbol }}';
const decimalPlaces = {{ decimal_places|default:3 }};
const decimalPlaces = {{ site_settings.decimal_places|default:3 }};
const loyaltyEnabled = {{ settings.loyalty_enabled|yesno:"true,false" }};
function formatAmount(amount) {
return parseFloat(amount).toFixed(decimalPlaces);
}
function toggleMobileCart() {
const cart = document.getElementById('posCart');
cart.classList.toggle('show');
document.body.classList.toggle('cart-open');
}
function addToCart(id, nameEn, nameAr, price) {
const existing = cart.find(item => item.id === id);
if (existing) {
@ -512,6 +567,10 @@
const emptyMsg = document.getElementById('emptyCartMsg');
const payBtn = document.getElementById('payNowBtn');
const holdBtn = document.getElementById('holdBtn');
const mobileBadge = document.getElementById('mobileCartCountBadge');
const totalItems = cart.reduce((acc, item) => acc + item.quantity, 0);
mobileBadge.innerText = totalItems;
if (cart.length === 0) {
emptyMsg.classList.remove('d-none');
@ -559,6 +618,19 @@
document.getElementById('cashReceivedInput').value = '';
document.getElementById('balanceAmount').innerText = `${currency} ${formatAmount(0)}`;
// Reset loyalty redemption
document.getElementById('loyaltyRedeemInput').value = '';
document.getElementById('loyaltyValueDisplay').innerText = `- ${currency} ${formatAmount(0)}`;
// Show loyalty section if customer has points
const loyaltySection = document.getElementById('modalLoyaltySection');
if (customerLoyalty && customerLoyalty.points >= customerLoyalty.min_points_to_redeem) {
loyaltySection.classList.remove('d-none');
document.getElementById('modalMaxPoints').innerText = customerLoyalty.points;
} else {
loyaltySection.classList.add('d-none');
}
// Default to first payment method
const firstBtn = document.querySelector('.payment-method-btn');
if (firstBtn) {
@ -569,6 +641,57 @@
paymentModal.show();
}
function onCustomerChange() {
const customerId = document.getElementById('customerSelect').value;
const loyaltyInfo = document.getElementById('loyaltyInfo');
if (!customerId || !loyaltyEnabled) {
loyaltyInfo.classList.add('d-none');
customerLoyalty = null;
return;
}
fetch(`/api/customer-loyalty/${customerId}/`)
.then(response => response.json())
.then(data => {
if (data.success) {
customerLoyalty = data;
loyaltyInfo.classList.remove('d-none');
document.getElementById('loyaltyPointsDisplay').innerText = data.points.toFixed(2);
const badge = document.getElementById('loyaltyTierBadge');
if (data.tier) {
badge.innerText = lang === 'ar' ? data.tier.name_ar : data.tier.name_en;
badge.style.backgroundColor = data.tier.color;
badge.classList.remove('d-none');
} else {
badge.classList.add('d-none');
}
}
});
}
function calculateLoyaltyDiscount() {
if (!customerLoyalty) return;
let points = parseFloat(document.getElementById('loyaltyRedeemInput').value) || 0;
if (points > customerLoyalty.points) {
points = customerLoyalty.points;
document.getElementById('loyaltyRedeemInput').value = points;
}
const value = points * customerLoyalty.currency_per_point;
document.getElementById('loyaltyValueDisplay').innerText = `- ${currency} ${formatAmount(value)}`;
// Update Total Payable
const subtotal = cart.reduce((acc, item) => acc + item.line_total, 0);
const discount = parseFloat(document.getElementById('discountInput').value) || 0;
const totalAmount = Math.max(0, subtotal - discount - value);
document.getElementById('modalTotalAmount').innerText = `${currency} ${formatAmount(totalAmount)}`;
calculateBalance();
}
function selectPaymentMethod(btn, id) {
document.querySelectorAll('.payment-method-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
@ -591,7 +714,10 @@
function setExactAmount() {
const subtotal = cart.reduce((acc, item) => acc + item.line_total, 0);
const discount = parseFloat(document.getElementById('discountInput').value) || 0;
const totalAmount = Math.max(0, subtotal - discount);
const loyaltyRedeem = parseFloat(document.getElementById('loyaltyRedeemInput').value) || 0;
const loyaltyDiscount = customerLoyalty ? loyaltyRedeem * customerLoyalty.currency_per_point : 0;
const totalAmount = Math.max(0, subtotal - discount - loyaltyDiscount);
document.getElementById('cashReceivedInput').value = totalAmount.toFixed(decimalPlaces);
calculateBalance();
@ -605,7 +731,10 @@
function calculateBalance() {
const subtotal = cart.reduce((acc, item) => acc + item.line_total, 0);
const discount = parseFloat(document.getElementById('discountInput').value) || 0;
const totalAmount = Math.max(0, subtotal - discount);
const loyaltyRedeem = parseFloat(document.getElementById('loyaltyRedeemInput').value) || 0;
const loyaltyDiscount = customerLoyalty ? loyaltyRedeem * customerLoyalty.currency_per_point : 0;
const totalAmount = Math.max(0, subtotal - discount - loyaltyDiscount);
const received = parseFloat(document.getElementById('cashReceivedInput').value) || 0;
const balance = Math.max(0, received - totalAmount);
@ -621,7 +750,10 @@
const subtotal = cart.reduce((acc, item) => acc + item.line_total, 0);
const discount = parseFloat(document.getElementById('discountInput').value) || 0;
const totalAmount = Math.max(0, subtotal - discount);
const loyaltyRedeem = parseFloat(document.getElementById('loyaltyRedeemInput').value) || 0;
const loyaltyDiscount = customerLoyalty ? loyaltyRedeem * customerLoyalty.currency_per_point : 0;
const totalAmount = Math.max(0, subtotal - discount - loyaltyDiscount);
const data = {
customer_id: document.getElementById('customerSelect').value,
@ -630,6 +762,7 @@
total_amount: totalAmount,
paid_amount: totalAmount,
discount: discount,
loyalty_points_redeemed: loyaltyRedeem,
payment_type: 'cash'
};
@ -659,6 +792,8 @@
cart = [];
document.getElementById('discountInput').value = 0;
customerLoyalty = null;
document.getElementById('loyaltyInfo').classList.add('d-none');
renderCart();
// Show receipt modal
@ -745,6 +880,7 @@
const select = document.getElementById('customerSelect');
const option = new Option(data.name, data.id, true, true);
select.add(option);
onCustomerChange();
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('addCustomerModal'));
@ -785,6 +921,9 @@
if (data.success) {
clearCart(false);
updateHeldCount();
if (document.getElementById('posCart').classList.contains('show')) {
toggleMobileCart();
}
} else {
alert('Error holding sale: ' + data.error);
}
@ -862,11 +1001,17 @@
cart = data.items;
document.getElementById('customerSelect').value = data.customer_id || "";
renderCart();
onCustomerChange();
updateHeldCount();
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('heldSalesModal'));
modal.hide();
// On mobile, show the cart automatically when recalled
if (!document.getElementById('posCart').classList.contains('show') && window.innerWidth < 992) {
toggleMobileCart();
}
} else {
alert('Error recalling sale: ' + data.error);
}

View File

@ -0,0 +1,192 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "My Profile" %} - {{ site_settings.business_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row mb-4 align-items-center">
<div class="col-md-6">
<h1 class="h3 mb-0 text-gray-800">{% trans "My Profile" %}</h1>
<p class="text-muted">{% trans "Manage your personal information and account security." %}</p>
</div>
</div>
{% if messages %}
<div class="row">
<div class="col-12">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="row">
<div class="col-lg-4">
<!-- Profile Card -->
<div class="card shadow-sm border-0 rounded-4 glassmorphism mb-4">
<div class="card-body text-center py-5">
<div class="position-relative d-inline-block mb-4">
{% if user.profile.image %}
<img src="{{ user.profile.image.url }}" alt="{{ user.username }}" class="rounded-circle img-thumbnail shadow-sm" style="width: 150px; height: 150px; object-fit: cover;">
{% else %}
<div class="bg-primary-subtle text-primary rounded-circle d-flex align-items-center justify-content-center mx-auto shadow-sm" style="width: 150px; height: 150px;">
<i class="bi bi-person-fill" style="font-size: 5rem;"></i>
</div>
{% endif %}
</div>
<h4 class="fw-bold mb-1">{{ user.get_full_name|default:user.username }}</h4>
<p class="text-muted mb-3">{{ user.email|default:"No email provided" }}</p>
<div class="d-flex justify-content-center gap-2">
{% for group in user.groups.all %}
<span class="badge bg-primary-soft text-primary px-3 rounded-pill">{{ group.name }}</span>
{% empty %}
<span class="badge bg-secondary-soft text-secondary px-3 rounded-pill">{% trans "User" %}</span>
{% endfor %}
</div>
</div>
<div class="card-footer bg-transparent border-0 pb-4 px-4">
<div class="d-flex justify-content-between small text-muted mb-2">
<span>{% trans "Joined" %}</span>
<span class="fw-bold text-dark">{{ user.date_joined|date:"M Y" }}</span>
</div>
<div class="d-flex justify-content-between small text-muted">
<span>{% trans "Last Login" %}</span>
<span class="fw-bold text-dark">{{ user.last_login|date:"d M, H:i"|default:"Never" }}</span>
</div>
</div>
</div>
<!-- Security Tip -->
<div class="card shadow-sm border-0 rounded-4 glassmorphism bg-light">
<div class="card-body py-4">
<div class="d-flex align-items-center mb-3">
<div class="bg-warning-subtle text-warning p-2 rounded-3 me-3">
<i class="bi bi-shield-lock fs-4"></i>
</div>
<h6 class="fw-bold mb-0">{% trans "Security Tip" %}</h6>
</div>
<p class="small text-muted mb-0">
{% trans "Always use a strong, unique password for your account. Avoid using the same password across multiple sites." %}
</p>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card shadow-sm border-0 rounded-4 glassmorphism">
<div class="card-header bg-transparent border-0 py-4 px-4">
<ul class="nav nav-tabs nav-tabs-custom border-0" id="profileTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active fw-bold px-4 border-0" id="edit-tab" data-bs-toggle="tab" data-bs-target="#edit" type="button" role="tab">
<i class="bi bi-person-lines-fill me-2"></i>{% trans "Edit Profile" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-bold px-4 border-0" id="security-tab" data-bs-toggle="tab" data-bs-target="#security" type="button" role="tab">
<i class="bi bi-lock me-2"></i>{% trans "Security" %}
</button>
</li>
</ul>
</div>
<div class="card-body p-4">
<div class="tab-content" id="profileTabsContent">
<!-- Edit Profile Tab -->
<div class="tab-pane fade show active" id="edit" role="tabpanel">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="row g-4">
<div class="col-md-12">
<label class="form-label small fw-bold text-uppercase text-muted">{% trans "Profile Picture" %}</label>
<div class="d-flex align-items-center gap-3">
<input type="file" name="image" class="form-control" accept="image/*">
</div>
<div class="form-text">{% trans "Recommended: Square image, max 2MB." %}</div>
</div>
<div class="col-md-6">
<label class="form-label small fw-bold text-uppercase text-muted">{% trans "First Name" %}</label>
<input type="text" name="first_name" class="form-control rounded-3" value="{{ user.first_name }}">
</div>
<div class="col-md-6">
<label class="form-label small fw-bold text-uppercase text-muted">{% trans "Last Name" %}</label>
<input type="text" name="last_name" class="form-control rounded-3" value="{{ user.last_name }}">
</div>
<div class="col-md-6">
<label class="form-label small fw-bold text-uppercase text-muted">{% trans "Email Address" %}</label>
<input type="email" name="email" class="form-control rounded-3" value="{{ user.email }}">
</div>
<div class="col-md-6">
<label class="form-label small fw-bold text-uppercase text-muted">{% trans "Phone Number" %}</label>
<input type="text" name="phone" class="form-control rounded-3" value="{{ user.profile.phone }}">
</div>
<div class="col-12">
<label class="form-label small fw-bold text-uppercase text-muted">{% trans "Bio" %}</label>
<textarea name="bio" class="form-control rounded-3" rows="3" placeholder="{% trans "A little bit about yourself..." %}">{{ user.profile.bio }}</textarea>
</div>
</div>
<div class="mt-5 pt-3 border-top d-flex justify-content-end">
<button type="submit" class="btn btn-primary px-5 py-2 rounded-3 fw-bold">
<i class="bi bi-save me-2"></i> {% trans "Update Profile" %}
</button>
</div>
</form>
</div>
<!-- Security Tab -->
<div class="tab-pane fade" id="security" role="tabpanel">
<form method="post">
{% csrf_token %}
<div class="row g-4">
<div class="col-md-12">
<div class="alert alert-info border-0 rounded-4">
<i class="bi bi-info-circle-fill me-2"></i>
{% trans "Leave password fields blank if you don't want to change your password." %}
</div>
</div>
<div class="col-md-6">
<label class="form-label small fw-bold text-uppercase text-muted">{% trans "New Password" %}</label>
<input type="password" name="password" class="form-control rounded-3" minlength="8">
</div>
<div class="col-md-6">
<label class="form-label small fw-bold text-uppercase text-muted">{% trans "Confirm New Password" %}</label>
<input type="password" name="confirm_password" class="form-control rounded-3" minlength="8">
</div>
</div>
<div class="mt-5 pt-3 border-top d-flex justify-content-end">
<button type="submit" class="btn btn-primary px-5 py-2 rounded-3 fw-bold">
<i class="bi bi-key me-2"></i> {% trans "Change Password" %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<style>
.nav-tabs-custom .nav-link {
color: #6c757d;
background: transparent;
border-bottom: 3px solid transparent !important;
margin-bottom: -1px;
}
.nav-tabs-custom .nav-link.active {
color: var(--bs-primary);
border-bottom: 3px solid var(--bs-primary) !important;
background: transparent;
}
.bg-primary-soft { background-color: rgba(13, 110, 253, 0.1); }
.bg-secondary-soft { background-color: rgba(108, 117, 125, 0.1); }
</style>
{% endblock %}

View File

@ -7,7 +7,7 @@
<div class="row mb-4 align-items-center">
<div class="col-md-6">
<h1 class="h3 mb-0 text-gray-800">{% trans "System Settings" %}</h1>
<p class="text-muted">{% trans "Manage your business profile and payments." %}</p>
<p class="text-muted">{% trans "Manage your business profile, payments, and loyalty system." %}</p>
</div>
</div>
@ -35,6 +35,11 @@
<i class="bi bi-credit-card me-2"></i>{% trans "Payment Methods" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-bold px-4" id="loyalty-tab" data-bs-toggle="pill" data-bs-target="#loyalty" type="button" role="tab">
<i class="bi bi-star me-2"></i>{% trans "Loyalty System" %}
</button>
</li>
</ul>
<div class="tab-content" id="settingsTabsContent">
@ -104,6 +109,29 @@
<input type="number" name="decimal_places" class="form-control" value="{{ settings.decimal_places }}" min="0" max="5" required>
<div class="form-text">{% trans "For price display" %}</div>
</div>
<hr class="my-4">
<h5 class="fw-bold mb-3">{% trans "Loyalty Configuration" %}</h5>
<div class="col-md-6">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" name="loyalty_enabled" {% if settings.loyalty_enabled %}checked{% endif %}>
<label class="form-check-label fw-bold">{% trans "Enable Loyalty System" %}</label>
</div>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">{% trans "Minimum Points to Redeem" %}</label>
<input type="number" name="min_points_to_redeem" class="form-control" value="{{ settings.min_points_to_redeem }}">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">{% trans "Points per Currency Unit Spent" %}</label>
<input type="number" step="0.01" name="points_per_currency" class="form-control" value="{{ settings.points_per_currency }}">
<div class="form-text">{% trans "e.g., 1.0 means 1 point for every 1 OMR spent." %}</div>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">{% trans "Currency Value per Point (Redemption)" %}</label>
<input type="number" step="0.001" name="currency_per_point" class="form-control" value="{{ settings.currency_per_point }}">
<div class="form-text">{% trans "e.g., 0.010 means 100 points = 1 OMR." %}</div>
</div>
</div>
<div class="mt-4 pt-3 border-top d-flex justify-content-end">
@ -255,6 +283,159 @@
</div>
</div>
</div>
<!-- Loyalty Tab -->
<div class="tab-pane fade" id="loyalty" role="tabpanel">
<div class="card shadow-sm border-0 glassmorphism mb-4">
<div class="card-header bg-transparent border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0 fw-bold">{% trans "Loyalty Tiers" %}</h5>
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addTierModal">
<i class="bi bi-plus-lg me-1"></i> {% trans "Add Tier" %}
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th class="ps-4">{% trans "Tier Name" %}</th>
<th>{% trans "Min. Points" %}</th>
<th>{% trans "Multiplier" %}</th>
<th>{% trans "Discount" %}</th>
<th class="text-end pe-4">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for tier in loyalty_tiers %}
<tr>
<td class="ps-4">
<div class="d-flex align-items-center">
<div class="rounded-circle me-2" style="width: 15px; height: 15px; background-color: {{ tier.color_code }};"></div>
<span class="fw-bold">{{ tier.name_en }} / {{ tier.name_ar }}</span>
</div>
</td>
<td>{{ tier.min_points }}</td>
<td>{{ tier.point_multiplier }}x</td>
<td>{{ tier.discount_percentage }}%</td>
<td class="text-end pe-4">
<button class="btn btn-sm btn-light text-primary" data-bs-toggle="modal" data-bs-target="#editTierModal{{ tier.id }}">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-light text-danger" data-bs-toggle="modal" data-bs-target="#deleteTierModal{{ tier.id }}">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
<!-- Edit Tier Modal -->
<div class="modal fade" id="editTierModal{{ tier.id }}" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content border-0">
<form action="{% url 'edit_loyalty_tier' tier.id %}" method="post">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title fw-bold">{% trans "Edit Loyalty Tier" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">{% trans "Name (EN)" %}</label>
<input type="text" name="name_en" class="form-control" value="{{ tier.name_en }}" required>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">{% trans "Name (AR)" %}</label>
<input type="text" name="name_ar" class="form-control" value="{{ tier.name_ar }}" required>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">{% trans "Min. Points" %}</label>
<input type="number" name="min_points" class="form-control" value="{{ tier.min_points }}" required>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">{% trans "Point Multiplier" %}</label>
<input type="number" step="0.01" name="point_multiplier" class="form-control" value="{{ tier.point_multiplier }}" required>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">{% trans "Discount (%)" %}</label>
<input type="number" step="0.01" name="discount_percentage" class="form-control" value="{{ tier.discount_percentage }}" required>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">{% trans "Color Code" %}</label>
<input type="color" name="color_code" class="form-control form-control-color w-100" value="{{ tier.color_code }}">
</div>
</div>
</div>
<div class="modal-footer bg-light border-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
<button type="submit" class="btn btn-primary">{% trans "Save Changes" %}</button>
</div>
</form>
</div>
</div>
</div>
{% empty %}
<tr>
<td colspan="5" class="text-center py-5">
<div class="text-muted">
<i class="bi bi-star fs-1 d-block mb-3"></i>
{% trans "No loyalty tiers defined." %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Add Tier Modal -->
<div class="modal fade" id="addTierModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content border-0 shadow">
<form action="{% url 'add_loyalty_tier' %}" method="post">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title fw-bold">{% trans "Add Loyalty Tier" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">{% trans "Name (EN)" %}</label>
<input type="text" name="name_en" class="form-control" required placeholder="e.g. Gold">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">{% trans "Name (AR)" %}</label>
<input type="text" name="name_ar" class="form-control" required placeholder="e.g. ذهبي">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">{% trans "Min. Points" %}</label>
<input type="number" name="min_points" class="form-control" required value="0">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">{% trans "Point Multiplier" %}</label>
<input type="number" step="0.01" name="point_multiplier" class="form-control" required value="1.00">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">{% trans "Discount (%)" %}</label>
<input type="number" step="0.01" name="discount_percentage" class="form-control" required value="0">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">{% trans "Color Code" %}</label>
<input type="color" name="color_code" class="form-control form-control-color w-100" value="#6c757d">
</div>
</div>
</div>
<div class="modal-footer bg-light border-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
<button type="submit" class="btn btn-primary">{% trans "Save Tier" %}</button>
</div>
</form>
</div>
</div>
</div>
@ -337,8 +518,6 @@
const data = await response.json();
if (data.success) {
// Refresh table or append row (for simplicity, we suggest a reload or partial update)
// For a smooth experience, let's append the row
const tableBody = document.querySelector('#paymentMethodsTable tbody');
const noItems = document.getElementById('noPaymentMethods');
if (noItems) noItems.parentElement.remove();
@ -359,16 +538,14 @@
`;
tableBody.appendChild(newRow);
// Success Toast/Alert (Simplified)
alert('{% trans "Payment method added successfully!" %}');
if (stayOpen) {
// Clear fields
document.getElementById('addPmNameEn').value = '';
document.getElementById('addPmNameAr').value = '';
} else {
bootstrap.Modal.getInstance(document.getElementById('addPaymentModal')).hide();
window.location.reload(); // Reload to get modals for the new row
window.location.reload();
}
} else {
alert('Error: ' + data.error);

View File

@ -10,6 +10,7 @@ urlpatterns = [
path('purchases/', views.purchases, name='purchases'),
path('reports/', views.reports, name='reports'),
path('settings/', views.settings_view, name='settings'),
path('profile/', views.profile_view, name='profile'),
path('users/', views.user_management, name='user_management'),
path('api/group-details/<int:pk>/', views.group_details_api, name='group_details_api'),
@ -95,4 +96,10 @@ urlpatterns = [
path('settings/payment-methods/edit/<int:pk>/', views.edit_payment_method, name='edit_payment_method'),
path('settings/payment-methods/delete/<int:pk>/', views.delete_payment_method, name='delete_payment_method'),
path('api/add-payment-method-ajax/', views.add_payment_method_ajax, name='add_payment_method_ajax'),
# Loyalty
path('settings/loyalty/add/', views.add_loyalty_tier, name='add_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('api/customer-loyalty/<int:pk>/', views.get_customer_loyalty_api, name='get_customer_loyalty_api'),
]

View File

@ -1,3 +1,4 @@
import decimal
from django.contrib.auth.models import User, Group, Permission
from django.urls import reverse
import random
@ -14,7 +15,7 @@ from .models import (
SaleItem, SalePayment, SystemSetting,
Quotation, QuotationItem,
SaleReturn, SaleReturnItem, PurchaseReturn, PurchaseReturnItem,
PaymentMethod, HeldSale
PaymentMethod, HeldSale, LoyaltyTier, LoyaltyTransaction
)
import json
from datetime import timedelta
@ -90,6 +91,7 @@ def pos(request):
customers = Customer.objects.all()
categories = Category.objects.all()
payment_methods = PaymentMethod.objects.filter(is_active=True)
settings = SystemSetting.objects.first()
# Ensure at least Cash exists
if not payment_methods.exists():
@ -100,7 +102,8 @@ def pos(request):
'products': products,
'customers': customers,
'categories': categories,
'payment_methods': payment_methods
'payment_methods': payment_methods,
'settings': settings
}
return render(request, 'core/pos.html', context)
@ -306,10 +309,22 @@ def create_sale_api(request):
due_date = data.get('due_date')
notes = data.get('notes', '')
# Loyalty data
points_to_redeem = data.get('loyalty_points_redeemed', 0)
customer = None
if customer_id:
customer = Customer.objects.get(id=customer_id)
settings = SystemSetting.objects.first()
if not settings:
settings = SystemSetting.objects.create()
loyalty_discount = 0
if settings.loyalty_enabled and customer and points_to_redeem > 0:
if customer.loyalty_points >= points_to_redeem:
loyalty_discount = float(points_to_redeem) * float(settings.currency_per_point)
sale = Sale.objects.create(
customer=customer,
invoice_number=invoice_number,
@ -317,6 +332,8 @@ def create_sale_api(request):
paid_amount=paid_amount,
balance_due=float(total_amount) - float(paid_amount),
discount=discount,
loyalty_points_redeemed=points_to_redeem,
loyalty_discount_amount=loyalty_discount,
payment_type=payment_type,
due_date=due_date if due_date else None,
notes=notes,
@ -359,9 +376,36 @@ def create_sale_api(request):
product.stock_quantity -= int(item['quantity'])
product.save()
settings = SystemSetting.objects.first()
if not settings:
settings = SystemSetting.objects.create()
# Handle Loyalty Points
if settings.loyalty_enabled and customer:
# Earn Points
points_earned = float(total_amount) * float(settings.points_per_currency)
if customer.loyalty_tier:
points_earned *= float(customer.loyalty_tier.point_multiplier)
if points_earned > 0:
customer.loyalty_points += decimal.Decimal(str(points_earned))
LoyaltyTransaction.objects.create(
customer=customer,
sale=sale,
transaction_type='earned',
points=points_earned,
notes=f"Points earned from Sale #{sale.id}"
)
# Redeem Points
if points_to_redeem > 0:
customer.loyalty_points -= decimal.Decimal(str(points_to_redeem))
LoyaltyTransaction.objects.create(
customer=customer,
sale=sale,
transaction_type='redeemed',
points=-points_to_redeem,
notes=f"Points redeemed for Sale #{sale.id}"
)
customer.update_tier()
customer.save()
return JsonResponse({
'success': True,
@ -755,6 +799,12 @@ def settings_view(request):
settings.vat_number = request.POST.get('vat_number')
settings.registration_number = request.POST.get('registration_number')
# Loyalty Settings
settings.loyalty_enabled = request.POST.get('loyalty_enabled') == 'on'
settings.points_per_currency = request.POST.get('points_per_currency', 1.0)
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)
if 'logo' in request.FILES:
settings.logo = request.FILES['logo']
@ -763,10 +813,12 @@ def settings_view(request):
return redirect(reverse('settings') + '#profile')
payment_methods = PaymentMethod.objects.all()
loyalty_tiers = LoyaltyTier.objects.all().order_by('min_points')
return render(request, 'core/settings.html', {
'settings': settings,
'payment_methods': payment_methods,
'loyalty_tiers': loyalty_tiers
})
@login_required
@ -1315,7 +1367,6 @@ def add_customer_ajax(request):
return JsonResponse({'success': False, 'error': str(e)}, status=400)
return JsonResponse({'success': False, 'error': 'Invalid request'}, status=405)
@csrf_exempt
@login_required
def hold_sale_api(request):
if request.method == 'POST':
@ -1357,7 +1408,6 @@ def get_held_sales_api(request):
})
return JsonResponse({'success': True, 'held_sales': data})
@csrf_exempt
@login_required
def recall_held_sale_api(request, pk):
held_sale = get_object_or_404(HeldSale, pk=pk, created_by=request.user)
@ -1371,9 +1421,111 @@ def recall_held_sale_api(request, pk):
held_sale.delete()
return JsonResponse(data)
@csrf_exempt
@login_required
def delete_held_sale_api(request, pk):
held_sale = get_object_or_404(HeldSale, pk=pk, created_by=request.user)
held_sale.delete()
return JsonResponse({'success': True})
@login_required
def add_loyalty_tier(request):
if request.method == 'POST':
name_en = request.POST.get('name_en')
name_ar = request.POST.get('name_ar')
min_points = request.POST.get('min_points', 0)
multiplier = request.POST.get('point_multiplier', 1.0)
discount = request.POST.get('discount_percentage', 0)
color = request.POST.get('color_code', '#6c757d')
LoyaltyTier.objects.create(
name_en=name_en, name_ar=name_ar,
min_points=min_points, point_multiplier=multiplier,
discount_percentage=discount, color_code=color
)
messages.success(request, "Loyalty tier added successfully!")
return redirect(reverse('settings') + '#loyalty')
@login_required
def edit_loyalty_tier(request, pk):
tier = get_object_or_404(LoyaltyTier, pk=pk)
if request.method == 'POST':
tier.name_en = request.POST.get('name_en')
tier.name_ar = request.POST.get('name_ar')
tier.min_points = request.POST.get('min_points')
tier.point_multiplier = request.POST.get('point_multiplier')
tier.discount_percentage = request.POST.get('discount_percentage')
tier.color_code = request.POST.get('color_code')
tier.save()
messages.success(request, "Loyalty tier updated successfully!")
return redirect(reverse('settings') + '#loyalty')
@login_required
def delete_loyalty_tier(request, pk):
tier = get_object_or_404(LoyaltyTier, pk=pk)
tier.delete()
messages.success(request, "Loyalty tier deleted successfully!")
return redirect(reverse('settings') + '#loyalty')
@login_required
def get_customer_loyalty_api(request, pk):
customer = get_object_or_404(Customer, pk=pk)
settings = SystemSetting.objects.first()
tier_info = None
if customer.loyalty_tier:
tier_info = {
'name_en': customer.loyalty_tier.name_en,
'name_ar': customer.loyalty_tier.name_ar,
'multiplier': float(customer.loyalty_tier.point_multiplier),
'discount': float(customer.loyalty_tier.discount_percentage),
'color': customer.loyalty_tier.color_code
}
return JsonResponse({
'success': True,
'points': float(customer.loyalty_points),
'tier': tier_info,
'currency_per_point': float(settings.currency_per_point) if settings else 0.01,
'min_points_to_redeem': settings.min_points_to_redeem if settings else 100
})
@login_required
def profile_view(request):
"""
User Profile View
"""
if request.method == 'POST':
user = request.user
user.first_name = request.POST.get('first_name')
user.last_name = request.POST.get('last_name')
user.email = request.POST.get('email')
# Profile specific
profile = user.profile
profile.phone = request.POST.get('phone')
profile.bio = request.POST.get('bio')
if 'image' in request.FILES:
profile.image = request.FILES['image']
user.save()
profile.save()
# Password change
password = request.POST.get('password')
confirm_password = request.POST.get('confirm_password')
if password:
if password == confirm_password:
user.set_password(password)
user.save()
from django.contrib.auth import update_session_auth_hash
update_session_auth_hash(request, user)
messages.success(request, "Profile and password updated successfully!")
else:
messages.error(request, "Passwords do not match.")
else:
messages.success(request, "Profile updated successfully!")
return redirect('profile')
return render(request, 'core/profile.html')

View File

@ -194,3 +194,116 @@ body {
width: 100%;
}
}
/* Enhanced Responsiveness */
/* Container & Spacing Adjustments */
@media (max-width: 576px) {
main.p-4 {
padding: 1rem !important;
}
.hero-gradient {
padding: 1.5rem;
}
.top-navbar {
padding: 10px 15px;
}
h1, .h1 { font-size: 1.5rem; }
h2, .h2 { font-size: 1.25rem; }
h3, .h3 { font-size: 1.1rem; }
h4, .h4 { font-size: 1rem; }
}
/* Sidebar Overlay for Mobile */
@media (max-width: 992px) {
#sidebar {
box-shadow: 0 0 20px rgba(0,0,0,0.1);
}
#sidebar.active {
margin-inline-start: 0;
}
#sidebar:not(.active) {
margin-inline-start: calc(-1 * var(--sidebar-width));
}
/* Overlay effect when sidebar is active */
#sidebar.active::after {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0,0,0,0.2);
z-index: -1;
display: block;
}
}
/* Table Card Styling for Mobile */
@media (max-width: 768px) {
.table-responsive {
border-radius: 12px;
overflow: hidden;
}
.btn-sm {
padding: 0.4rem 0.8rem;
}
}
/* POS Specific Responsive Utilities */
.mobile-cart-toggle {
display: none;
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1030;
border-radius: 50px;
padding: 12px 24px;
box-shadow: 0 4px 15px rgba(46, 91, 255, 0.4);
}
[dir="rtl"] .mobile-cart-toggle {
right: auto;
left: 20px;
}
@media (max-width: 991px) {
.mobile-cart-toggle {
display: flex;
align-items: center;
}
.cart-container {
position: fixed !important;
top: 0 !important;
right: -100% !important;
width: 100% !important;
height: 100% !important;
z-index: 1040 !important;
transition: right 0.3s ease;
border-radius: 0 !important;
}
[dir="rtl"] .cart-container {
right: auto !important;
left: -100% !important;
transition: left 0.3s ease;
}
.cart-container.show {
right: 0 !important;
}
[dir="rtl"] .cart-container.show {
left: 0 !important;
}
/* Ensure body doesn't scroll when cart is open */
body.cart-open {
overflow: hidden;
}
}
/* Grid adjustments */
@media (max-width: 400px) {
#productGrid .col {
width: 100% !important;
}
}