Autosave: 20260211-073630
This commit is contained in:
parent
48923270af
commit
a30bc16ccc
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -250,16 +250,55 @@
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
|
||||
<script>
|
||||
function downloadPDF() {
|
||||
function getCompactStyle() {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'temp-pdf-style';
|
||||
style.textContent = `
|
||||
/* Cairo loaded in base.html */
|
||||
#invoice-card {
|
||||
font-family: 'Cairo', sans-serif !important;
|
||||
padding: 20px !important;
|
||||
direction: rtl;
|
||||
}
|
||||
#invoice-card * {
|
||||
font-family: 'Cairo', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Compact overrides */
|
||||
.p-5 { padding: 10px !important; }
|
||||
.mb-5 { margin-bottom: 10px !important; }
|
||||
.mx-5 { margin-left: 0 !important; margin-right: 0 !important; }
|
||||
.h1, h1 { font-size: 22px !important; margin-bottom: 5px !important; }
|
||||
.h3, h3 { font-size: 18px !important; }
|
||||
.h5, h5 { font-size: 14px !important; }
|
||||
.table th, .table td { padding: 4px 8px !important; font-size: 11px !important; }
|
||||
.small { font-size: 10px !important; }
|
||||
img { max-height: 50px !important; margin-bottom: 10px !important; }
|
||||
.badge { padding: 2px 8px !important; font-size: 10px !important; }
|
||||
`;
|
||||
return style;
|
||||
}
|
||||
|
||||
async function downloadPDF() {
|
||||
const element = document.getElementById('invoice-card');
|
||||
const style = getCompactStyle();
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Wait for fonts
|
||||
await document.fonts.ready;
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const opt = {
|
||||
margin: 0,
|
||||
margin: [10, 10, 10, 10],
|
||||
filename: 'Invoice_{{ sale.invoice_number|default:sale.id }}.pdf',
|
||||
image: { type: 'jpeg', quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true, letterRendering: true },
|
||||
html2canvas: { scale: 2, useCORS: true, letterRendering: false },
|
||||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
|
||||
};
|
||||
html2pdf().set(opt).from(element).save();
|
||||
|
||||
html2pdf().set(opt).from(element).save().then(() => {
|
||||
style.remove();
|
||||
});
|
||||
}
|
||||
|
||||
async function sendWhatsAppDirect() {
|
||||
@ -275,13 +314,20 @@ async function sendWhatsAppDirect() {
|
||||
spinner.classList.remove('d-none');
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const element = document.getElementById('invoice-card');
|
||||
const style = getCompactStyle();
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Wait for fonts
|
||||
await document.fonts.ready;
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
try {
|
||||
const opt = {
|
||||
margin: 0,
|
||||
margin: [10, 10, 10, 10],
|
||||
filename: 'Invoice_{{ sale.invoice_number|default:sale.id }}.pdf',
|
||||
image: { type: 'jpeg', quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true, letterRendering: true },
|
||||
html2canvas: { scale: 2, useCORS: true, letterRendering: false },
|
||||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
|
||||
};
|
||||
|
||||
@ -306,7 +352,7 @@ async function sendWhatsAppDirect() {
|
||||
if (data.success) {
|
||||
alert(data.message || "{% trans 'Invoice sent successfully!' %}");
|
||||
} else {
|
||||
alert(data.error || data.message || "{% trans 'Failed to send invoice.' %}");
|
||||
alert((data.error || data.message || "{% trans 'Failed to send invoice.' %}") + '\n\n{% trans "Please check your System Settings > WhatsApp Gateway configuration." %}');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@ -314,21 +360,10 @@ async function sendWhatsAppDirect() {
|
||||
} finally {
|
||||
spinner.classList.add('d-none');
|
||||
btn.disabled = false;
|
||||
style.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-trigger if 'created=true' is in URL
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get("created") === "true") {
|
||||
const phone = document.getElementById('whatsappPhoneDirect').value;
|
||||
if (phone) {
|
||||
sendWhatsAppDirect();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
@page {
|
||||
|
||||
@ -240,6 +240,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
|
||||
<script>
|
||||
function openWhatsAppModal(saleId, phone, invoiceNum) {
|
||||
document.getElementById('waSaleId').value = saleId;
|
||||
@ -262,30 +263,125 @@ async function sendWhatsAppFromList() {
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('d-none');
|
||||
|
||||
// Create a temporary container for the invoice
|
||||
const tempContainer = document.createElement('div');
|
||||
tempContainer.style.position = 'absolute';
|
||||
tempContainer.style.left = '-9999px';
|
||||
tempContainer.style.top = '0';
|
||||
tempContainer.style.width = '790px'; // Slightly less than A4 width to prevent overflow
|
||||
document.body.appendChild(tempContainer);
|
||||
|
||||
try {
|
||||
// 1. Fetch the invoice detail page
|
||||
const invoiceResponse = await fetch(`/invoices/${saleId}/`);
|
||||
const invoiceHtml = await invoiceResponse.text();
|
||||
|
||||
// 2. Parse and extract the invoice card and styles
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(invoiceHtml, 'text/html');
|
||||
const invoiceCard = doc.getElementById('invoice-card');
|
||||
const styles = doc.querySelectorAll('style');
|
||||
|
||||
if (!invoiceCard) {
|
||||
throw new Error("Could not load invoice template");
|
||||
}
|
||||
|
||||
// Append styles to head temporarily
|
||||
const addedStyles = [];
|
||||
|
||||
// Force Cairo Font for Arabic support AND compact styles for single page
|
||||
const customStyle = document.createElement('style');
|
||||
customStyle.textContent = `
|
||||
/* Cairo is already loaded in base.html, just apply it */
|
||||
#invoice-card {
|
||||
font-family: 'Cairo', sans-serif !important;
|
||||
padding: 20px !important;
|
||||
background: white !important;
|
||||
direction: rtl; /* Force RTL for better Arabic support in canvas */
|
||||
}
|
||||
#invoice-card * {
|
||||
font-family: 'Cairo', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Compact overrides */
|
||||
.p-5 { padding: 10px !important; }
|
||||
.mb-5 { margin-bottom: 10px !important; }
|
||||
.mx-5 { margin-left: 0 !important; margin-right: 0 !important; }
|
||||
.h1, h1 { font-size: 22px !important; margin-bottom: 5px !important; }
|
||||
.h3, h3 { font-size: 18px !important; }
|
||||
.h5, h5 { font-size: 14px !important; }
|
||||
.table td, .table th { padding: 4px 8px !important; font-size: 11px !important; }
|
||||
.small { font-size: 10px !important; }
|
||||
img { max-height: 50px !important; margin-bottom: 10px !important; }
|
||||
.badge { padding: 2px 8px !important; font-size: 10px !important; }
|
||||
`;
|
||||
document.head.appendChild(customStyle);
|
||||
addedStyles.push(customStyle);
|
||||
|
||||
styles.forEach(style => {
|
||||
const newStyle = document.createElement('style');
|
||||
newStyle.textContent = style.textContent;
|
||||
document.head.appendChild(newStyle);
|
||||
addedStyles.push(newStyle);
|
||||
});
|
||||
|
||||
// Clean up for PDF (remove buttons/non-printable if any leaked)
|
||||
const noPrint = invoiceCard.querySelectorAll('.d-print-none');
|
||||
noPrint.forEach(el => el.remove());
|
||||
|
||||
tempContainer.appendChild(invoiceCard);
|
||||
|
||||
// Wait for fonts and layout
|
||||
await document.fonts.ready;
|
||||
await new Promise(resolve => setTimeout(resolve, 800)); // Give browser time to render ligatures
|
||||
|
||||
// 3. Generate PDF
|
||||
const opt = {
|
||||
margin: [10, 10, 10, 10], // Top, Left, Bottom, Right margins in mm
|
||||
filename: `Invoice_${saleId}.pdf`,
|
||||
image: { type: 'jpeg', quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true, letterRendering: false }, // letterRendering: false fixes Arabic
|
||||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
|
||||
};
|
||||
|
||||
const pdfBlob = await html2pdf().set(opt).from(invoiceCard).outputPdf('datauristring');
|
||||
|
||||
// Remove temporary styles
|
||||
addedStyles.forEach(style => style.remove());
|
||||
|
||||
// 4. Send to Backend
|
||||
const response = await fetch("{% url 'send_invoice_whatsapp' %}", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({ sale_id: saleId, phone: phone })
|
||||
body: JSON.stringify({
|
||||
sale_id: saleId,
|
||||
phone: phone,
|
||||
pdf_data: pdfBlob
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert(data.message || "{% trans 'Message sent successfully!' %}");
|
||||
bootstrap.Modal.getInstance(document.getElementById('whatsappModal')).hide();
|
||||
alert('{% trans "Invoice sent via WhatsApp successfully!" %}');
|
||||
const waModalEl = document.getElementById('whatsappModal');
|
||||
const modalInstance = bootstrap.Modal.getInstance(waModalEl);
|
||||
if (modalInstance) {
|
||||
modalInstance.hide();
|
||||
}
|
||||
} else {
|
||||
alert(data.error || "{% trans 'Failed to send message.' %}");
|
||||
alert('{% trans "WhatsApp Error:" %} ' + data.error + '\n\n{% trans "Please check your System Settings > WhatsApp Gateway configuration." %}');
|
||||
}
|
||||
} catch (e) {
|
||||
alert("{% trans 'An error occurred.' %}");
|
||||
alert("{% trans 'An error occurred while generating or sending the invoice.' %}");
|
||||
console.error(e);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
spinner.classList.add('d-none');
|
||||
document.body.removeChild(tempContainer);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -169,16 +169,54 @@
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
|
||||
<script>
|
||||
function downloadPDF() {
|
||||
function getCompactStyle() {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'temp-pdf-style';
|
||||
style.textContent = `
|
||||
/* Cairo loaded in base.html */
|
||||
#order-card {
|
||||
font-family: 'Cairo', sans-serif !important;
|
||||
padding: 20px !important;
|
||||
direction: rtl;
|
||||
}
|
||||
#order-card * {
|
||||
font-family: 'Cairo', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Compact overrides */
|
||||
.p-5 { padding: 10px !important; }
|
||||
.mb-5 { margin-bottom: 10px !important; }
|
||||
.mx-5 { margin-left: 0 !important; margin-right: 0 !important; }
|
||||
.h1, h1 { font-size: 22px !important; margin-bottom: 5px !important; }
|
||||
.h3, h3 { font-size: 18px !important; }
|
||||
.h5, h5 { font-size: 14px !important; }
|
||||
.table th, .table td { padding: 4px 8px !important; font-size: 11px !important; }
|
||||
.small { font-size: 10px !important; }
|
||||
img { max-height: 50px !important; margin-bottom: 10px !important; }
|
||||
.badge { padding: 2px 8px !important; font-size: 10px !important; }
|
||||
`;
|
||||
return style;
|
||||
}
|
||||
|
||||
async function downloadPDF() {
|
||||
const element = document.getElementById('order-card');
|
||||
const style = getCompactStyle();
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Wait for fonts
|
||||
await document.fonts.ready;
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const opt = {
|
||||
margin: 0,
|
||||
margin: [10, 10, 10, 10],
|
||||
filename: 'LPO_{{ order.lpo_number|default:order.id }}.pdf',
|
||||
image: { type: 'jpeg', quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true, letterRendering: true },
|
||||
html2canvas: { scale: 2, useCORS: true, letterRendering: false },
|
||||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
|
||||
};
|
||||
html2pdf().set(opt).from(element).save();
|
||||
html2pdf().set(opt).from(element).save().then(() => {
|
||||
style.remove();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -69,6 +69,11 @@
|
||||
<a href="{% url 'lpo_detail' order.id %}" class="btn btn-sm btn-white border" title="{% trans 'View & Print' %}">
|
||||
<i class="bi bi-printer"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-white border text-success"
|
||||
onclick="openWhatsAppModal('{{ order.id }}', '{{ order.supplier.phone|default:'' }}', '{{ order.lpo_number|default:order.id }}')"
|
||||
title="{% trans 'Send via WhatsApp' %}">
|
||||
<i class="bi bi-whatsapp"></i>
|
||||
</button>
|
||||
{% if order.status != 'converted' %}
|
||||
<form action="{% url 'convert_lpo_to_purchase' order.id %}" method="POST" style="display: inline;" onsubmit="return confirm('{% trans 'Convert this LPO to a Purchase Invoice? This will update stock.' %}');">
|
||||
{% csrf_token %}
|
||||
@ -118,6 +123,186 @@
|
||||
{% include "core/pagination.html" with page_obj=orders %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WhatsApp Modal -->
|
||||
<div class="modal fade" id="whatsappModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-0 shadow rounded-4">
|
||||
<div class="modal-header border-0">
|
||||
<h5 class="fw-bold">{% trans "Send LPO via WhatsApp" %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="waLpoId">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">{% trans "LPO #" %}</label>
|
||||
<input type="text" id="waLpoNum" class="form-control-plaintext fw-bold" readonly>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">{% trans "Phone Number" %}</label>
|
||||
<input type="text" id="waPhone" class="form-control rounded-3" placeholder="e.g. 628123456789">
|
||||
<div class="form-text">{% trans "Enter number with country code (e.g., 62...)" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0">
|
||||
<button type="button" class="btn btn-light rounded-3" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
|
||||
<button type="button" class="btn btn-success rounded-3 px-4" onclick="sendWhatsAppFromList()">
|
||||
<span id="waSpinner" class="spinner-border spinner-border-sm d-none me-2"></span>
|
||||
{% trans "Send Message" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
|
||||
<script>
|
||||
function openWhatsAppModal(lpoId, phone, lpoNum) {
|
||||
document.getElementById('waLpoId').value = lpoId;
|
||||
document.getElementById('waPhone').value = phone;
|
||||
document.getElementById('waLpoNum').value = lpoNum;
|
||||
new bootstrap.Modal(document.getElementById('whatsappModal')).show();
|
||||
}
|
||||
|
||||
async function sendWhatsAppFromList() {
|
||||
const lpoId = document.getElementById('waLpoId').value;
|
||||
const phone = document.getElementById('waPhone').value;
|
||||
const btn = document.querySelector('#whatsappModal .btn-success');
|
||||
const spinner = document.getElementById('waSpinner');
|
||||
|
||||
if (!phone) {
|
||||
alert("{% trans 'Please enter a phone number.' %}");
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('d-none');
|
||||
|
||||
// Create a temporary container
|
||||
const tempContainer = document.createElement('div');
|
||||
tempContainer.style.position = 'absolute';
|
||||
tempContainer.style.left = '-9999px';
|
||||
tempContainer.style.top = '0';
|
||||
tempContainer.style.width = '790px'; // Compact width
|
||||
document.body.appendChild(tempContainer);
|
||||
|
||||
try {
|
||||
// 1. Fetch detail page
|
||||
const response = await fetch(`/lpo/${lpoId}/`);
|
||||
const html = await response.text();
|
||||
|
||||
// 2. Parse
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const card = doc.getElementById('order-card'); // ID in lpo_detail.html
|
||||
const styles = doc.querySelectorAll('style');
|
||||
|
||||
if (!card) {
|
||||
throw new Error("Could not load LPO template");
|
||||
}
|
||||
|
||||
// Append styles
|
||||
const addedStyles = [];
|
||||
|
||||
// Force Cairo Font and Compact Styles
|
||||
const customStyle = document.createElement('style');
|
||||
customStyle.textContent = `
|
||||
/* Cairo loaded in base.html */
|
||||
#order-card {
|
||||
font-family: 'Cairo', sans-serif !important;
|
||||
padding: 20px !important;
|
||||
background: white !important;
|
||||
direction: rtl;
|
||||
}
|
||||
#order-card * {
|
||||
font-family: 'Cairo', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Compact overrides */
|
||||
.p-5 { padding: 10px !important; }
|
||||
.mb-5 { margin-bottom: 10px !important; }
|
||||
.mx-5 { margin-left: 0 !important; margin-right: 0 !important; }
|
||||
.h1, h1 { font-size: 22px !important; margin-bottom: 5px !important; }
|
||||
.h3, h3 { font-size: 18px !important; }
|
||||
.h5, h5 { font-size: 14px !important; }
|
||||
.table td, .table th { padding: 4px 8px !important; font-size: 11px !important; }
|
||||
.small { font-size: 10px !important; }
|
||||
img { max-height: 50px !important; margin-bottom: 10px !important; }
|
||||
.badge { padding: 2px 8px !important; font-size: 10px !important; }
|
||||
`;
|
||||
document.head.appendChild(customStyle);
|
||||
addedStyles.push(customStyle);
|
||||
|
||||
styles.forEach(style => {
|
||||
const newStyle = document.createElement('style');
|
||||
newStyle.textContent = style.textContent;
|
||||
document.head.appendChild(newStyle);
|
||||
addedStyles.push(newStyle);
|
||||
});
|
||||
|
||||
// Clean up
|
||||
const noPrint = card.querySelectorAll('.d-print-none');
|
||||
noPrint.forEach(el => el.remove());
|
||||
|
||||
tempContainer.appendChild(card);
|
||||
|
||||
// Wait for fonts and layout
|
||||
await document.fonts.ready;
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
// 3. Generate PDF
|
||||
const opt = {
|
||||
margin: [10, 10, 10, 10],
|
||||
filename: `LPO_${lpoId}.pdf`,
|
||||
image: { type: 'jpeg', quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true, letterRendering: false },
|
||||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
|
||||
};
|
||||
|
||||
const pdfBlob = await html2pdf().set(opt).from(card).outputPdf('datauristring');
|
||||
|
||||
// Remove styles
|
||||
addedStyles.forEach(style => style.remove());
|
||||
|
||||
// 4. Send to Backend
|
||||
const apiResponse = await fetch("{% url 'send_lpo_whatsapp' %}", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
lpo_id: lpoId,
|
||||
phone: phone,
|
||||
pdf_data: pdfBlob
|
||||
})
|
||||
});
|
||||
|
||||
const data = await apiResponse.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('{% trans "LPO sent via WhatsApp successfully!" %}');
|
||||
const waModalEl = document.getElementById('whatsappModal');
|
||||
const modalInstance = bootstrap.Modal.getInstance(waModalEl);
|
||||
if (modalInstance) {
|
||||
modalInstance.hide();
|
||||
}
|
||||
} else {
|
||||
alert('{% trans "WhatsApp Error:" %} ' + data.error + '\n\n{% trans "Please check your System Settings > WhatsApp Gateway configuration." %}');
|
||||
}
|
||||
} catch (e) {
|
||||
alert("{% trans 'An error occurred while generating or sending the LPO.' %}");
|
||||
console.error(e);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
spinner.classList.add('d-none');
|
||||
document.body.removeChild(tempContainer);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -213,16 +213,55 @@
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
|
||||
<script>
|
||||
function downloadPDF() {
|
||||
function getCompactStyle() {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'temp-pdf-style';
|
||||
style.textContent = `
|
||||
/* Cairo loaded in base.html */
|
||||
#quotation-card {
|
||||
font-family: 'Cairo', sans-serif !important;
|
||||
padding: 20px !important;
|
||||
direction: rtl;
|
||||
}
|
||||
#quotation-card * {
|
||||
font-family: 'Cairo', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Compact overrides */
|
||||
.p-5 { padding: 10px !important; }
|
||||
.mb-5 { margin-bottom: 10px !important; }
|
||||
.mx-5 { margin-left: 0 !important; margin-right: 0 !important; }
|
||||
.h1, h1 { font-size: 22px !important; margin-bottom: 5px !important; }
|
||||
.h3, h3 { font-size: 18px !important; }
|
||||
.h5, h5 { font-size: 14px !important; }
|
||||
.table th, .table td { padding: 4px 8px !important; font-size: 11px !important; }
|
||||
.small { font-size: 10px !important; }
|
||||
img { max-height: 50px !important; margin-bottom: 10px !important; }
|
||||
.badge { padding: 2px 8px !important; font-size: 10px !important; }
|
||||
`;
|
||||
return style;
|
||||
}
|
||||
|
||||
async function downloadPDF() {
|
||||
const element = document.getElementById('quotation-card');
|
||||
const style = getCompactStyle();
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Wait for fonts
|
||||
await document.fonts.ready;
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const opt = {
|
||||
margin: 0,
|
||||
margin: [10, 10, 10, 10],
|
||||
filename: 'Quotation_{{ quotation.quotation_number|default:quotation.id }}.pdf',
|
||||
image: { type: 'jpeg', quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true, letterRendering: true },
|
||||
html2canvas: { scale: 2, useCORS: true, letterRendering: false },
|
||||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
|
||||
};
|
||||
html2pdf().set(opt).from(element).save();
|
||||
|
||||
html2pdf().set(opt).from(element).save().then(() => {
|
||||
style.remove();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -72,6 +72,11 @@
|
||||
<td>{{ q.valid_until|date:"Y-m-d"|default:"-" }}</td>
|
||||
<td class="text-end pe-4">
|
||||
<div class="btn-group shadow-sm rounded-3">
|
||||
<button type="button" class="btn btn-sm btn-white border text-success"
|
||||
onclick="openWhatsAppModal('{{ q.id }}', '{{ q.customer.phone|default:'' }}', '{{ q.quotation_number|default:q.id }}')"
|
||||
title="{% trans 'Send via WhatsApp' %}">
|
||||
<i class="bi bi-whatsapp"></i>
|
||||
</button>
|
||||
<a href="{% url 'quotation_detail' q.id %}" class="btn btn-sm btn-white border" title="{% trans 'View & Print' %}">
|
||||
<i class="bi bi-printer"></i>
|
||||
</a>
|
||||
@ -138,5 +143,181 @@
|
||||
{% include "core/pagination.html" with page_obj=quotations %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- WhatsApp Modal -->
|
||||
<div class="modal fade" id="whatsappModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-0 shadow rounded-4">
|
||||
<div class="modal-header border-0">
|
||||
<h5 class="fw-bold">{% trans "Send Quotation via WhatsApp" %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="waQuotationId">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">{% trans "Quotation #" %}</label>
|
||||
<input type="text" id="waQuotationNum" class="form-control-plaintext fw-bold" readonly>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">{% trans "Phone Number" %}</label>
|
||||
<input type="text" id="waPhone" class="form-control rounded-3" placeholder="e.g. 628123456789">
|
||||
<div class="form-text">{% trans "Enter number with country code (e.g., 62...)" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0">
|
||||
<button type="button" class="btn btn-light rounded-3" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
|
||||
<button type="button" class="btn btn-success rounded-3 px-4" onclick="sendWhatsAppFromList()">
|
||||
<span id="waSpinner" class="spinner-border spinner-border-sm d-none me-2"></span>
|
||||
{% trans "Send Message" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
|
||||
<script>
|
||||
function openWhatsAppModal(quotationId, phone, quotationNum) {
|
||||
document.getElementById('waQuotationId').value = quotationId;
|
||||
document.getElementById('waPhone').value = phone;
|
||||
document.getElementById('waQuotationNum').value = quotationNum;
|
||||
new bootstrap.Modal(document.getElementById('whatsappModal')).show();
|
||||
}
|
||||
|
||||
async function sendWhatsAppFromList() {
|
||||
const quotationId = document.getElementById('waQuotationId').value;
|
||||
const phone = document.getElementById('waPhone').value;
|
||||
const btn = document.querySelector('#whatsappModal .btn-success');
|
||||
const spinner = document.getElementById('waSpinner');
|
||||
|
||||
if (!phone) {
|
||||
alert("{% trans 'Please enter a phone number.' %}");
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('d-none');
|
||||
|
||||
// Create a temporary container for the quotation
|
||||
const tempContainer = document.createElement('div');
|
||||
tempContainer.style.position = 'absolute';
|
||||
tempContainer.style.left = '-9999px';
|
||||
tempContainer.style.top = '0';
|
||||
tempContainer.style.width = '790px'; // Slightly less than A4
|
||||
document.body.appendChild(tempContainer);
|
||||
|
||||
try {
|
||||
// 1. Fetch the quotation detail page
|
||||
const quotationResponse = await fetch(`/quotations/${quotationId}/`);
|
||||
const quotationHtml = await quotationResponse.text();
|
||||
|
||||
// 2. Parse and extract the quotation card and styles
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(quotationHtml, 'text/html');
|
||||
const quotationCard = doc.getElementById('quotation-card');
|
||||
const styles = doc.querySelectorAll('style');
|
||||
|
||||
if (!quotationCard) {
|
||||
throw new Error("Could not load quotation template");
|
||||
}
|
||||
|
||||
// Append styles to head temporarily
|
||||
const addedStyles = [];
|
||||
|
||||
// Force Cairo Font and Compact Styles
|
||||
const customStyle = document.createElement('style');
|
||||
customStyle.textContent = `
|
||||
/* Cairo loaded in base.html */
|
||||
#quotation-card {
|
||||
font-family: 'Cairo', sans-serif !important;
|
||||
padding: 20px !important;
|
||||
background: white !important;
|
||||
direction: rtl;
|
||||
}
|
||||
#quotation-card * {
|
||||
font-family: 'Cairo', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Compact overrides */
|
||||
.p-5 { padding: 10px !important; }
|
||||
.mb-5 { margin-bottom: 10px !important; }
|
||||
.mx-5 { margin-left: 0 !important; margin-right: 0 !important; }
|
||||
.h1, h1 { font-size: 22px !important; margin-bottom: 5px !important; }
|
||||
.h3, h3 { font-size: 18px !important; }
|
||||
.h5, h5 { font-size: 14px !important; }
|
||||
.table td, .table th { padding: 4px 8px !important; font-size: 11px !important; }
|
||||
.small { font-size: 10px !important; }
|
||||
img { max-height: 50px !important; margin-bottom: 10px !important; }
|
||||
.badge { padding: 2px 8px !important; font-size: 10px !important; }
|
||||
`;
|
||||
document.head.appendChild(customStyle);
|
||||
addedStyles.push(customStyle);
|
||||
|
||||
styles.forEach(style => {
|
||||
const newStyle = document.createElement('style');
|
||||
newStyle.textContent = style.textContent;
|
||||
document.head.appendChild(newStyle);
|
||||
addedStyles.push(newStyle);
|
||||
});
|
||||
|
||||
// Clean up for PDF (remove buttons/non-printable if any leaked)
|
||||
const noPrint = quotationCard.querySelectorAll('.d-print-none');
|
||||
noPrint.forEach(el => el.remove());
|
||||
|
||||
tempContainer.appendChild(quotationCard);
|
||||
|
||||
// Wait for fonts and layout
|
||||
await document.fonts.ready;
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
// 3. Generate PDF
|
||||
const opt = {
|
||||
margin: [10, 10, 10, 10],
|
||||
filename: `Quotation_${quotationId}.pdf`,
|
||||
image: { type: 'jpeg', quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true, letterRendering: false },
|
||||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
|
||||
};
|
||||
|
||||
const pdfBlob = await html2pdf().set(opt).from(quotationCard).outputPdf('datauristring');
|
||||
|
||||
// Remove temporary styles
|
||||
addedStyles.forEach(style => style.remove());
|
||||
|
||||
// 4. Send to Backend
|
||||
const response = await fetch("{% url 'send_quotation_whatsapp' %}", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
quotation_id: quotationId,
|
||||
phone: phone,
|
||||
pdf_data: pdfBlob
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('{% trans "Quotation sent via WhatsApp successfully!" %}');
|
||||
const waModalEl = document.getElementById('whatsappModal');
|
||||
const modalInstance = bootstrap.Modal.getInstance(waModalEl);
|
||||
if (modalInstance) {
|
||||
modalInstance.hide();
|
||||
}
|
||||
} else {
|
||||
alert('{% trans "WhatsApp Error:" %} ' + data.error + '\n\n{% trans "Please check your System Settings > WhatsApp Gateway configuration." %}');
|
||||
}
|
||||
} catch (e) {
|
||||
alert("{% trans 'An error occurred while generating or sending the quotation.' %}");
|
||||
console.error(e);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
spinner.classList.add('d-none');
|
||||
document.body.removeChild(tempContainer);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -373,83 +373,6 @@
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Edit Device Modal -->
|
||||
<div class="modal fade" id="editDeviceModal{{ device.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-0">
|
||||
<form action="{% url 'edit_device' device.id %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">{% trans "Edit Device" %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{% trans "Device Name" %}</label>
|
||||
<input type="text" name="name" class="form-control" value="{{ device.name }}" required>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">{% trans "Type" %}</label>
|
||||
<select name="device_type" class="form-select">
|
||||
<option value="counter" {% if device.device_type == 'counter' %}selected{% endif %}>{% trans "POS Counter" %}</option>
|
||||
<option value="printer" {% if device.device_type == 'printer' %}selected{% endif %}>{% trans "Printer" %}</option>
|
||||
<option value="scanner" {% if device.device_type == 'scanner' %}selected{% endif %}>{% trans "Scanner" %}</option>
|
||||
<option value="scale" {% if device.device_type == 'scale' %}selected{% endif %}>{% trans "Weight Scale" %}</option>
|
||||
<option value="display" {% if device.device_type == 'display' %}selected{% endif %}>{% trans "Customer Display" %}</option>
|
||||
<option value="other" {% if device.device_type == 'other' %}selected{% endif %}>{% trans "Other" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">{% trans "Connection" %}</label>
|
||||
<select name="connection_type" class="form-select">
|
||||
<option value="network" {% if device.connection_type == 'network' %}selected{% endif %}>{% trans "Network (IP)" %}</option>
|
||||
<option value="usb" {% if device.connection_type == 'usb' %}selected{% endif %}>{% trans "USB" %}</option>
|
||||
<option value="bluetooth" {% if device.connection_type == 'bluetooth' %}selected{% endif %}>{% trans "Bluetooth" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-semibold">{% trans "IP Address" %}</label>
|
||||
<input type="text" name="ip_address" class="form-control" value="{{ device.ip_address|default:'' }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">{% trans "Port" %}</label>
|
||||
<input type="number" name="port" class="form-control" value="{{ device.port|default:'' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check form-switch mt-3">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" {% if device.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label">{% trans "Active" %}</label>
|
||||
</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>
|
||||
|
||||
<!-- Delete Device Modal -->
|
||||
<div class="modal fade" id="deleteDeviceModal{{ device.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-0">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">{% trans "Confirm Delete" %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{% trans "Are you sure you want to delete" %} <strong>{{ device.name }}</strong>?</p>
|
||||
</div>
|
||||
<div class="modal-footer bg-light border-0">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
|
||||
<a href="{% url 'delete_device' device.id %}" class="btn btn-danger">{% trans "Delete" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-5">
|
||||
@ -509,53 +432,6 @@
|
||||
</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">
|
||||
@ -658,6 +534,217 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= MODALS SECTION ================= -->
|
||||
|
||||
<!-- Payment Method Modals -->
|
||||
{% for pm in payment_methods %}
|
||||
<!-- Edit Modal -->
|
||||
<div class="modal fade" id="editPaymentModal{{ pm.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-0">
|
||||
<form action="{% url 'edit_payment_method' pm.id %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">{% trans "Edit Payment Method" %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{% trans "Name (English)" %}</label>
|
||||
<input type="text" name="name_en" class="form-control" value="{{ pm.name_en }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{% trans "Name (Arabic)" %}</label>
|
||||
<input type="text" name="name_ar" class="form-control" value="{{ pm.name_ar }}" required>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" {% if pm.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label">{% trans "Active" %}</label>
|
||||
</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>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div class="modal fade" id="deletePaymentModal{{ pm.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-0">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">{% trans "Confirm Delete" %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{% trans "Are you sure you want to delete" %} <strong>{{ pm.name_en }}</strong>?</p>
|
||||
<div class="alert alert-warning mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
{% trans "Deleting this will not affect historical records but it will no longer be available for new transactions." %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer bg-light border-0">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
|
||||
<a href="{% url 'delete_payment_method' pm.id %}" class="btn btn-danger">{% trans "Delete" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Device Modals -->
|
||||
{% for device in devices %}
|
||||
<!-- Edit Device Modal -->
|
||||
<div class="modal fade" id="editDeviceModal{{ device.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-0">
|
||||
<form action="{% url 'edit_device' device.id %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">{% trans "Edit Device" %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{% trans "Device Name" %}</label>
|
||||
<input type="text" name="name" class="form-control" value="{{ device.name }}" required>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">{% trans "Type" %}</label>
|
||||
<select name="device_type" class="form-select">
|
||||
<option value="counter" {% if device.device_type == 'counter' %}selected{% endif %}>{% trans "POS Counter" %}</option>
|
||||
<option value="printer" {% if device.device_type == 'printer' %}selected{% endif %}>{% trans "Printer" %}</option>
|
||||
<option value="scanner" {% if device.device_type == 'scanner' %}selected{% endif %}>{% trans "Scanner" %}</option>
|
||||
<option value="scale" {% if device.device_type == 'scale' %}selected{% endif %}>{% trans "Weight Scale" %}</option>
|
||||
<option value="display" {% if device.device_type == 'display' %}selected{% endif %}>{% trans "Customer Display" %}</option>
|
||||
<option value="other" {% if device.device_type == 'other' %}selected{% endif %}>{% trans "Other" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">{% trans "Connection" %}</label>
|
||||
<select name="connection_type" class="form-select">
|
||||
<option value="network" {% if device.connection_type == 'network' %}selected{% endif %}>{% trans "Network (IP)" %}</option>
|
||||
<option value="usb" {% if device.connection_type == 'usb' %}selected{% endif %}>{% trans "USB" %}</option>
|
||||
<option value="bluetooth" {% if device.connection_type == 'bluetooth' %}selected{% endif %}>{% trans "Bluetooth" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-semibold">{% trans "IP Address" %}</label>
|
||||
<input type="text" name="ip_address" class="form-control" value="{{ device.ip_address|default:'' }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">{% trans "Port" %}</label>
|
||||
<input type="number" name="port" class="form-control" value="{{ device.port|default:'' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check form-switch mt-3">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" {% if device.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label">{% trans "Active" %}</label>
|
||||
</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>
|
||||
|
||||
<!-- Delete Device Modal -->
|
||||
<div class="modal fade" id="deleteDeviceModal{{ device.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-0">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">{% trans "Confirm Delete" %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{% trans "Are you sure you want to delete" %} <strong>{{ device.name }}</strong>?</p>
|
||||
</div>
|
||||
<div class="modal-footer bg-light border-0">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
|
||||
<a href="{% url 'delete_device' device.id %}" class="btn btn-danger">{% trans "Delete" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Loyalty Tier Modals -->
|
||||
{% for tier in loyalty_tiers %}
|
||||
<!-- 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>
|
||||
|
||||
<!-- Delete Tier Modal (Optional, based on original code needing it, but not shown in original snippet for tiers, adding for consistency if button exists) -->
|
||||
<div class="modal fade" id="deleteTierModal{{ tier.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-0">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">{% trans "Confirm Delete" %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{% trans "Are you sure you want to delete this tier?" %}</p>
|
||||
</div>
|
||||
<div class="modal-footer bg-light border-0">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
|
||||
<a href="{% url 'delete_loyalty_tier' tier.id %}" class="btn btn-danger">{% trans "Delete" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
|
||||
<!-- Add Tier Modal -->
|
||||
<div class="modal fade" id="addTierModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
@ -787,7 +874,7 @@
|
||||
</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="button" class="btn btn-primary">{% trans "Save Device" %}</button>
|
||||
<button type="submit" class="btn btn-primary">{% trans "Save Device" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -141,6 +141,8 @@ urlpatterns = [
|
||||
|
||||
# WhatsApp
|
||||
path('api/send-invoice-whatsapp/', views.send_invoice_whatsapp, name='send_invoice_whatsapp'),
|
||||
path('api/send-quotation-whatsapp/', views.send_quotation_whatsapp, name='send_quotation_whatsapp'),
|
||||
path('api/send-lpo-whatsapp/', views.send_lpo_whatsapp, name='send_lpo_whatsapp'),
|
||||
path('api/test-whatsapp/', views.test_whatsapp_connection, name='test_whatsapp_connection'),
|
||||
|
||||
# Devices
|
||||
|
||||
@ -80,6 +80,10 @@ def send_whatsapp_message(phone, message):
|
||||
payload = {"phone": phone, "message": message}
|
||||
|
||||
response = requests.post(url, data=payload, headers=headers, timeout=10)
|
||||
|
||||
# Debug Logging
|
||||
print(f"Wablas Send Message Response: {response.status_code} - {response.text}")
|
||||
|
||||
data = response.json()
|
||||
if response.status_code == 200 and data.get('status') == True:
|
||||
return True, "Message sent successfully."
|
||||
@ -116,6 +120,10 @@ def send_whatsapp_document(phone, document_url, caption=""):
|
||||
}
|
||||
|
||||
response = requests.post(url, data=payload, headers=headers, timeout=15)
|
||||
|
||||
# Debug Logging
|
||||
print(f"Wablas Send Document Response: {response.status_code} - {response.text}")
|
||||
|
||||
data = response.json()
|
||||
if response.status_code == 200 and data.get('status') == True:
|
||||
return True, "Document sent successfully."
|
||||
|
||||
192
core/views.py
192
core/views.py
@ -43,16 +43,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@login_required
|
||||
def index(request):
|
||||
# Auto-Fix Migration on Home Page Load (Temporary)
|
||||
try:
|
||||
from django.core.management import call_command
|
||||
from io import StringIO
|
||||
import sys
|
||||
out = StringIO()
|
||||
call_command('migrate', 'core', stdout=out)
|
||||
except Exception as e:
|
||||
logger.error(f"Migration Fix Failed: {e}")
|
||||
|
||||
settings = SystemSetting.objects.first()
|
||||
if not settings:
|
||||
settings = SystemSetting.objects.create()
|
||||
@ -234,9 +224,9 @@ def settings_view(request):
|
||||
|
||||
# Handle WhatsApp update manually to avoid validation errors on other fields
|
||||
settings.wablas_enabled = request.POST.get('wablas_enabled') == 'on'
|
||||
settings.wablas_token = request.POST.get('wablas_token', '')
|
||||
settings.wablas_server_url = request.POST.get('wablas_server_url', '')
|
||||
settings.wablas_secret_key = request.POST.get('wablas_secret_key', '')
|
||||
settings.wablas_token = request.POST.get('wablas_token', '').strip()
|
||||
settings.wablas_server_url = request.POST.get('wablas_server_url', '').strip()
|
||||
settings.wablas_secret_key = request.POST.get('wablas_secret_key', '').strip()
|
||||
settings.save()
|
||||
messages.success(request, _("WhatsApp settings updated successfully."))
|
||||
return redirect(reverse('settings') + '#whatsapp')
|
||||
@ -1363,7 +1353,31 @@ def delete_device(request, pk):
|
||||
|
||||
@login_required
|
||||
def test_whatsapp_connection(request):
|
||||
return JsonResponse({'success': True, 'message': 'Connected'})
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'success': False, 'error': 'Invalid method'})
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
phone = data.get('phone')
|
||||
|
||||
if not phone:
|
||||
return JsonResponse({'success': False, 'error': 'Phone number required'})
|
||||
|
||||
success, msg = send_whatsapp_message(phone, "Test message from Smart Admin")
|
||||
|
||||
if success:
|
||||
return JsonResponse({'success': True, 'message': msg})
|
||||
else:
|
||||
# Enhanced Error Handling for Common Wablas Issues
|
||||
error_msg = str(msg)
|
||||
if "Access denied" in error_msg and "IP" in error_msg:
|
||||
error_msg += " <br><b>Action Required:</b> Go to Wablas Dashboard > Security and whitelist the IP shown in this error."
|
||||
|
||||
return JsonResponse({'success': False, 'error': error_msg})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WhatsApp Test Error: {e}")
|
||||
return JsonResponse({'success': False, 'error': str(e)})
|
||||
|
||||
@login_required
|
||||
def send_invoice_whatsapp(request):
|
||||
@ -1432,11 +1446,157 @@ def send_invoice_whatsapp(request):
|
||||
if success:
|
||||
return JsonResponse({'success': True, 'message': response_msg})
|
||||
else:
|
||||
return JsonResponse({'success': False, 'error': response_msg})
|
||||
# Enhanced Error Handling
|
||||
error_msg = str(response_msg)
|
||||
if "Access denied" in error_msg and "IP" in error_msg:
|
||||
error_msg += " <br><b>Action Required:</b> Go to Wablas Dashboard > Security and whitelist the IP shown in this error."
|
||||
|
||||
return JsonResponse({'success': False, 'error': error_msg})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WhatsApp Error: {e}")
|
||||
return JsonResponse({'success': False, 'error': str(e)}) # Changed to str(e) for clarity
|
||||
return JsonResponse({'success': False, 'error': str(e)})
|
||||
|
||||
@login_required
|
||||
def send_quotation_whatsapp(request):
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'success': False, 'error': 'Method not allowed'})
|
||||
|
||||
try:
|
||||
# Handle JSON payload
|
||||
data = json.loads(request.body)
|
||||
quotation_id = data.get('quotation_id')
|
||||
phone = data.get('phone')
|
||||
pdf_data = data.get('pdf_data') # Base64 string
|
||||
except json.JSONDecodeError:
|
||||
# Fallback to Form Data
|
||||
quotation_id = request.POST.get('quotation_id')
|
||||
phone = request.POST.get('phone')
|
||||
pdf_data = None
|
||||
|
||||
if not quotation_id:
|
||||
return JsonResponse({'success': False, 'error': 'Quotation ID missing'})
|
||||
|
||||
quotation = get_object_or_404(Quotation, pk=quotation_id)
|
||||
|
||||
if not phone:
|
||||
if quotation.customer and quotation.customer.phone:
|
||||
phone = quotation.customer.phone
|
||||
else:
|
||||
return JsonResponse({'success': False, 'error': 'Phone number missing'})
|
||||
|
||||
try:
|
||||
# If PDF data is present, save and send document
|
||||
if pdf_data:
|
||||
# Remove header if present (data:application/pdf;base64,)
|
||||
if ',' in pdf_data:
|
||||
pdf_data = pdf_data.split(',')[1]
|
||||
|
||||
file_data = base64.b64decode(pdf_data)
|
||||
dir_path = os.path.join(django_settings.MEDIA_ROOT, 'temp_quotations')
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
|
||||
filename = f"quotation_{quotation.id}_{int(timezone.now().timestamp())}.pdf"
|
||||
file_path = os.path.join(dir_path, filename)
|
||||
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(file_data)
|
||||
|
||||
# Construct URL
|
||||
file_url = request.build_absolute_uri(django_settings.MEDIA_URL + 'temp_quotations/' + filename)
|
||||
|
||||
success, response_msg = send_whatsapp_document(phone, file_url, caption=f"Quotation #{quotation.quotation_number or quotation.id}")
|
||||
|
||||
else:
|
||||
# Fallback to Text Link
|
||||
# Note: You might need a public view for quotation if you want to send a link
|
||||
# For now, we assume PDF is the primary method
|
||||
message = (
|
||||
f"Hello {quotation.customer.name if quotation.customer else 'Guest'},\n"
|
||||
f"Here is your quotation #{quotation.quotation_number or quotation.id}.\n"
|
||||
f"Total: {quotation.total_amount}\n"
|
||||
f"Thank you!"
|
||||
)
|
||||
|
||||
success, response_msg = send_whatsapp_message(phone, message)
|
||||
|
||||
if success:
|
||||
return JsonResponse({'success': True, 'message': response_msg})
|
||||
else:
|
||||
# Enhanced Error Handling
|
||||
error_msg = str(response_msg)
|
||||
if "Access denied" in error_msg and "IP" in error_msg:
|
||||
error_msg += " <br><b>Action Required:</b> Go to Wablas Dashboard > Security and whitelist the IP shown in this error."
|
||||
|
||||
return JsonResponse({'success': False, 'error': error_msg})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WhatsApp Error: {e}")
|
||||
return JsonResponse({'success': False, 'error': str(e)})
|
||||
|
||||
@login_required
|
||||
def send_lpo_whatsapp(request):
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'success': False, 'error': 'Method not allowed'})
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
lpo_id = data.get('lpo_id')
|
||||
phone = data.get('phone')
|
||||
pdf_data = data.get('pdf_data')
|
||||
except json.JSONDecodeError:
|
||||
lpo_id = request.POST.get('lpo_id')
|
||||
phone = request.POST.get('phone')
|
||||
pdf_data = None
|
||||
|
||||
if not lpo_id:
|
||||
return JsonResponse({'success': False, 'error': 'LPO ID missing'})
|
||||
|
||||
lpo = get_object_or_404(PurchaseOrder, pk=lpo_id)
|
||||
|
||||
if not phone:
|
||||
if lpo.supplier and lpo.supplier.phone:
|
||||
phone = lpo.supplier.phone
|
||||
else:
|
||||
return JsonResponse({'success': False, 'error': 'Phone number missing'})
|
||||
|
||||
try:
|
||||
if pdf_data:
|
||||
if ',' in pdf_data:
|
||||
pdf_data = pdf_data.split(',')[1]
|
||||
|
||||
file_data = base64.b64decode(pdf_data)
|
||||
dir_path = os.path.join(django_settings.MEDIA_ROOT, 'temp_lpos')
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
|
||||
filename = f"lpo_{lpo.id}_{int(timezone.now().timestamp())}.pdf"
|
||||
file_path = os.path.join(dir_path, filename)
|
||||
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(file_data)
|
||||
|
||||
file_url = request.build_absolute_uri(django_settings.MEDIA_URL + 'temp_lpos/' + filename)
|
||||
|
||||
success, response_msg = send_whatsapp_document(phone, file_url, caption=f"LPO #{lpo.lpo_number or lpo.id}")
|
||||
|
||||
if success and lpo.status == 'draft':
|
||||
lpo.status = 'sent'
|
||||
lpo.save()
|
||||
|
||||
else:
|
||||
success, response_msg = send_whatsapp_message(phone, f"Here is LPO #{lpo.lpo_number or lpo.id}")
|
||||
|
||||
if success:
|
||||
return JsonResponse({'success': True, 'message': response_msg})
|
||||
else:
|
||||
error_msg = str(response_msg)
|
||||
if "Access denied" in error_msg and "IP" in error_msg:
|
||||
error_msg += " <br><b>Action Required:</b> Go to Wablas Dashboard > Security and whitelist the IP shown in this error."
|
||||
return JsonResponse({'success': False, 'error': error_msg})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WhatsApp Error: {e}")
|
||||
return JsonResponse({'success': False, 'error': str(e)})
|
||||
|
||||
# --- LPO ---
|
||||
@login_required
|
||||
|
||||
BIN
media/temp_invoices/invoice_26_1770793647.pdf
Normal file
BIN
media/temp_invoices/invoice_26_1770793647.pdf
Normal file
Binary file not shown.
BIN
media/temp_invoices/invoice_26_1770794941.pdf
Normal file
BIN
media/temp_invoices/invoice_26_1770794941.pdf
Normal file
Binary file not shown.
BIN
media/temp_invoices/invoice_26_1770795250.pdf
Normal file
BIN
media/temp_invoices/invoice_26_1770795250.pdf
Normal file
Binary file not shown.
BIN
media/temp_quotations/quotation_1_1770794329.pdf
Normal file
BIN
media/temp_quotations/quotation_1_1770794329.pdf
Normal file
Binary file not shown.
2
staticfiles/js/JsBarcode.all.min.js
vendored
Normal file
2
staticfiles/js/JsBarcode.all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
staticfiles/pasted-20260210-182651-b15404c1.png
Normal file
BIN
staticfiles/pasted-20260210-182651-b15404c1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Loading…
x
Reference in New Issue
Block a user