Revert to version 2ad0af1
This commit is contained in:
parent
75d9aea042
commit
77e86e4c7b
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB |
Binary file not shown.
@ -8,12 +8,9 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
import pymysql
|
||||
|
||||
pymysql.install_as_MySQLdb()
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
application = get_wsgi_application()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,461 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block title %}{% trans "Customer Display" %} | {{ site_settings.business_name }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
/* --- Critical Layout Overrides for Full Screen --- */
|
||||
#sidebar,
|
||||
.navbar,
|
||||
.top-navbar,
|
||||
#sidebarCollapse,
|
||||
.mobile-cart-toggle,
|
||||
footer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#wrapper {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
height: 100vh !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
#content {
|
||||
margin-left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 0 !important;
|
||||
height: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
background-color: #f8f9fa;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* --- Customer Display Specific Styles --- */
|
||||
.customer-display-container {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
flex: 1; /* Takes 50% or remaining space */
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 2px 0 10px rgba(0,0,0,0.05);
|
||||
z-index: 10;
|
||||
height: 100%;
|
||||
max-width: 50%; /* limit width to half screen */
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
flex: 1;
|
||||
background: #f8f9fa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cart-items-container {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 2rem;
|
||||
padding-right: 10px; /* space for scrollbar */
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for items */
|
||||
.cart-items-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.cart-items-container::-webkit-scrollbar-thumb {
|
||||
background-color: #dee2e6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cart-item {
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
.cart-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.2rem;
|
||||
color: #212529;
|
||||
}
|
||||
.item-price {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.item-total {
|
||||
font-weight: 700;
|
||||
font-size: 1.3rem;
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.total-section {
|
||||
background: var(--bs-primary);
|
||||
color: white;
|
||||
padding: 2.5rem;
|
||||
border-radius: 1.5rem;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
|
||||
margin-bottom: 2rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
.total-label {
|
||||
font-size: 1.4rem;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.total-amount {
|
||||
font-size: 4.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.logo-placeholder {
|
||||
max-width: 300px;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.promo-card {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
background: white;
|
||||
border-radius: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Connection Status Dot */
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: #dc3545; /* Red initially */
|
||||
z-index: 100;
|
||||
transition: background-color 0.3s;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 0 5px rgba(0,0,0,0.2);
|
||||
}
|
||||
.status-dot.connected {
|
||||
background-color: #198754; /* Green */
|
||||
}
|
||||
|
||||
/* Debug Panel */
|
||||
#debugPanel {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: #0f0;
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
padding: 10px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
display: none; /* Hidden by default, toggleable */
|
||||
z-index: 2000;
|
||||
}
|
||||
.debug-toggle {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
font-size: 10px;
|
||||
opacity: 0.3;
|
||||
z-index: 2001;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="customer-display-container">
|
||||
<div id="connectionStatus" class="status-dot" title="Connection Status"></div>
|
||||
<div class="debug-toggle" onclick="toggleDebug()">Debug</div>
|
||||
|
||||
<!-- Left Panel: Cart Items -->
|
||||
<div class="left-panel">
|
||||
<div class="d-flex align-items-center mb-4 border-bottom pb-3">
|
||||
{% if site_settings.logo %}
|
||||
<img src="{{ site_settings.logo.url }}" alt="Logo" height="50" class="me-3">
|
||||
{% endif %}
|
||||
<div>
|
||||
<h2 class="fw-bold mb-0 text-dark">{{ site_settings.business_name|default:"POS Display" }}</h2>
|
||||
<div class="text-muted small">{% trans "Your Order Details" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cart-items-container" id="cartItems">
|
||||
<!-- Default Empty State -->
|
||||
<div class="h-100 d-flex flex-column align-items-center justify-content-center text-muted opacity-50">
|
||||
<i class="bi bi-basket display-1 mb-3"></i>
|
||||
<h3>{% trans "Welcome!" %}</h3>
|
||||
<p>{% trans "Items will appear here as scanned." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto border-top pt-3">
|
||||
<div class="d-flex justify-content-between mb-2 fs-5">
|
||||
<span class="text-muted">{% trans "Subtotal" %}</span>
|
||||
<span id="subtotalDisplay" class="fw-bold">---</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2 fs-5">
|
||||
<span class="text-muted">{% trans "VAT" %}</span>
|
||||
<span id="vatDisplay" class="fw-bold">---</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2 fs-5 text-danger" style="visibility: hidden;">
|
||||
<span class="text-muted">{% trans "Discount" %}</span>
|
||||
<span id="discountDisplay" class="fw-bold">---</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel: Big Total / Ads -->
|
||||
<div class="right-panel">
|
||||
<div class="total-section text-center">
|
||||
<div class="total-label">{% trans "Total to Pay" %}</div>
|
||||
<div class="total-amount" id="totalDisplay">0.00</div>
|
||||
</div>
|
||||
|
||||
<div class="promo-card">
|
||||
<div class="d-flex flex-column justify-content-center h-100">
|
||||
<div class="display-1 text-primary mb-3"><i class="bi bi-emoji-smile"></i></div>
|
||||
<h3 class="fw-bold text-dark mb-2">{% trans "Thank You!" %}</h3>
|
||||
<p class="text-muted fs-5">{% trans "We appreciate your business." %}</p>
|
||||
<div class="d-flex gap-2 justify-content-center mt-3">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="requestSync()">{% trans "Refresh" %}</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="hardReset()">{% trans "Reset" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="debugPanel">Waiting for data...</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const channel = new BroadcastChannel('pos_channel');
|
||||
let currencySymbol = '{{ site_settings.currency_symbol|escapejs }}';
|
||||
if (!currencySymbol) currencySymbol = '{{ settings.currency_symbol|default:"$"|escapejs }}';
|
||||
|
||||
const statusDot = document.getElementById('connectionStatus');
|
||||
const debugPanel = document.getElementById('debugPanel');
|
||||
|
||||
function log(msg) {
|
||||
const time = new Date().toLocaleTimeString();
|
||||
debugPanel.innerHTML = `<div>[${time}] ${msg}</div>` + debugPanel.innerHTML;
|
||||
// console.log(msg); // Optional: keep console clean or enable for dev
|
||||
}
|
||||
|
||||
function toggleDebug() {
|
||||
debugPanel.style.display = debugPanel.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function setConnected(isConnected) {
|
||||
if (isConnected) {
|
||||
statusDot.classList.add('connected');
|
||||
statusDot.title = "Connected";
|
||||
} else {
|
||||
statusDot.classList.remove('connected');
|
||||
statusDot.title = "Disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
function requestSync() {
|
||||
log("Requesting sync...");
|
||||
channel.postMessage({ type: 'request_state' });
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: 'request_state' }, '*');
|
||||
}
|
||||
}
|
||||
|
||||
function hardReset() {
|
||||
localStorage.removeItem('pos_cart_state');
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// --- State Management ---
|
||||
let lastTimestamp = 0;
|
||||
|
||||
function handleUpdate(data) {
|
||||
if (!data || !data.items) return;
|
||||
|
||||
// Avoid re-rendering if timestamp hasn't changed
|
||||
if (data.timestamp && data.timestamp <= lastTimestamp) {
|
||||
return;
|
||||
}
|
||||
lastTimestamp = data.timestamp || Date.now();
|
||||
|
||||
setConnected(true);
|
||||
renderCart(data);
|
||||
}
|
||||
|
||||
// 1. Initial Load
|
||||
try {
|
||||
const savedState = localStorage.getItem('pos_cart_state');
|
||||
if (savedState) {
|
||||
log("Loaded LocalStorage");
|
||||
handleUpdate(JSON.parse(savedState));
|
||||
}
|
||||
} catch (e) {
|
||||
log("LS Error: " + e.message);
|
||||
}
|
||||
|
||||
// 2. Broadcast Channel
|
||||
channel.onmessage = (event) => {
|
||||
log("Broadcast: " + event.data.type);
|
||||
if (event.data.type === 'update') {
|
||||
handleUpdate(event.data);
|
||||
localStorage.setItem('pos_cart_state', JSON.stringify(event.data));
|
||||
} else if (event.data.type === 'clear') {
|
||||
clearDisplay();
|
||||
localStorage.removeItem('pos_cart_state');
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Polling LocalStorage (Robust Fallback)
|
||||
setInterval(() => {
|
||||
try {
|
||||
const savedState = localStorage.getItem('pos_cart_state');
|
||||
if (savedState) {
|
||||
const data = JSON.parse(savedState);
|
||||
if (data.timestamp > lastTimestamp) {
|
||||
log("Polling found update");
|
||||
handleUpdate(data);
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}, 500);
|
||||
|
||||
// 4. PostMessage (Iframe/Opener)
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'update') {
|
||||
log("PostMessage received");
|
||||
handleUpdate(event.data);
|
||||
localStorage.setItem('pos_cart_state', JSON.stringify(event.data));
|
||||
}
|
||||
});
|
||||
|
||||
// Request initial state
|
||||
setTimeout(requestSync, 500);
|
||||
|
||||
function renderCart(data) {
|
||||
const container = document.getElementById('cartItems');
|
||||
const items = data.items || [];
|
||||
|
||||
if (items.length === 0) {
|
||||
clearDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
items.forEach(item => {
|
||||
const nameAr = item.name_ar || item.name_en || 'Unknown';
|
||||
const nameEn = item.name_en || '';
|
||||
const price = parseFloat(item.price || 0).toFixed(2);
|
||||
const qty = parseFloat(item.quantity || 1).toFixed(2);
|
||||
const lineTotal = parseFloat(item.line_total || 0).toFixed(2);
|
||||
|
||||
html += `
|
||||
<div class="cart-item">
|
||||
<div>
|
||||
<div class="item-name">${nameAr}</div>
|
||||
<div class="small text-muted mb-1">${nameEn}</div>
|
||||
<div class="item-price">${currencySymbol} ${price} x ${qty}</div>
|
||||
</div>
|
||||
<div class="item-total">
|
||||
${currencySymbol} ${lineTotal}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
|
||||
// Auto scroll
|
||||
setTimeout(() => container.scrollTop = container.scrollHeight, 50);
|
||||
|
||||
const subtotal = parseFloat(data.subtotal || 0).toFixed(2);
|
||||
const vat = parseFloat(data.vat || 0).toFixed(2);
|
||||
const total = parseFloat(data.total || 0).toFixed(2);
|
||||
|
||||
document.getElementById('subtotalDisplay').innerText = `${currencySymbol} ${subtotal}`;
|
||||
document.getElementById('vatDisplay').innerText = `${currencySymbol} ${vat}`;
|
||||
|
||||
const discountVal = parseFloat(data.discount || 0);
|
||||
const discountEl = document.getElementById('discountDisplay');
|
||||
if (discountEl) {
|
||||
const discountRow = discountEl.parentElement;
|
||||
if (discountVal > 0) {
|
||||
discountEl.innerText = `-${currencySymbol} ${discountVal.toFixed(2)}`;
|
||||
discountRow.style.visibility = 'visible';
|
||||
} else {
|
||||
discountRow.style.visibility = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('totalDisplay').innerText = `${currencySymbol} ${total}`;
|
||||
}
|
||||
|
||||
function clearDisplay() {
|
||||
document.getElementById('cartItems').innerHTML = `
|
||||
<div class="h-100 d-flex flex-column align-items-center justify-content-center text-muted opacity-50">
|
||||
<i class="bi bi-basket display-1 mb-3"></i>
|
||||
<h3>{% trans "Welcome!" %}</h3>
|
||||
<p>{% trans "Items will appear here as scanned." %}</p>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('subtotalDisplay').innerText = '---';
|
||||
document.getElementById('vatDisplay').innerText = '---';
|
||||
const discountEl = document.getElementById('discountDisplay');
|
||||
if (discountEl) {
|
||||
discountEl.innerText = '---';
|
||||
discountEl.parentElement.style.visibility = 'hidden';
|
||||
}
|
||||
document.getElementById('totalDisplay').innerText = `${currencySymbol} 0.00`;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -128,9 +128,6 @@
|
||||
<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">
|
||||
<h4 class="fw-bold mb-0">{% trans "Point of Sale" %}</h4>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-3" 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>
|
||||
<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...' %}">
|
||||
@ -518,71 +515,6 @@
|
||||
{% localize off %}
|
||||
<script>
|
||||
let cart = [];
|
||||
const posChannel = new BroadcastChannel('pos_channel');
|
||||
|
||||
// Listen for requests from the customer display (BroadcastChannel)
|
||||
posChannel.onmessage = (event) => {
|
||||
if (event.data.type === 'request_state') {
|
||||
broadcastCart();
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for direct messages from the popup (fallback for iframe restrictions)
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'request_state') {
|
||||
// Reply directly to the source window
|
||||
broadcastCart(event.source);
|
||||
}
|
||||
});
|
||||
|
||||
// Force sync when returning to this tab
|
||||
window.addEventListener('focus', () => {
|
||||
broadcastCart();
|
||||
});
|
||||
|
||||
function openCustomerDisplay() {
|
||||
const win = window.open('{% url "customer_display" %}', 'CustomerDisplay', 'width=1000,height=800,menubar=no,toolbar=no,location=no,status=no');
|
||||
// Give it a moment to load and then force push data
|
||||
setTimeout(() => broadcastCart(), 1000);
|
||||
setTimeout(() => broadcastCart(), 3000);
|
||||
}
|
||||
|
||||
function broadcastCart(targetWindow = null) {
|
||||
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 payload = {
|
||||
type: 'update',
|
||||
items: cart,
|
||||
subtotal: subtotal,
|
||||
vat: totalVat,
|
||||
discount: discount,
|
||||
total: total,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// 1. Broadcast Channel (Primary, Real-time)
|
||||
posChannel.postMessage(payload);
|
||||
|
||||
// 2. Local Storage (Backup/Sync)
|
||||
localStorage.setItem('pos_cart_state', JSON.stringify(payload));
|
||||
|
||||
// 3. Direct PostMessage (Fallback if target window requested it)
|
||||
if (targetWindow) {
|
||||
targetWindow.postMessage(payload, '*');
|
||||
}
|
||||
}
|
||||
|
||||
// Heartbeat: Ensure customer display stays in sync even if events are missed
|
||||
setInterval(() => {
|
||||
if (cart.length > 0) {
|
||||
broadcastCart();
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
let lastSaleData = null;
|
||||
let selectedPaymentMethodId = null;
|
||||
let customerLoyalty = null;
|
||||
@ -609,8 +541,8 @@
|
||||
} else {
|
||||
cart.push({
|
||||
id,
|
||||
name_en: nameEn || '',
|
||||
name_ar: nameAr || nameEn || '',
|
||||
name_en: nameEn,
|
||||
name_ar: nameAr,
|
||||
price,
|
||||
vat_rate: vatRate,
|
||||
quantity: 1,
|
||||
@ -651,7 +583,6 @@
|
||||
document.getElementById('subtotalAmount').innerText = `${currency} ${formatAmount(subtotal)}`;
|
||||
document.getElementById('taxAmount').innerText = `${currency} ${formatAmount(totalVat)}`;
|
||||
document.getElementById('totalAmount').innerText = `${currency} ${formatAmount(total)}`;
|
||||
broadcastCart();
|
||||
}
|
||||
|
||||
function renderCart() {
|
||||
@ -670,10 +601,6 @@
|
||||
updateTotals();
|
||||
payBtn.disabled = true;
|
||||
holdBtn.disabled = true;
|
||||
|
||||
// Clear customer display as well
|
||||
posChannel.postMessage({ type: 'clear' });
|
||||
localStorage.removeItem('pos_cart_state');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -50,12 +50,6 @@
|
||||
<i class="bi bi-whatsapp me-2"></i>{% trans "WhatsApp Gateway" %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link fw-bold px-4" id="backup-tab" data-bs-toggle="pill" data-bs-target="#backup" type="button" role="tab">
|
||||
<i class="bi bi-hdd-network me-2"></i>{% trans "System Backup" %}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="settingsTabsContent">
|
||||
@ -631,50 +625,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup Tab -->
|
||||
<div class="tab-pane fade" id="backup" role="tabpanel">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<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 "Backup Database" %}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">{% trans "Download a complete SQL dump of your database. Keep this file safe." %}</p>
|
||||
<a href="{% url 'backup_database' %}" class="btn btn-primary w-100">
|
||||
<i class="bi bi-download me-2"></i> {% trans "Download Backup" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<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 text-danger">{% trans "Restore Database" %}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
<strong>{% trans "Warning:" %}</strong> {% trans "This will overwrite all current data. This action cannot be undone." %}
|
||||
</div>
|
||||
<form action="{% url 'restore_database' %}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{% trans "Select Backup File (.sql)" %}</label>
|
||||
<input type="file" name="backup_file" class="form-control" accept=".sql" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger w-100" onclick="return confirm('{% trans "Are you sure? This will wipe the current database." %}')">
|
||||
<i class="bi bi-arrow-counterclockwise me-2"></i> {% trans "Restore Database" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Add Tier Modal -->
|
||||
<div class="modal fade" id="addTierModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
|
||||
@ -5,7 +5,6 @@ urlpatterns = [
|
||||
path('', views.index, name='index'),
|
||||
path('inventory/', views.inventory, name='inventory'),
|
||||
path('pos/', views.pos, name='pos'),
|
||||
path('pos/customer-display/', views.customer_display, name='customer_display'),
|
||||
path('customers/', views.customers, name='customers'),
|
||||
path('suppliers/', views.suppliers, name='suppliers'),
|
||||
path('purchases/', views.purchases, name='purchases'),
|
||||
@ -129,8 +128,4 @@ urlpatterns = [
|
||||
path('settings/devices/add/', views.add_device, name='add_device'),
|
||||
path('settings/devices/edit/<int:pk>/', views.edit_device, name='edit_device'),
|
||||
path('settings/devices/delete/<int:pk>/', views.delete_device, name='delete_device'),
|
||||
|
||||
# System Backup
|
||||
path('settings/backup/download/', views.backup_database, name='backup_database'),
|
||||
path('settings/backup/restore/', views.restore_database, name='restore_database'),
|
||||
]
|
||||
]
|
||||
|
||||
102
core/views.py
102
core/views.py
@ -1,6 +1,5 @@
|
||||
import base64
|
||||
import os
|
||||
import subprocess
|
||||
from django.conf import settings as django_settings
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -2424,103 +2423,4 @@ def search_customers_api(request):
|
||||
).values('id', 'name', 'phone')[:20]
|
||||
else:
|
||||
customers = []
|
||||
return JsonResponse({'results': list(customers)})
|
||||
@login_required
|
||||
def backup_database(request):
|
||||
if not request.user.is_superuser:
|
||||
messages.error(request, _("Access denied."))
|
||||
return redirect('settings')
|
||||
|
||||
db_settings = django_settings.DATABASES['default']
|
||||
db_name = db_settings['NAME']
|
||||
db_user = db_settings['USER']
|
||||
db_password = db_settings['PASSWORD']
|
||||
db_host = db_settings['HOST']
|
||||
db_port = db_settings['PORT']
|
||||
|
||||
timestamp = timezone.now().strftime('%Y-%m-%d_%H-%M-%S')
|
||||
filename = f"backup_{db_name}_{timestamp}.sql"
|
||||
|
||||
env = os.environ.copy()
|
||||
env['MYSQL_PWD'] = db_password
|
||||
|
||||
command = [
|
||||
'mysqldump',
|
||||
'-h', db_host,
|
||||
'-P', str(db_port),
|
||||
'-u', db_user,
|
||||
'--no-tablespaces',
|
||||
db_name
|
||||
]
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(command, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
output, error = process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
messages.error(request, f"Backup failed: {error.decode('utf-8')}")
|
||||
return redirect('settings')
|
||||
|
||||
response = HttpResponse(output, content_type='application/octet-stream')
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"An error occurred: {str(e)}")
|
||||
return redirect('settings')
|
||||
|
||||
@login_required
|
||||
def restore_database(request):
|
||||
if not request.user.is_superuser:
|
||||
messages.error(request, _("Access denied."))
|
||||
return redirect('settings')
|
||||
|
||||
if request.method == 'POST' and request.FILES.get('backup_file'):
|
||||
backup_file = request.FILES['backup_file']
|
||||
|
||||
if not backup_file.name.endswith('.sql'):
|
||||
messages.error(request, _("Please upload a valid .sql file."))
|
||||
return redirect(reverse('settings') + '#backup')
|
||||
|
||||
db_settings = django_settings.DATABASES['default']
|
||||
db_name = db_settings['NAME']
|
||||
db_user = db_settings['USER']
|
||||
db_password = db_settings['PASSWORD']
|
||||
db_host = db_settings['HOST']
|
||||
db_port = db_settings['PORT']
|
||||
|
||||
env = os.environ.copy()
|
||||
env['MYSQL_PWD'] = db_password
|
||||
|
||||
command = [
|
||||
'mysql',
|
||||
'-h', db_host,
|
||||
'-P', str(db_port),
|
||||
'-u', db_user,
|
||||
db_name
|
||||
]
|
||||
|
||||
try:
|
||||
file_content = backup_file.read()
|
||||
|
||||
process = subprocess.Popen(command, env=env, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
output, error = process.communicate(input=file_content)
|
||||
|
||||
if process.returncode != 0:
|
||||
messages.error(request, f"Restore failed: {error.decode('utf-8')}")
|
||||
else:
|
||||
messages.success(request, _("Database restored successfully!"))
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"An error occurred: {str(e)}")
|
||||
|
||||
return redirect(reverse('settings') + '#backup')
|
||||
|
||||
|
||||
@login_required
|
||||
def customer_display(request):
|
||||
"""
|
||||
Customer Facing Display (CFD) View
|
||||
"""
|
||||
settings = SystemSetting.objects.first()
|
||||
return render(request, 'core/customer_display.html', {'settings': settings})
|
||||
return JsonResponse({'results': list(customers)})
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -12,10 +12,9 @@ class JobPositionAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Employee)
|
||||
class EmployeeAdmin(admin.ModelAdmin):
|
||||
list_display = ('first_name', 'last_name', 'biometric_id', 'email', 'department', 'job_position', 'status')
|
||||
list_display = ('first_name', 'last_name', 'email', 'department', 'job_position', 'status')
|
||||
list_filter = ('status', 'department', 'gender')
|
||||
search_fields = ('first_name', 'last_name', 'email', 'phone', 'biometric_id')
|
||||
ordering = ('first_name', 'last_name')
|
||||
search_fields = ('first_name', 'last_name', 'email', 'phone')
|
||||
|
||||
@admin.register(Attendance)
|
||||
class AttendanceAdmin(admin.ModelAdmin):
|
||||
@ -32,4 +31,4 @@ class LeaveRequestAdmin(admin.ModelAdmin):
|
||||
class BiometricDeviceAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'ip_address', 'port', 'device_type', 'status', 'last_sync')
|
||||
list_filter = ('status', 'device_type')
|
||||
search_fields = ('name', 'ip_address')
|
||||
search_fields = ('name', 'ip_address')
|
||||
29
hr/forms.py
29
hr/forms.py
@ -1,29 +0,0 @@
|
||||
from django import forms
|
||||
from .models import Employee
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
class EmployeeForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Employee
|
||||
fields = [
|
||||
'first_name', 'last_name', 'gender', 'date_of_birth',
|
||||
'email', 'phone', 'address',
|
||||
'department', 'job_position', 'hire_date', 'status', 'salary',
|
||||
'user', 'biometric_id'
|
||||
]
|
||||
widgets = {
|
||||
'date_of_birth': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||
'hire_date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
|
||||
'first_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'last_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'email': forms.EmailInput(attrs={'class': 'form-control'}),
|
||||
'phone': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'address': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'gender': forms.Select(attrs={'class': 'form-select'}),
|
||||
'department': forms.Select(attrs={'class': 'form-select'}),
|
||||
'job_position': forms.Select(attrs={'class': 'form-select'}),
|
||||
'status': forms.Select(attrs={'class': 'form-select'}),
|
||||
'salary': forms.NumberInput(attrs={'class': 'form-control'}),
|
||||
'user': forms.Select(attrs={'class': 'form-select'}),
|
||||
'biometric_id': forms.NumberInput(attrs={'class': 'form-control'}),
|
||||
}
|
||||
@ -9,100 +9,11 @@
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger">
|
||||
{% for field in form %}
|
||||
{% for error in field.errors %}
|
||||
<strong>{{ field.label }}:</strong> {{ error }}<br>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% for error in form.non_field_errors %}
|
||||
{{ error }}<br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h5 class="text-primary mb-3">{% trans "Personal Information" %}</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.first_name.id_for_label }}" class="form-label">{{ form.first_name.label }}</label>
|
||||
{{ form.first_name }}
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.last_name.id_for_label }}" class="form-label">{{ form.last_name.label }}</label>
|
||||
{{ form.last_name }}
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.gender.id_for_label }}" class="form-label">{{ form.gender.label }}</label>
|
||||
{{ form.gender }}
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.date_of_birth.id_for_label }}" class="form-label">{{ form.date_of_birth.label }}</label>
|
||||
{{ form.date_of_birth }}
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.email.id_for_label }}" class="form-label">{{ form.email.label }}</label>
|
||||
{{ form.email }}
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.phone.id_for_label }}" class="form-label">{{ form.phone.label }}</label>
|
||||
{{ form.phone }}
|
||||
</div>
|
||||
<div class="col-12 mb-3">
|
||||
<label for="{{ form.address.id_for_label }}" class="form-label">{{ form.address.label }}</label>
|
||||
{{ form.address }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<h5 class="text-primary mb-3">{% trans "Job Details" %}</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.department.id_for_label }}" class="form-label">{{ form.department.label }}</label>
|
||||
{{ form.department }}
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.job_position.id_for_label }}" class="form-label">{{ form.job_position.label }}</label>
|
||||
{{ form.job_position }}
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="{{ form.hire_date.id_for_label }}" class="form-label">{{ form.hire_date.label }}</label>
|
||||
{{ form.hire_date }}
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="{{ form.status.id_for_label }}" class="form-label">{{ form.status.label }}</label>
|
||||
{{ form.status }}
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="{{ form.salary.id_for_label }}" class="form-label">{{ form.salary.label }}</label>
|
||||
{{ form.salary }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<h5 class="text-primary mb-3">{% trans "System Access & Biometrics" %}</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.user.id_for_label }}" class="form-label">{{ form.user.label }}</label>
|
||||
{{ form.user }}
|
||||
<small class="form-text text-muted">{% trans "Link to a system user account for login access." %}</small>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ form.biometric_id.id_for_label }}" class="form-label">{{ form.biometric_id.label }}</label>
|
||||
{{ form.biometric_id }}
|
||||
<small class="form-text text-muted">{% trans "User ID on the physical biometric device." %}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">{% trans "Save" %}</button>
|
||||
<a href="{% url 'hr:employee_list' %}" class="btn btn-secondary">{% trans "Cancel" %}</a>
|
||||
</div>
|
||||
{{ form.as_p }}
|
||||
<button type="submit" class="btn btn-primary">{% trans "Save" %}</button>
|
||||
<a href="{% url 'hr:employee_list' %}" class="btn btn-secondary">{% trans "Cancel" %}</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Biometric ID" %}</th>
|
||||
<th>{% trans "Department" %}</th>
|
||||
<th>{% trans "Position" %}</th>
|
||||
<th>{% trans "Email" %}</th>
|
||||
@ -34,7 +33,6 @@
|
||||
{{ employee.first_name }} {{ employee.last_name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ employee.biometric_id|default:"-" }}</td>
|
||||
<td>{{ employee.department.name_en }} / {{ employee.department.name_ar }}</td>
|
||||
<td>{{ employee.job_position.title_en }} / {{ employee.job_position.title_ar }}</td>
|
||||
<td>{{ employee.email }}</td>
|
||||
@ -52,7 +50,7 @@
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center">{% trans "No employees found." %}</td>
|
||||
<td colspan="7" class="text-center">{% trans "No employees found." %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@ -61,4 +59,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -3,7 +3,6 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from .models import Employee, Department, Attendance, LeaveRequest, JobPosition, BiometricDevice
|
||||
from .forms import EmployeeForm
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.contrib import messages
|
||||
@ -28,7 +27,7 @@ class EmployeeListView(LoginRequiredMixin, ListView):
|
||||
|
||||
class EmployeeCreateView(LoginRequiredMixin, CreateView):
|
||||
model = Employee
|
||||
form_class = EmployeeForm
|
||||
fields = '__all__'
|
||||
template_name = 'hr/employee_form.html'
|
||||
success_url = reverse_lazy('hr:employee_list')
|
||||
|
||||
@ -39,7 +38,7 @@ class EmployeeCreateView(LoginRequiredMixin, CreateView):
|
||||
|
||||
class EmployeeUpdateView(LoginRequiredMixin, UpdateView):
|
||||
model = Employee
|
||||
form_class = EmployeeForm
|
||||
fields = '__all__'
|
||||
template_name = 'hr/employee_form.html'
|
||||
success_url = reverse_lazy('hr:employee_list')
|
||||
|
||||
@ -142,4 +141,4 @@ def sync_device_logs(request, pk):
|
||||
else:
|
||||
messages.success(request, _("Sync Successful! Fetched %(total)s records, %(new)s new.") % {'total': result['total'], 'new': result['new']})
|
||||
|
||||
return redirect('hr:device_list')
|
||||
return redirect('hr:device_list')
|
||||
|
||||
@ -2,9 +2,7 @@
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
import pymysql
|
||||
|
||||
pymysql.install_as_MySQLdb()
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
@ -21,4 +19,4 @@ def main():
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
main()
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
Django==5.2.7
|
||||
pymysql
|
||||
mysqlclient==2.2.7
|
||||
python-dotenv==1.1.1
|
||||
pyzk==0.9
|
||||
requests==2.32.3
|
||||
Loading…
x
Reference in New Issue
Block a user