Autosave: 20260206-043847
This commit is contained in:
parent
77e86e4c7b
commit
49c4d4dab1
BIN
assets/pasted-20260206-043257-ab1cccfb.png
Normal file
BIN
assets/pasted-20260206-043257-ab1cccfb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
277
core/templates/core/customer_display.html
Normal file
277
core/templates/core/customer_display.html
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
{% load static i18n l10n %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ LANGUAGE_CODE }}" dir="{% if LANGUAGE_CODE == 'ar' %}rtl{% else %}ltr{% endif %}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% trans "Customer Display" %} | {{ settings.business_name }}</title>
|
||||||
|
<!-- Bootstrap CSS -->
|
||||||
|
<link rel="stylesheet" href="{% static 'bootstrap/dist/css/bootstrap.min.css' %}">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bs-primary-rgb: 13, 110, 253;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.main-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.left-panel {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid #dee2e6;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.right-panel {
|
||||||
|
width: 40%;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cart-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
.cart-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #f1f3f5;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
.cart-item.new-item {
|
||||||
|
animation: highlight 1s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes highlight {
|
||||||
|
0% { background-color: rgba(var(--bs-primary-rgb), 0.1); }
|
||||||
|
100% { background-color: transparent; }
|
||||||
|
}
|
||||||
|
.item-name {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
.item-meta {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
.item-price {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.total-display {
|
||||||
|
background: var(--bs-primary);
|
||||||
|
color: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: auto;
|
||||||
|
box-shadow: 0 10px 20px rgba(var(--bs-primary-rgb), 0.2);
|
||||||
|
}
|
||||||
|
.welcome-screen {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: white;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
transition: opacity 0.5s;
|
||||||
|
}
|
||||||
|
/* UPDATED: display: none is safer than opacity for hiding overlay */
|
||||||
|
.welcome-screen.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Welcome / Idle Screen -->
|
||||||
|
<div id="welcomeScreen" class="welcome-screen">
|
||||||
|
<div class="text-center">
|
||||||
|
{% if settings.logo %}
|
||||||
|
<img src="{{ settings.logo.url }}" alt="Logo" class="mb-4" style="max-height: 150px;">
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-shop display-1 text-primary mb-4"></i>
|
||||||
|
{% endif %}
|
||||||
|
<h1 class="display-4 fw-bold text-primary">{{ settings.business_name }}</h1>
|
||||||
|
<p class="lead text-muted mt-3">{% trans "Welcome! We are ready to serve you." %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Transaction Screen -->
|
||||||
|
<div class="main-container">
|
||||||
|
<!-- Left: Cart Items -->
|
||||||
|
<div class="left-panel">
|
||||||
|
<h4 class="fw-bold mb-4 border-bottom pb-3">{% trans "Your Order" %}</h4>
|
||||||
|
<div class="cart-list" id="cartList">
|
||||||
|
<!-- Items injected via JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Totals & Ads -->
|
||||||
|
<div class="right-panel">
|
||||||
|
<div class="mb-auto w-100">
|
||||||
|
{% if settings.logo %}
|
||||||
|
<img src="{{ settings.logo.url }}" alt="Logo" class="mb-3" style="max-height: 80px;">
|
||||||
|
{% endif %}
|
||||||
|
<h5 class="fw-bold">{{ settings.business_name }}</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Big Total Box -->
|
||||||
|
<div class="total-display">
|
||||||
|
<div class="d-flex justify-content-between align-items-end mb-2">
|
||||||
|
<span class="h5 mb-0 opacity-75">{% trans "Total" %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="display-3 fw-bold" id="totalAmount">{{ settings.currency_symbol }} 0.000</div>
|
||||||
|
|
||||||
|
<div class="mt-3 pt-3 border-top border-white border-opacity-25 d-flex justify-content-between">
|
||||||
|
<span class="small">{% trans "Items" %}: <span id="itemCount">0</span></span>
|
||||||
|
<span class="small">{% trans "Tax" %}: <span id="taxAmount">0.000</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Debug Status (z-index increased to 2000) -->
|
||||||
|
<div id="debugStatus" style="position: fixed; bottom: 5px; right: 5px; font-size: 11px; color: #555; background: rgba(255,255,200,0.9); padding: 5px 10px; border-radius: 5px; border: 1px solid #ccc; z-index: 2000; font-family: monospace;">Init (v3)...</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const currencySymbol = '{{ settings.currency_symbol|escapejs }}';
|
||||||
|
const welcomeScreen = document.getElementById('welcomeScreen');
|
||||||
|
const cartList = document.getElementById('cartList');
|
||||||
|
const totalEl = document.getElementById('totalAmount');
|
||||||
|
const countEl = document.getElementById('itemCount');
|
||||||
|
const taxEl = document.getElementById('taxAmount');
|
||||||
|
const debugEl = document.getElementById('debugStatus');
|
||||||
|
|
||||||
|
function updateStatus(msg) {
|
||||||
|
console.log('[CFD] ' + msg);
|
||||||
|
debugEl.innerText = msg + ' (' + new Date().toLocaleTimeString() + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMessage(event) {
|
||||||
|
const data = event.data;
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
updateStatus('Msg: ' + (data.type || 'unknown'));
|
||||||
|
|
||||||
|
if (data.type === 'update_cart') {
|
||||||
|
renderCart(data.cart, data.totals);
|
||||||
|
} else if (data.type === 'clear') {
|
||||||
|
showWelcome();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. BroadcastChannel (Cross-tab)
|
||||||
|
const channel = new BroadcastChannel('pos_channel');
|
||||||
|
channel.onmessage = handleMessage;
|
||||||
|
|
||||||
|
// 2. Direct Window Message (Parent-Child)
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
// 3. LocalStorage (Cross-window backup)
|
||||||
|
window.addEventListener('storage', (event) => {
|
||||||
|
if (event.key === 'pos_cart_update' && event.newValue) {
|
||||||
|
updateStatus('Storage Event');
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.newValue);
|
||||||
|
handleMessage({ data: msg });
|
||||||
|
} catch(e) { updateStatus('Storage Parse Error'); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial load: Check storage
|
||||||
|
const stored = localStorage.getItem('pos_cart_update');
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(stored);
|
||||||
|
updateStatus('Restored Storage');
|
||||||
|
// Small delay to ensure DOM is ready
|
||||||
|
setTimeout(() => handleMessage({ data: msg }), 100);
|
||||||
|
} catch(e) {
|
||||||
|
updateStatus('Storage Error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateStatus('No Storage Data');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handshake: Ask POS for current state
|
||||||
|
setTimeout(() => {
|
||||||
|
updateStatus('Requesting Sync...');
|
||||||
|
try {
|
||||||
|
channel.postMessage({ type: 'new_client' });
|
||||||
|
} catch(e) {
|
||||||
|
updateStatus('BC Error');
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
function renderCart(cart, totals) {
|
||||||
|
updateStatus('Render: ' + (cart ? cart.length : 0));
|
||||||
|
|
||||||
|
if (!cart || cart.length === 0) {
|
||||||
|
showWelcome();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
welcomeScreen.classList.add('hidden');
|
||||||
|
cartList.innerHTML = '';
|
||||||
|
|
||||||
|
cart.forEach(item => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'cart-item';
|
||||||
|
div.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<div class="item-name">${item.name_ar} / ${item.name_en}</div>
|
||||||
|
<div class="item-meta">${item.quantity} x ${parseFloat(item.price).toFixed(3)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-price">${parseFloat(item.line_total).toFixed(3)}</div>
|
||||||
|
`;
|
||||||
|
cartList.appendChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll to bottom
|
||||||
|
cartList.scrollTop = cartList.scrollHeight;
|
||||||
|
|
||||||
|
// Update Totals
|
||||||
|
if (totals) {
|
||||||
|
totalEl.innerText = `${currencySymbol} ${parseFloat(totals.total).toFixed(3)}`;
|
||||||
|
taxEl.innerText = parseFloat(totals.tax).toFixed(3);
|
||||||
|
// Calculate item count
|
||||||
|
const count = cart.reduce((acc, item) => acc + parseFloat(item.quantity), 0);
|
||||||
|
countEl.innerText = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWelcome() {
|
||||||
|
updateStatus('State: Welcome');
|
||||||
|
welcomeScreen.classList.remove('hidden');
|
||||||
|
cartList.innerHTML = '';
|
||||||
|
totalEl.innerText = `${currencySymbol} 0.000`;
|
||||||
|
countEl.innerText = '0';
|
||||||
|
taxEl.innerText = '0.000';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -128,6 +128,14 @@
|
|||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-4 gap-3">
|
<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>
|
<h4 class="fw-bold mb-0">{% trans "Point of Sale" %}</h4>
|
||||||
|
<div class="ms-3 d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="openCustomerDisplay()" title="{% trans 'Open Customer Screen' %}">
|
||||||
|
<i class="bi bi-display"></i> <span class="d-none d-md-inline">{% trans "Customer Screen" %}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-warning" onclick="broadcastCartUpdate()" title="{% trans 'Force Sync' %}">
|
||||||
|
<i class="bi bi-arrow-repeat"></i> <span class="d-none d-md-inline">{% trans "Sync" %}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="input-group w-100 w-md-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>
|
<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...' %}">
|
<input type="text" id="productSearch" class="form-control border-start-0 shadow-none" placeholder="{% trans 'Search products...' %}">
|
||||||
@ -571,6 +579,7 @@
|
|||||||
cart = [];
|
cart = [];
|
||||||
document.getElementById('discountInput').value = 0;
|
document.getElementById('discountInput').value = 0;
|
||||||
renderCart();
|
renderCart();
|
||||||
|
broadcastClear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -583,6 +592,7 @@
|
|||||||
document.getElementById('subtotalAmount').innerText = `${currency} ${formatAmount(subtotal)}`;
|
document.getElementById('subtotalAmount').innerText = `${currency} ${formatAmount(subtotal)}`;
|
||||||
document.getElementById('taxAmount').innerText = `${currency} ${formatAmount(totalVat)}`;
|
document.getElementById('taxAmount').innerText = `${currency} ${formatAmount(totalVat)}`;
|
||||||
document.getElementById('totalAmount').innerText = `${currency} ${formatAmount(total)}`;
|
document.getElementById('totalAmount').innerText = `${currency} ${formatAmount(total)}`;
|
||||||
|
broadcastCartUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCart() {
|
function renderCart() {
|
||||||
@ -826,6 +836,7 @@
|
|||||||
customerLoyalty = null;
|
customerLoyalty = null;
|
||||||
document.getElementById('loyaltyInfo').classList.add('d-none');
|
document.getElementById('loyaltyInfo').classList.add('d-none');
|
||||||
renderCart();
|
renderCart();
|
||||||
|
broadcastClear();
|
||||||
|
|
||||||
// Show receipt modal
|
// Show receipt modal
|
||||||
const receiptModal = new bootstrap.Modal(document.getElementById('receiptModal'));
|
const receiptModal = new bootstrap.Modal(document.getElementById('receiptModal'));
|
||||||
@ -1046,6 +1057,7 @@
|
|||||||
document.getElementById('customerSearchInput').value = data.customer_name || "";
|
document.getElementById('customerSearchInput').value = data.customer_name || "";
|
||||||
document.getElementById('clearCustomerBtn').style.display = data.customer_id ? 'block' : 'none';
|
document.getElementById('clearCustomerBtn').style.display = data.customer_id ? 'block' : 'none';
|
||||||
renderCart();
|
renderCart();
|
||||||
|
broadcastClear();
|
||||||
onCustomerChange();
|
onCustomerChange();
|
||||||
updateHeldCount();
|
updateHeldCount();
|
||||||
|
|
||||||
@ -1182,6 +1194,68 @@ document.addEventListener('click', function(e) {
|
|||||||
container.classList.add('d-none');
|
container.classList.add('d-none');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Customer Display Logic
|
||||||
|
let customerDisplayWindow = null;
|
||||||
|
const displayChannel = new BroadcastChannel('pos_channel');
|
||||||
|
|
||||||
|
function openCustomerDisplay() {
|
||||||
|
if (customerDisplayWindow && !customerDisplayWindow.closed) {
|
||||||
|
customerDisplayWindow.focus();
|
||||||
|
} else {
|
||||||
|
customerDisplayWindow = window.open('{% url "customer_display" %}', 'CustomerDisplay', 'width=800,height=600,menubar=no,toolbar=no,location=no,status=no');
|
||||||
|
setTimeout(broadcastCartUpdate, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: Listen for new clients requesting state
|
||||||
|
displayChannel.onmessage = (event) => {
|
||||||
|
if (event.data && event.data.type === 'new_client') {
|
||||||
|
console.log('[POS] New Client Handshake Received');
|
||||||
|
broadcastCartUpdate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function broadcastCartUpdate() {
|
||||||
|
const subtotal = cart.reduce((acc, item) => acc + item.line_total, 0);
|
||||||
|
const totalVat = cart.reduce((acc, item) => acc + (item.line_total * (item.vat_rate / 100)), 0);
|
||||||
|
const discount = parseFloat(document.getElementById('discountInput').value) || 0;
|
||||||
|
const total = Math.max(0, subtotal + totalVat - discount);
|
||||||
|
|
||||||
|
const msg = {
|
||||||
|
type: 'update_cart',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
cart: cart,
|
||||||
|
totals: {
|
||||||
|
subtotal: subtotal,
|
||||||
|
tax: totalVat,
|
||||||
|
discount: discount,
|
||||||
|
total: total
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. BroadcastChannel
|
||||||
|
try { displayChannel.postMessage(msg); } catch(e) { console.error('BC Error:', e); }
|
||||||
|
|
||||||
|
// 2. Direct Window
|
||||||
|
if (customerDisplayWindow && !customerDisplayWindow.closed) {
|
||||||
|
customerDisplayWindow.postMessage(msg, '*');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. LocalStorage Fallback (triggers 'storage' event in other tabs)
|
||||||
|
localStorage.setItem('pos_cart_update', JSON.stringify(msg));
|
||||||
|
console.log('[POS] Cart Broadcast Sent');
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastClear() {
|
||||||
|
const msg = { type: 'clear' };
|
||||||
|
displayChannel.postMessage(msg);
|
||||||
|
if (customerDisplayWindow && !customerDisplayWindow.closed) {
|
||||||
|
customerDisplayWindow.postMessage(msg, '*');
|
||||||
|
}
|
||||||
|
localStorage.setItem('pos_cart_update', JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
{% endlocalize %}
|
{% endlocalize %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -5,6 +5,7 @@ urlpatterns = [
|
|||||||
path('', views.index, name='index'),
|
path('', views.index, name='index'),
|
||||||
path('inventory/', views.inventory, name='inventory'),
|
path('inventory/', views.inventory, name='inventory'),
|
||||||
path('pos/', views.pos, name='pos'),
|
path('pos/', views.pos, name='pos'),
|
||||||
|
path('pos/display/', views.customer_display, name='customer_display'),
|
||||||
path('customers/', views.customers, name='customers'),
|
path('customers/', views.customers, name='customers'),
|
||||||
path('suppliers/', views.suppliers, name='suppliers'),
|
path('suppliers/', views.suppliers, name='suppliers'),
|
||||||
path('purchases/', views.purchases, name='purchases'),
|
path('purchases/', views.purchases, name='purchases'),
|
||||||
|
|||||||
170
core/views.py
170
core/views.py
@ -484,6 +484,166 @@ def create_sale_api(request):
|
|||||||
sale.status = 'unpaid'
|
sale.status = 'unpaid'
|
||||||
sale.save()
|
sale.save()
|
||||||
|
|
||||||
|
# Record initial payment if any
|
||||||
|
if float(paid_amount) > 0:
|
||||||
|
pm = None
|
||||||
|
if payment_method_id:
|
||||||
|
pm = PaymentMethod.objects.filter(id=payment_method_id).first()
|
||||||
|
|
||||||
|
SalePayment.objects.create(
|
||||||
|
sale=sale,
|
||||||
|
amount=paid_amount,
|
||||||
|
payment_method=pm,
|
||||||
|
payment_method_name=pm.name_en if pm else payment_type.capitalize(),
|
||||||
|
notes="Initial payment",
|
||||||
|
created_by=request.user
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
product = Product.objects.get(id=item['id'])
|
||||||
|
SaleItem.objects.create(
|
||||||
|
sale=sale,
|
||||||
|
product=product,
|
||||||
|
quantity=item['quantity'],
|
||||||
|
unit_price=item['price'],
|
||||||
|
line_total=item['line_total']
|
||||||
|
)
|
||||||
|
product.stock_quantity -= int(item['quantity'])
|
||||||
|
product.save()
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
'sale_id': sale.id,
|
||||||
|
'business': {
|
||||||
|
'name': settings.business_name,
|
||||||
|
'address': settings.address,
|
||||||
|
'phone': settings.phone,
|
||||||
|
'email': settings.email,
|
||||||
|
'currency': settings.currency_symbol,
|
||||||
|
'vat_number': settings.vat_number,
|
||||||
|
'registration_number': settings.registration_number,
|
||||||
|
'logo_url': settings.logo.url if settings.logo else None
|
||||||
|
},
|
||||||
|
'sale': {
|
||||||
|
'id': sale.id,
|
||||||
|
'invoice_number': sale.invoice_number,
|
||||||
|
'created_at': sale.created_at.strftime("%Y-%m-%d %H:%M"),
|
||||||
|
'subtotal': float(sale.subtotal),
|
||||||
|
'vat_amount': float(sale.vat_amount),
|
||||||
|
'total': float(sale.total_amount),
|
||||||
|
'discount': float(sale.discount),
|
||||||
|
'paid': float(sale.paid_amount),
|
||||||
|
'balance': float(sale.balance_due),
|
||||||
|
'customer_name': sale.customer.name if sale.customer else 'Guest',
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'name_en': si.product.name_en,
|
||||||
|
'name_ar': si.product.name_ar,
|
||||||
|
'qty': si.quantity,
|
||||||
|
'price': float(si.unit_price),
|
||||||
|
'total': float(si.line_total)
|
||||||
|
} for si in sale.items.all()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'success': False, 'error': str(e)}, status=400)
|
||||||
|
def create_sale_api(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
customer_id = data.get('customer_id')
|
||||||
|
invoice_number = data.get('invoice_number', '')
|
||||||
|
items = data.get('items', [])
|
||||||
|
|
||||||
|
# Retrieve amounts
|
||||||
|
subtotal = data.get('subtotal', 0)
|
||||||
|
vat_amount = data.get('vat_amount', 0)
|
||||||
|
total_amount = data.get('total_amount', 0)
|
||||||
|
|
||||||
|
paid_amount = data.get('paid_amount', 0)
|
||||||
|
discount = data.get('discount', 0)
|
||||||
|
payment_type = data.get('payment_type', 'cash')
|
||||||
|
payment_method_id = data.get('payment_method_id')
|
||||||
|
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)
|
||||||
|
|
||||||
|
if not customer and payment_type != 'cash':
|
||||||
|
return JsonResponse({'success': False, 'error': _('Credit or Partial payments are not allowed for Guest customers.')}, status=400)
|
||||||
|
|
||||||
|
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,
|
||||||
|
subtotal=subtotal,
|
||||||
|
vat_amount=vat_amount,
|
||||||
|
total_amount=total_amount,
|
||||||
|
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,
|
||||||
|
created_by=request.user
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set status based on payments
|
||||||
|
if float(paid_amount) >= float(total_amount):
|
||||||
|
sale.status = 'paid'
|
||||||
|
elif float(paid_amount) > 0:
|
||||||
|
sale.status = 'partial'
|
||||||
|
else:
|
||||||
|
sale.status = 'unpaid'
|
||||||
|
sale.save()
|
||||||
|
|
||||||
# Record initial payment if any
|
# Record initial payment if any
|
||||||
if float(paid_amount) > 0:
|
if float(paid_amount) > 0:
|
||||||
pm = None
|
pm = None
|
||||||
@ -2423,4 +2583,12 @@ def search_customers_api(request):
|
|||||||
).values('id', 'name', 'phone')[:20]
|
).values('id', 'name', 'phone')[:20]
|
||||||
else:
|
else:
|
||||||
customers = []
|
customers = []
|
||||||
return JsonResponse({'results': list(customers)})
|
return JsonResponse({'results': list(customers)})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def customer_display(request):
|
||||||
|
"""
|
||||||
|
Render the Customer Facing Display screen.
|
||||||
|
"""
|
||||||
|
settings = SystemSetting.objects.first()
|
||||||
|
return render(request, "core/customer_display.html", {"settings": settings})
|
||||||
Binary file not shown.
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user