Autosave: 20260508-031143

This commit is contained in:
Flatlogic Bot 2026-05-08 03:11:43 +00:00
parent 5a46bb40e1
commit ed140fd00d
13 changed files with 1731 additions and 309 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

257
index.php
View File

@ -1861,7 +1861,7 @@ if (isset($_GET['action']) || isset($_POST['action'])) {
// Render Card HTML // Render Card HTML
?> ?>
<div class="product-card" data-id="<?= $p['id'] ?>" data-name-en="<?= htmlspecialchars($p['name_en']) ?>" data-name-ar="<?= htmlspecialchars($p['name_ar']) ?>" data-price="<?= $p['sale_price'] ?>" data-sku="<?= htmlspecialchars($p['sku']) ?>" data-stock-quantity="<?= format_quantity($p['stock_quantity']) ?>" data-vat-rate="<?= $p['vat_rate'] ?>"> <div class="product-card" data-id="<?= $p['id'] ?>" data-name-en="<?= htmlspecialchars($p['name_en']) ?>" data-name-ar="<?= htmlspecialchars($p['name_ar']) ?>" data-price="<?= $p['sale_price'] ?>" data-purchase-price="<?= (float)$p['purchase_price'] ?>" data-sku="<?= htmlspecialchars($p['sku']) ?>" data-stock-quantity="<?= format_quantity($p['stock_quantity']) ?>" data-vat-rate="<?= $p['vat_rate'] ?>">
<?php if ($p['image_path']): ?> <?php if ($p['image_path']): ?>
<img src="<?= htmlspecialchars($p['image_path']) ?>" alt="<?= htmlspecialchars($p['name_en']) ?>"> <img src="<?= htmlspecialchars($p['image_path']) ?>" alt="<?= htmlspecialchars($p['name_en']) ?>">
<?php else: ?> <?php else: ?>
@ -2014,9 +2014,23 @@ if (isset($_GET['action']) || isset($_POST['action'])) {
$tax_amount = (float)($_POST['tax_amount'] ?? 0); $tax_amount = (float)($_POST['tax_amount'] ?? 0);
$discount_code_id = !empty($_POST['discount_code_id']) ? (int)$_POST['discount_code_id'] : null; $discount_code_id = !empty($_POST['discount_code_id']) ? (int)$_POST['discount_code_id'] : null;
$discount_amount = max(0, (float)($_POST['discount_amount'] ?? 0)); $discount_amount = max(0, (float)($_POST['discount_amount'] ?? 0));
$manual_discount_amount = max(0, (float)($_POST['manual_discount_amount'] ?? 0));
$manualDiscountEnabled = getSettingValue('manual_discount_enabled', '0') === '1'; $manualDiscountEnabled = getSettingValue('manual_discount_enabled', '0') === '1';
if (!$manualDiscountEnabled && $discount_code_id === null) { if (!$manualDiscountEnabled) {
$discount_amount = 0.0; $manual_discount_amount = 0.0;
if ($discount_code_id === null) {
$discount_amount = 0.0;
}
}
if ($manual_discount_amount > ($discount_amount + 0.0005)) {
throw new Exception(($lang ?? 'en') === 'ar' ? 'بيانات الخصم اليدوي غير صالحة.' : 'Invalid manual discount data.');
}
if ($manual_discount_amount > 0) {
$discountMetrics = calculateManualDiscountProfitMetrics($items, true);
$maxManualDiscount = min(max(0, $total_amount), max(0, (float)($discountMetrics['max_discount'] ?? 0)));
if ($manual_discount_amount > ($maxManualDiscount + 0.0005)) {
throw new Exception(manualDiscountLimitMessage($discountMetrics, $manual_discount_amount));
}
} }
if ($discount_amount > $total_amount) { if ($discount_amount > $total_amount) {
$discount_amount = max(0, $total_amount); $discount_amount = max(0, $total_amount);
@ -2166,7 +2180,7 @@ if (isset($_GET['action']) || isset($_POST['action'])) {
$invoice['party_name'] = $partyName !== '' ? $partyName : '---'; $invoice['party_name'] = $partyName !== '' ? $partyName : '---';
$invoice['paid_amount'] = (float)($invoice['paid_amount'] ?? 0); $invoice['paid_amount'] = (float)($invoice['paid_amount'] ?? 0);
$stmtItems = db()->prepare("SELECT li.*, i.name_en, i.name_ar, i.sku, i.vat_rate, i.stock_quantity FROM $itemTable li LEFT JOIN stock_items i ON li.item_id = i.id WHERE li.$fkColumn = ?"); $stmtItems = db()->prepare("SELECT li.*, i.name_en, i.name_ar, i.sku, i.vat_rate, i.stock_quantity, i.purchase_price FROM $itemTable li LEFT JOIN stock_items i ON li.item_id = i.id WHERE li.$fkColumn = ?");
$stmtItems->execute([$invoice_id]); $stmtItems->execute([$invoice_id]);
$invoice['items'] = $stmtItems->fetchAll(PDO::FETCH_ASSOC); $invoice['items'] = $stmtItems->fetchAll(PDO::FETCH_ASSOC);
@ -2725,6 +2739,113 @@ function getSettingValue(string $key, ?string $default = null): ?string {
return $value; return $value;
} }
function getManualDiscountProfitLimitPercent(): float {
$rawValue = getSettingValue('manual_discount_profit_limit_percent', '5');
$percent = is_numeric($rawValue) ? (float)$rawValue : 5.0;
if ($percent < 0) {
$percent = 0.0;
}
if ($percent > 100) {
$percent = 100.0;
}
return $percent;
}
function calculateManualDiscountProfitMetrics(array $lines, bool $pricesIncludeVat = false): array {
$percent = getManualDiscountProfitLimitPercent();
$normalizedLines = [];
$itemIds = [];
foreach ($lines as $line) {
$itemId = (int)($line['item_id'] ?? $line['id'] ?? 0);
$qty = normalize_quantity($line['qty'] ?? $line['quantity'] ?? 0);
$unitPrice = (float)($line['unit_price'] ?? $line['price'] ?? 0);
$vatRate = isset($line['vat_rate']) ? (float)$line['vat_rate'] : 0.0;
if ($itemId <= 0 || $qty <= 0) {
continue;
}
$normalizedLines[] = [
'item_id' => $itemId,
'qty' => $qty,
'unit_price' => $unitPrice,
'vat_rate' => $vatRate,
];
$itemIds[$itemId] = true;
}
if ($normalizedLines === [] || $itemIds === []) {
return [
'percent' => $percent,
'profit_amount' => 0.0,
'max_discount' => 0.0,
];
}
$ids = array_keys($itemIds);
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$purchasePrices = [];
try {
$stmt = db()->prepare("SELECT id, purchase_price FROM stock_items WHERE id IN ($placeholders)");
$stmt->execute($ids);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$purchasePrices[(int)$row['id']] = (float)($row['purchase_price'] ?? 0);
}
} catch (Throwable $e) {
$purchasePrices = [];
}
$profitAmount = 0.0;
foreach ($normalizedLines as $line) {
$purchasePrice = $purchasePrices[(int)$line['item_id']] ?? 0.0;
$effectiveUnitPrice = (float)$line['unit_price'];
$vatRate = (float)($line['vat_rate'] ?? 0.0);
if ($pricesIncludeVat && $vatRate > 0) {
$effectiveUnitPrice = $effectiveUnitPrice / (1 + ($vatRate / 100));
}
$profitAmount += ($effectiveUnitPrice - $purchasePrice) * (float)$line['qty'];
}
$profitAmount = max(0, $profitAmount);
$maxDiscount = max(0, $profitAmount * ($percent / 100));
return [
'percent' => $percent,
'profit_amount' => $profitAmount,
'max_discount' => $maxDiscount,
];
}
function manualDiscountLimitMessage(array $metrics, ?float $requestedAmount = null): string {
$langCode = $_SESSION['lang'] ?? 'en';
$currency = function_exists('__') ? __('currency') : 'OMR';
$allowed = number_format(max(0, (float)($metrics['max_discount'] ?? 0)), 3, '.', '');
$profit = number_format(max(0, (float)($metrics['profit_amount'] ?? 0)), 3, '.', '');
$percent = rtrim(rtrim(number_format(max(0, (float)($metrics['percent'] ?? 0)), 3, '.', ''), '0'), '.');
if ($percent === '') {
$percent = '0';
}
$requested = $requestedAmount !== null ? number_format(max(0, $requestedAmount), 3, '.', '') : null;
if ($langCode === 'ar') {
$message = "الخصم اليدوي المسموح هو {$currency} {$allowed} فقط ({$percent}% من ربح الفاتورة {$currency} {$profit}).";
if ($requested !== null) {
$message = "الخصم اليدوي {$currency} {$requested} يتجاوز الحد المسموح. " . $message;
}
return $message;
}
$message = "The maximum allowed manual discount is {$currency} {$allowed} ({$percent}% of invoice profit {$currency} {$profit}).";
if ($requested !== null) {
$message = "Manual discount {$currency} {$requested} exceeds the allowed limit. " . $message;
}
return $message;
}
function getWeightBarcodeConfig(): array { function getWeightBarcodeConfig(): array {
$prefixStart = (int)(getSettingValue('weight_barcode_prefix_start', '20') ?? '20'); $prefixStart = (int)(getSettingValue('weight_barcode_prefix_start', '20') ?? '20');
$prefixEnd = (int)(getSettingValue('weight_barcode_prefix_end', '29') ?? '29'); $prefixEnd = (int)(getSettingValue('weight_barcode_prefix_end', '29') ?? '29');
@ -7712,7 +7833,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
</div> </div>
<div class="product-grid" id="productGrid"> <div class="product-grid" id="productGrid">
<?php foreach ($products as $p): ?> <?php foreach ($products as $p): ?>
<div class="product-card" data-id="<?= $p['id'] ?>" data-name-en="<?= htmlspecialchars($p['name_en']) ?>" data-name-ar="<?= htmlspecialchars($p['name_ar']) ?>" data-price="<?= $p['sale_price'] ?>" data-sku="<?= htmlspecialchars($p['sku']) ?>" data-stock-quantity="<?= format_quantity($p['stock_quantity']) ?>" data-vat-rate="<?= $p['vat_rate'] ?>"> <div class="product-card" data-id="<?= $p['id'] ?>" data-name-en="<?= htmlspecialchars($p['name_en']) ?>" data-name-ar="<?= htmlspecialchars($p['name_ar']) ?>" data-price="<?= $p['sale_price'] ?>" data-purchase-price="<?= (float)$p['purchase_price'] ?>" data-sku="<?= htmlspecialchars($p['sku']) ?>" data-stock-quantity="<?= format_quantity($p['stock_quantity']) ?>" data-vat-rate="<?= $p['vat_rate'] ?>">
<?php if ($p['image_path']): ?> <?php if ($p['image_path']): ?>
<img src="<?= htmlspecialchars($p['image_path']) ?>" alt="<?= htmlspecialchars($p['name_en']) ?>"> <img src="<?= htmlspecialchars($p['image_path']) ?>" alt="<?= htmlspecialchars($p['name_en']) ?>">
<?php else: ?> <?php else: ?>
@ -7799,6 +7920,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
<input type="number" id="manualDiscountAmount" class="form-control" value="0.000" min="0" step="0.001" oninput="cart.onManualDiscountChange()"> <input type="number" id="manualDiscountAmount" class="form-control" value="0.000" min="0" step="0.001" oninput="cart.onManualDiscountChange()">
</div> </div>
<div class="smaller text-muted mt-1" data-en="Fixed amount discount applied to the cart total." data-ar="يتم تطبيق خصم ثابت على إجمالي السلة."><?= $lang === 'ar' ? 'يتم تطبيق خصم ثابت على إجمالي السلة.' : 'Fixed amount discount applied to the cart total.' ?></div> <div class="smaller text-muted mt-1" data-en="Fixed amount discount applied to the cart total." data-ar="يتم تطبيق خصم ثابت على إجمالي السلة."><?= $lang === 'ar' ? 'يتم تطبيق خصم ثابت على إجمالي السلة.' : 'Fixed amount discount applied to the cart total.' ?></div>
<div class="smaller text-muted mt-1" id="manualDiscountLimitInfo"></div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div> <div>
@ -7877,6 +7999,47 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
isManualDiscountEnabled() { isManualDiscountEnabled() {
return String((typeof companySettings !== 'undefined' && companySettings && companySettings.manual_discount_enabled !== undefined) ? companySettings.manual_discount_enabled : '0') === '1'; return String((typeof companySettings !== 'undefined' && companySettings && companySettings.manual_discount_enabled !== undefined) ? companySettings.manual_discount_enabled : '0') === '1';
}, },
getManualDiscountProfitLimitPercent() {
let value = (typeof companySettings !== 'undefined' && companySettings && companySettings.manual_discount_profit_limit_percent !== undefined)
? parseFloat(companySettings.manual_discount_profit_limit_percent)
: 5;
if (!Number.isFinite(value) || value < 0) value = 5;
return Math.min(100, Math.max(0, value));
},
getManualDiscountProfitMetrics() {
const percent = this.getManualDiscountProfitLimitPercent();
let profitAmount = 0;
(this.items || []).forEach(item => {
const qty = normalizeQuantity(item.qty);
const grossUnitPrice = parseFloat(item.price) || 0;
const vatRate = (item.vatRate !== undefined && item.vatRate !== null)
? (parseFloat(item.vatRate) || 0)
: (parseFloat(item.vat_rate) || 0);
const purchasePrice = (item.purchasePrice !== undefined && item.purchasePrice !== null)
? (parseFloat(item.purchasePrice) || 0)
: (parseFloat(item.purchase_price) || 0);
const netUnitPrice = vatRate > 0 ? (grossUnitPrice / (1 + (vatRate / 100))) : grossUnitPrice;
profitAmount += (netUnitPrice - purchasePrice) * qty;
});
profitAmount = Math.max(0, profitAmount);
return {
percent,
profitAmount,
maxDiscount: Math.max(0, profitAmount * (percent / 100))
};
},
updateManualDiscountLimitInfo(limitAmount = null, metrics = null) {
const info = document.getElementById('manualDiscountLimitInfo');
if (!info || !this.isManualDiscountEnabled()) return;
const activeMetrics = metrics || this.getManualDiscountProfitMetrics();
const safeLimit = Number.isFinite(limitAmount) ? Math.max(0, limitAmount) : Math.max(0, activeMetrics.maxDiscount);
const percentLabel = activeMetrics.percent.toFixed(3).replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1');
info.textContent = posIsArabic
? `الحد الأقصى الآن: <?= __('currency') ?> ${safeLimit.toFixed(3)} (${percentLabel}% من ربح الفاتورة <?= __('currency') ?> ${activeMetrics.profitAmount.toFixed(3)})`
: `Max allowed now: <?= __('currency') ?> ${safeLimit.toFixed(3)} (${percentLabel}% of invoice profit <?= __('currency') ?> ${activeMetrics.profitAmount.toFixed(3)})`;
},
getCodeDiscountAmount(subtotal) { getCodeDiscountAmount(subtotal) {
if (!this.discount) return 0; if (!this.discount) return 0;
const rawDiscount = this.discount.type === 'percentage' const rawDiscount = this.discount.type === 'percentage'
@ -7907,7 +8070,10 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
return sum + (price * qty * (vatRate / (100 + vatRate))); return sum + (price * qty * (vatRate / (100 + vatRate)));
}, 0); }, 0);
const codeDiscount = this.getCodeDiscountAmount(subtotal); const codeDiscount = this.getCodeDiscountAmount(subtotal);
const manualDiscount = this.getManualDiscountAmount(subtotal - codeDiscount, syncInput); const profitMetrics = this.getManualDiscountProfitMetrics();
const manualDiscountLimit = Math.min(Math.max(0, subtotal - codeDiscount), Math.max(0, profitMetrics.maxDiscount));
const manualDiscount = this.getManualDiscountAmount(manualDiscountLimit, syncInput);
this.updateManualDiscountLimitInfo(manualDiscountLimit, profitMetrics);
const discountAmount = Math.min(subtotal, codeDiscount + manualDiscount); const discountAmount = Math.min(subtotal, codeDiscount + manualDiscount);
const redeemSwitch = document.getElementById('redeemLoyalty'); const redeemSwitch = document.getElementById('redeemLoyalty');
const redeemRate = (this.loyaltySettings && this.loyaltySettings.redeemPointsPerUnit) ? this.loyaltySettings.redeemPointsPerUnit : 100; const redeemRate = (this.loyaltySettings && this.loyaltySettings.redeemPointsPerUnit) ? this.loyaltySettings.redeemPointsPerUnit : 100;
@ -7916,7 +8082,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
? Math.min(Math.max(0, subtotal - discountAmount), availableRedeemValue) ? Math.min(Math.max(0, subtotal - discountAmount), availableRedeemValue)
: 0; : 0;
const total = Math.max(0, subtotal - discountAmount - loyaltyRedeemed); const total = Math.max(0, subtotal - discountAmount - loyaltyRedeemed);
return { subtotal, totalVat, codeDiscount, manualDiscount, discountAmount, loyaltyRedeemed, total }; return { subtotal, totalVat, codeDiscount, manualDiscount, discountAmount, loyaltyRedeemed, total, manualDiscountLimit, profitMetrics };
}, },
onManualDiscountChange() { onManualDiscountChange() {
const manualDiscountInput = document.getElementById('manualDiscountAmount'); const manualDiscountInput = document.getElementById('manualDiscountAmount');
@ -7928,6 +8094,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
const discInfo = document.getElementById('appliedDiscountInfo'); const discInfo = document.getElementById('appliedDiscountInfo');
if (discInfo) discInfo.style.display = 'none'; if (discInfo) discInfo.style.display = 'none';
} }
this.calculateTotals(true);
this.render(); this.render();
}, },
broadcast() { broadcast() {
@ -7935,7 +8102,8 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
// Ensure items is an array // Ensure items is an array
if (!Array.isArray(this.items)) this.items = []; if (!Array.isArray(this.items)) this.items = [];
const totals = this.calculateTotals(false); const shouldSyncManualDiscountInput = !(document.activeElement && document.activeElement.id === 'manualDiscountAmount');
const totals = this.calculateTotals(shouldSyncManualDiscountInput);
const subtotal = totals.subtotal; const subtotal = totals.subtotal;
const totalVat = totals.totalVat; const totalVat = totals.totalVat;
const discountAmount = totals.discountAmount; const discountAmount = totals.discountAmount;
@ -7978,7 +8146,10 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
const currentStock = normalizeQuantity(product.stock_quantity); const currentStock = normalizeQuantity(product.stock_quantity);
const addQty = Math.max(normalizeQuantity((product.qty !== undefined && product.qty !== null) ? product.qty : 1), 0.01); const addQty = Math.max(normalizeQuantity((product.qty !== undefined && product.qty !== null) ? product.qty : 1), 0.01);
const unitPrice = (product.price !== undefined && product.price !== null) ? (parseFloat(product.price) || 0) : (parseFloat(product.sale_price) || 0); const unitPrice = (product.price !== undefined && product.price !== null) ? (parseFloat(product.price) || 0) : (parseFloat(product.sale_price) || 0);
const normalizedProduct = {...product, price: unitPrice}; const purchasePrice = (product.purchasePrice !== undefined && product.purchasePrice !== null)
? (parseFloat(product.purchasePrice) || 0)
: (parseFloat(product.purchase_price) || 0);
const normalizedProduct = {...product, price: unitPrice, purchasePrice};
const existing = this.items.find(item => item.id === product.id); const existing = this.items.find(item => item.id === product.id);
if (existing) { if (existing) {
@ -7988,6 +8159,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
} }
existing.qty = normalizeQuantity(existing.qty + addQty); existing.qty = normalizeQuantity(existing.qty + addQty);
existing.price = unitPrice; existing.price = unitPrice;
existing.purchasePrice = purchasePrice;
} else { } else {
if (!allowZeroStock && currentStock < addQty) { if (!allowZeroStock && currentStock < addQty) {
Swal.fire(posT('Error', 'خطأ'), posT('Insufficient stock!', 'المخزون غير كافٍ!'), 'error'); Swal.fire(posT('Error', 'خطأ'), posT('Insufficient stock!', 'المخزون غير كافٍ!'), 'error');
@ -8146,6 +8318,13 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
throw new Error(posT('Invalid server response', 'استجابة غير صالحة من الخادم')); throw new Error(posT('Invalid server response', 'استجابة غير صالحة من الخادم'));
} }
const lang = document.documentElement.lang || 'en'; const lang = document.documentElement.lang || 'en';
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}[char] || char));
let html = '<div class="list-group list-group-flush shadow-sm rounded">'; let html = '<div class="list-group list-group-flush shadow-sm rounded">';
if (carts.length === 0) { if (carts.length === 0) {
html += ` html += `
@ -8155,21 +8334,29 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
</div>`; </div>`;
} }
carts.forEach(c => { carts.forEach(c => {
const cartId = Number.parseInt(c.id, 10);
if (!Number.isFinite(cartId) || cartId <= 0) return;
const cartName = escapeHtml(c.cart_name || (lang === 'ar' ? 'طلب غير مسمى' : 'Untitled Cart'));
const customerName = escapeHtml(c.customer_name || (lang === 'ar' ? 'عميل عابر' : 'Walk-in'));
const createdAtDate = c.created_at ? new Date(c.created_at) : null;
const createdAtText = createdAtDate && !Number.isNaN(createdAtDate.getTime())
? createdAtDate.toLocaleString()
: (c.created_at || '');
html += ` html += `
<div class="list-group-item d-flex justify-content-between align-items-center p-3 hover-bg-light border-start-0 border-end-0"> <div class="list-group-item d-flex justify-content-between align-items-center gap-3 p-3 hover-bg-light border-start-0 border-end-0 held-cart-entry" role="button" tabindex="0" data-held-action="resume" data-held-cart-id="${cartId}" aria-label="${lang === 'ar' ? 'استرجاع الطلب المعلق' : 'Resume held cart'}" style="cursor:pointer;">
<div class="text-start"> <div class="text-start flex-grow-1 pe-2" data-held-action="resume" data-held-cart-id="${cartId}">
<div class="fw-bold text-primary">${c.cart_name}</div> <div class="fw-bold text-primary">${cartName}</div>
<div class="small text-muted"> <div class="small text-muted">
<i class="bi bi-person me-1"></i>${c.customer_name || (lang === 'ar' ? 'عميل عابر' : 'Walk-in')} <i class="bi bi-person me-1"></i>${customerName}
<span class="mx-2 text-silver">|</span> <span class="mx-2 text-silver">|</span>
<i class="bi bi-clock me-1"></i>${new Date(c.created_at).toLocaleString()} <i class="bi bi-clock me-1"></i>${escapeHtml(createdAtText)}
</div> </div>
</div> </div>
<div class="btn-group"> <div class="btn-group flex-shrink-0" role="group">
<button class="btn btn-sm btn-primary" onclick="cart.resume(${c.id})"> <button type="button" class="btn btn-sm btn-primary" data-held-action="resume" data-held-cart-id="${cartId}">
<i class="bi bi-arrow-repeat me-1"></i><span data-en="Resume" data-ar="استرجاع">${lang === 'ar' ? 'استرجاع' : 'Resume'}</span> <i class="bi bi-arrow-repeat me-1"></i><span data-en="Resume" data-ar="استرجاع">${lang === 'ar' ? 'استرجاع' : 'Resume'}</span>
</button> </button>
<button class="btn btn-sm btn-outline-danger" onclick="cart.deleteHeld(${c.id})"> <button type="button" class="btn btn-sm btn-outline-danger" data-held-action="delete" data-held-cart-id="${cartId}" aria-label="${lang === 'ar' ? 'حذف الطلب المعلق' : 'Delete held cart'}">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
@ -8184,6 +8371,36 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
width: '700px', width: '700px',
customClass: { customClass: {
container: 'held-carts-swal' container: 'held-carts-swal'
},
didOpen: (popup) => {
const triggerHeldCartAction = (action, id) => {
if (!Number.isFinite(id) || id <= 0) return;
if (action === 'delete') {
this.deleteHeld(id);
return;
}
this.resume(id);
};
popup.querySelectorAll('.held-cart-entry').forEach((row) => {
row.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
const id = Number.parseInt(row.getAttribute('data-held-cart-id') || '', 10);
triggerHeldCartAction('resume', id);
}
});
});
popup.addEventListener('click', (event) => {
const actionTarget = event.target.closest('[data-held-action]');
if (!actionTarget) return;
event.preventDefault();
event.stopPropagation();
const action = actionTarget.getAttribute('data-held-action');
const id = Number.parseInt(actionTarget.getAttribute('data-held-cart-id') || '', 10);
triggerHeldCartAction(action, id);
});
} }
}); });
} catch (err) { } catch (err) {
@ -8271,7 +8488,8 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
`; `;
}).join(''); }).join('');
const totals = this.calculateTotals(false); const shouldSyncManualDiscountInput = !(document.activeElement && document.activeElement.id === 'manualDiscountAmount');
const totals = this.calculateTotals(shouldSyncManualDiscountInput);
const discountAmount = totals.discountAmount; const discountAmount = totals.discountAmount;
const loyaltyRedeemedValue = totals.loyaltyRedeemed; const loyaltyRedeemedValue = totals.loyaltyRedeemed;
const total = totals.total; const total = totals.total;
@ -8514,6 +8732,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
const subtotal = totals.subtotal; const subtotal = totals.subtotal;
const totalVat = totals.totalVat; const totalVat = totals.totalVat;
const discountAmount = totals.discountAmount; const discountAmount = totals.discountAmount;
const manualDiscountAmount = totals.manualDiscount;
const loyaltyRedeemed = totals.loyaltyRedeemed; const loyaltyRedeemed = totals.loyaltyRedeemed;
const formData = new FormData(); const formData = new FormData();
@ -8524,6 +8743,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
formData.append('tax_amount', totalVat); formData.append('tax_amount', totalVat);
formData.append('discount_code_id', this.discount ? this.discount.id : ''); formData.append('discount_code_id', this.discount ? this.discount.id : '');
formData.append('discount_amount', discountAmount); formData.append('discount_amount', discountAmount);
formData.append('manual_discount_amount', manualDiscountAmount);
formData.append('loyalty_redeemed', loyaltyRedeemed); formData.append('loyalty_redeemed', loyaltyRedeemed);
formData.append('items', JSON.stringify(this.items.map(i => { formData.append('items', JSON.stringify(this.items.map(i => {
const vr = (i.vatRate !== undefined && i.vatRate !== null) ? i.vatRate : 0; const vr = (i.vatRate !== undefined && i.vatRate !== null) ? i.vatRate : 0;
@ -8722,6 +8942,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
nameEn: card.dataset.nameEn, nameEn: card.dataset.nameEn,
nameAr: card.dataset.nameAr, nameAr: card.dataset.nameAr,
price: parseFloat(card.dataset.price), price: parseFloat(card.dataset.price),
purchasePrice: parseFloat(card.dataset.purchasePrice) || 0,
sku: card.dataset.sku, sku: card.dataset.sku,
stock_quantity: parseFloat(card.dataset.stockQuantity), stock_quantity: parseFloat(card.dataset.stockQuantity),
vatRate: parseFloat(card.dataset.vatRate) || 0 vatRate: parseFloat(card.dataset.vatRate) || 0

View File

@ -41,10 +41,12 @@
name_ar: item.name_ar || item.item_name_ar || '', name_ar: item.name_ar || item.item_name_ar || '',
sku: item.sku || '', sku: item.sku || '',
vat_rate: item.vat_rate || 0, vat_rate: item.vat_rate || 0,
purchase_price: item.purchase_price || 0,
stock_quantity: item.stock_quantity || 0 stock_quantity: item.stock_quantity || 0
}, tableBody, null, null, grandTotalEl, subtotalEl, totalVatEl, { }, tableBody, null, null, grandTotalEl, subtotalEl, totalVatEl, {
quantity: item.quantity, quantity: item.quantity,
unit_price: item.unit_price unit_price: item.unit_price,
purchase_price: item.purchase_price || 0
}); });
}); });
}; };

View File

@ -12,6 +12,51 @@
return value; return value;
}; };
const getInvoiceManualDiscountPercent = () => {
let value = (typeof companySettings !== 'undefined' && companySettings && companySettings.manual_discount_profit_limit_percent !== undefined)
? parseFloat(companySettings.manual_discount_profit_limit_percent)
: 5;
if (!Number.isFinite(value) || value < 0) value = 5;
return Math.min(100, Math.max(0, value));
};
const getInvoiceManualDiscountMetrics = (tableBody) => {
const percent = getInvoiceManualDiscountPercent();
if (!tableBody || invoiceType !== 'sale') {
return { percent, profitAmount: 0, maxDiscount: 0 };
}
let profitAmount = 0;
tableBody.querySelectorAll('.item-row').forEach(row => {
const qty = normalizeQuantity(row.querySelector('.item-qty')?.value || 0);
const unitPrice = parseFloat(row.querySelector('.item-price')?.value) || 0;
const purchasePrice = parseFloat(row.querySelector('.item-purchase-price')?.value) || 0;
profitAmount += (unitPrice - purchasePrice) * qty;
});
profitAmount = Math.max(0, profitAmount);
return {
percent,
profitAmount,
maxDiscount: Math.max(0, profitAmount * (percent / 100))
};
};
const updateInvoiceDiscountHelp = (tableBody, limitAmount = 0, metrics = null) => {
const input = getInvoiceDiscountInput(tableBody);
if (!input) return;
const form = tableBody && typeof tableBody.closest === 'function' ? tableBody.closest('form') : null;
const help = form ? form.querySelector('[data-invoice-discount-help]') : null;
if (!help) return;
const activeMetrics = metrics || getInvoiceManualDiscountMetrics(tableBody);
const safeLimit = Math.max(0, Number(limitAmount) || 0);
const percentLabel = activeMetrics.percent.toFixed(3).replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1');
const isArabic = (document.documentElement.lang || 'en') === 'ar';
help.textContent = isArabic
? `الحد الأقصى الآن: <?= __('currency') ?> ${safeLimit.toFixed(3)} (${percentLabel}% من ربح الفاتورة <?= __('currency') ?> ${activeMetrics.profitAmount.toFixed(3)})`
: `Max allowed now: <?= __('currency') ?> ${safeLimit.toFixed(3)} (${percentLabel}% of invoice profit <?= __('currency') ?> ${activeMetrics.profitAmount.toFixed(3)})`;
};
const initInvoiceForm = (searchInputId, suggestionsId, tableBodyId, grandTotalId, subtotalId, totalVatId) => { const initInvoiceForm = (searchInputId, suggestionsId, tableBodyId, grandTotalId, subtotalId, totalVatId) => {
const searchInput = document.getElementById(searchInputId); const searchInput = document.getElementById(searchInputId);
const suggestions = document.getElementById(suggestionsId); const suggestions = document.getElementById(suggestionsId);
@ -206,12 +251,16 @@ ${text}` : title);
const price = customData ? customData.unit_price : (invoiceType === 'sale' ? item.sale_price : item.purchase_price); const price = customData ? customData.unit_price : (invoiceType === 'sale' ? item.sale_price : item.purchase_price);
const qty = normalizeQuantity(customData ? customData.quantity : 1); const qty = normalizeQuantity(customData ? customData.quantity : 1);
const vatRate = item.vat_rate || 0; const vatRate = item.vat_rate || 0;
const purchasePrice = customData && customData.purchase_price !== undefined && customData.purchase_price !== null
? customData.purchase_price
: (item.purchase_price || 0);
row.innerHTML = ` row.innerHTML = `
<td> <td>
<input type="hidden" name="item_ids[]" class="item-id-input" value="${item.id}"> <input type="hidden" name="item_ids[]" class="item-id-input" value="${item.id}">
<input type="hidden" class="item-row-stock" value="${formatQuantity(item.stock_quantity)}"> <input type="hidden" class="item-row-stock" value="${formatQuantity(item.stock_quantity)}">
<input type="hidden" class="item-vat-rate" value="${vatRate}"> <input type="hidden" class="item-vat-rate" value="${vatRate}">
<input type="hidden" class="item-purchase-price" value="${parseFloat(purchasePrice || 0).toFixed(3)}">
<div><strong>${item.name_en}</strong></div> <div><strong>${item.name_en}</strong></div>
<div class="small text-muted">${item.name_ar} (${item.sku})</div> <div class="small text-muted">${item.name_ar} (${item.sku})</div>
</td> </td>
@ -247,11 +296,14 @@ ${text}` : title);
const rawGrandTotal = subtotal + totalVat; const rawGrandTotal = subtotal + totalVat;
const discountInput = getInvoiceDiscountInput(tableBody); const discountInput = getInvoiceDiscountInput(tableBody);
const discountMetrics = getInvoiceManualDiscountMetrics(tableBody);
const maxDiscountAmount = discountInput ? Math.min(rawGrandTotal, discountMetrics.maxDiscount) : rawGrandTotal;
let discountAmount = getInvoiceDiscountValue(tableBody); let discountAmount = getInvoiceDiscountValue(tableBody);
if (discountAmount > rawGrandTotal) { if (discountAmount > maxDiscountAmount) {
discountAmount = rawGrandTotal; discountAmount = maxDiscountAmount;
if (discountInput) discountInput.value = discountAmount.toFixed(3); if (discountInput) discountInput.value = discountAmount.toFixed(3);
} }
updateInvoiceDiscountHelp(tableBody, maxDiscountAmount, discountMetrics);
const grandTotal = Math.max(0, rawGrandTotal - discountAmount); const grandTotal = Math.max(0, rawGrandTotal - discountAmount);
if (subtotalEl) subtotalEl.textContent = 'OMR ' + subtotal.toFixed(3); if (subtotalEl) subtotalEl.textContent = 'OMR ' + subtotal.toFixed(3);

View File

@ -496,6 +496,7 @@
<span class="input-group-text"><?= __('currency') ?></span> <span class="input-group-text"><?= __('currency') ?></span>
<input type="number" step="0.001" min="0" name="discount_amount" class="form-control text-end" value="0.000" data-invoice-discount-input> <input type="number" step="0.001" min="0" name="discount_amount" class="form-control text-end" value="0.000" data-invoice-discount-input>
</div> </div>
<div class="form-text text-end" data-invoice-discount-help></div>
</td> </td>
<td></td> <td></td>
</tr> </tr>
@ -612,6 +613,7 @@
<span class="input-group-text"><?= __('currency') ?></span> <span class="input-group-text"><?= __('currency') ?></span>
<input type="number" step="0.001" min="0" name="discount_amount" id="edit_discount_amount" class="form-control text-end" value="0.000" data-invoice-discount-input> <input type="number" step="0.001" min="0" name="discount_amount" id="edit_discount_amount" class="form-control text-end" value="0.000" data-invoice-discount-input>
</div> </div>
<div class="form-text text-end" data-invoice-discount-help></div>
</td> </td>
<td></td> <td></td>
</tr> </tr>

View File

@ -1,72 +1,200 @@
const escapeHtml = (value) => {
const stringValue = value == null ? '' : String(value);
return stringValue.replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
}[character] || character));
};
const humanizeInvoiceText = (value) => {
const raw = String(value ?? '').trim();
if (!raw) {
return '---';
}
return raw
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (letter) => letter.toUpperCase());
};
const formatInvoiceCurrency = (amount, decimals = 3) => {
const numericAmount = Number.isFinite(parseFloat(amount)) ? parseFloat(amount) : 0;
return `<span class="invoice-currency"><?= __('currency') ?></span>${numericAmount.toFixed(decimals)}`;
};
const setInvoiceText = (id, value) => {
const element = document.getElementById(id);
if (element) {
element.textContent = value == null || value === '' ? '---' : String(value);
}
return element;
};
const toggleInvoiceField = (containerId, valueId, value) => {
const container = document.getElementById(containerId);
const valueElement = document.getElementById(valueId);
if (!container || !valueElement) {
return;
}
if (value) {
valueElement.textContent = value;
container.style.display = 'block';
} else {
valueElement.textContent = '';
container.style.display = 'none';
}
};
let invoicePrintOriginalParent = null;
let invoicePrintOriginalNextSibling = null;
let invoicePrintRestoreTimer = null;
const prepareInvoiceForPrint = () => {
const modal = document.getElementById('viewInvoiceModal');
if (!modal || !document.body) {
return;
}
if (!invoicePrintOriginalParent) {
invoicePrintOriginalParent = modal.parentNode;
invoicePrintOriginalNextSibling = modal.nextSibling;
}
if (modal.parentNode !== document.body) {
document.body.appendChild(modal);
}
document.body.classList.add('printing-invoice');
};
const restoreInvoiceAfterPrint = () => {
const modal = document.getElementById('viewInvoiceModal');
if (!document.body) {
return;
}
document.body.classList.remove('printing-invoice');
if (!modal || !invoicePrintOriginalParent || modal.parentNode === invoicePrintOriginalParent) {
return;
}
invoicePrintOriginalParent.insertBefore(modal, invoicePrintOriginalNextSibling);
};
window.printInvoiceDocument = function() {
prepareInvoiceForPrint();
window.print();
if (invoicePrintRestoreTimer) {
window.clearTimeout(invoicePrintRestoreTimer);
}
invoicePrintRestoreTimer = window.setTimeout(() => {
restoreInvoiceAfterPrint();
}, 1200);
};
window.addEventListener('beforeprint', prepareInvoiceForPrint);
window.addEventListener('afterprint', restoreInvoiceAfterPrint);
window.viewAndPrintA4Invoice = function(data, autoPrint = true) { window.viewAndPrintA4Invoice = function(data, autoPrint = true) {
if (!data) return; if (!data) return;
// Reuse view logic
const invoiceDisplayNo = data.document_no || data.transaction_no || ((data.type === 'purchase' ? 'PUR-' : 'INV-') + data.id.toString().padStart(5, '0')); const invoiceType = String(data.type || 'sale').toLowerCase() === 'purchase' ? 'purchase' : 'sale';
document.getElementById('invNumber').textContent = invoiceDisplayNo; const invoiceDisplayNo = data.document_no || data.transaction_no || ((invoiceType === 'purchase' ? 'PUR-' : 'INV-') + data.id.toString().padStart(5, '0'));
document.getElementById('invDate').textContent = data.invoice_date; const paymentText = humanizeInvoiceText(data.payment_type || 'cash');
document.getElementById('invPaymentType').textContent = data.payment_type ? data.payment_type.toUpperCase() : 'CASH'; const statusKey = String(data.status || '').toLowerCase();
document.getElementById('invCustomerName').textContent = data.customer_name || '---'; const statusText = statusKey === 'partially_paid' ? 'Partially Paid' : humanizeInvoiceText(statusKey);
const typeText = invoiceType === 'purchase' ? 'Purchase Invoice' : 'Sales Invoice';
const phoneEl = document.getElementById('invCustomerPhone'); const documentTitle = invoiceType === 'purchase' ? 'Purchase Invoice / فاتورة شراء' : 'Tax Invoice / فاتورة ضريبية';
const phoneContainer = document.getElementById('invCustomerPhoneContainer'); const documentSubtitle = invoiceType === 'purchase' ? 'Official purchase record / مستند شراء رسمي' : 'Official tax document / مستند ضريبي رسمي';
if (data.customer_phone) { const partyLabelText = invoiceType === 'purchase' ? 'Supplier Details / بيانات المورد' : 'Bill To / بيانات العميل';
phoneEl.textContent = data.customer_phone; const partyLabelEn = invoiceType === 'purchase' ? 'Supplier Details' : 'Bill To';
phoneContainer.style.display = 'block'; const partyLabelAr = invoiceType === 'purchase' ? 'بيانات المورد' : 'بيانات العميل';
} else { const formatQty = typeof window.formatQuantity === 'function'
phoneContainer.style.display = 'none'; ? window.formatQuantity
: (value) => {
const numericValue = Number.isFinite(parseFloat(value)) ? parseFloat(value) : 0;
return Number.isInteger(numericValue)
? String(numericValue)
: numericValue.toFixed(3).replace(/\.?0+$/, '');
};
setInvoiceText('invNumber', invoiceDisplayNo);
setInvoiceText('invDate', data.invoice_date || '---');
setInvoiceText('invPaymentType', paymentText);
setInvoiceText('invPaymentTypeSummary', paymentText);
setInvoiceText('invCustomerName', data.customer_name || '---');
setInvoiceText('invStatusText', statusText);
setInvoiceText('invDocumentTitle', documentTitle);
setInvoiceText('invDocumentSubtitle', documentSubtitle);
setInvoiceText('invAmountInWords', data.total_in_words || '---');
toggleInvoiceField('invCustomerPhoneContainer', 'invCustomerPhone', data.customer_phone || '');
toggleInvoiceField('invCustomerTaxIdContainer', 'invCustomerTaxId', data.customer_tax_id || '');
toggleInvoiceField('invOutletRow', 'invOutletName', data.outlet_name || '');
const partyLabel = document.getElementById('invPartyLabel');
if (partyLabel) {
partyLabel.textContent = partyLabelText;
partyLabel.setAttribute('data-en', partyLabelEn);
partyLabel.setAttribute('data-ar', partyLabelAr);
} }
const taxIdEl = document.getElementById('invCustomerTaxId'); const invoiceTypeLabel = document.getElementById('invoiceTypeLabel');
const taxIdContainer = document.getElementById('invCustomerTaxIdContainer'); if (invoiceTypeLabel) {
if (data.customer_tax_id) { invoiceTypeLabel.textContent = typeText;
taxIdEl.textContent = data.customer_tax_id; invoiceTypeLabel.className = 'invoice-pill ' + (invoiceType === 'purchase' ? 'invoice-pill--purchase' : 'invoice-pill--sale');
taxIdContainer.style.display = 'block';
} else {
taxIdContainer.style.display = 'none';
} }
document.getElementById('invAmountInWords').textContent = data.total_in_words || '';
const invOutletEl = document.getElementById('invOutletName');
if (invOutletEl) {
invOutletEl.textContent = data.outlet_name ? (data.outlet_name) : '';
invOutletEl.style.display = data.outlet_name ? 'block' : 'none';
}
document.getElementById('invPartyLabel').textContent = data.type === 'sale' ? 'Bill To / فاتورة إلى' : 'Bill From / فاتورة من';
document.getElementById('invPartyLabel').setAttribute('data-en', data.type === 'sale' ? 'Bill To' : 'Bill From');
document.getElementById('invPartyLabel').setAttribute('data-ar', data.type === 'sale' ? 'فاتورة إلى' : 'فاتورة من');
document.getElementById('invoiceTypeLabel').textContent = data.type;
document.getElementById('invoiceTypeLabel').className = 'badge text-uppercase ' + (data.type === 'sale' ? 'bg-success' : 'bg-warning');
const statusLabel = document.getElementById('invoiceStatusLabel'); const statusLabel = document.getElementById('invoiceStatusLabel');
let statusClass = 'bg-secondary'; let statusClass = 'invoice-pill--neutral';
let statusEn = data.status ? (data.status.charAt(0).toUpperCase() + data.status.slice(1)) : '---'; if (statusKey === 'paid') {
if (data.status === 'paid') statusClass = 'bg-success'; statusClass = 'invoice-pill--paid';
else if (data.status === 'unpaid') statusClass = 'bg-danger'; } else if (statusKey === 'unpaid') {
else if (data.status === 'partially_paid') { statusClass = 'invoice-pill--unpaid';
statusClass = 'bg-warning text-dark'; } else if (statusKey === 'partially_paid') {
statusEn = 'Partially Paid'; statusClass = 'invoice-pill--partial';
} }
if (statusLabel) {
statusLabel.textContent = statusEn; statusLabel.textContent = statusText;
statusLabel.className = 'badge text-uppercase ' + statusClass; statusLabel.className = 'invoice-pill ' + statusClass;
}
const body = document.getElementById('invItemsBody'); const body = document.getElementById('invItemsBody');
body.innerHTML = ''; if (body) {
if (data.items) { body.innerHTML = '';
data.items.forEach(item => { if (Array.isArray(data.items) && data.items.length > 0) {
const tr = document.createElement('tr'); data.items.forEach((item, index) => {
tr.innerHTML = ` const tr = document.createElement('tr');
<td>${item.name_en} / ${item.name_ar}</td> const englishName = escapeHtml(item.name_en || item.name || 'Item');
<td class="text-center">${formatQuantity(item.quantity)}</td> const arabicName = escapeHtml(item.name_ar || '');
<td class="text-end"><small><?= __('currency') ?></small> ${parseFloat(item.unit_price).toFixed(3)}</td> const itemNameHtml = arabicName
<td class="text-end">${parseFloat(item.vat_rate || 0).toFixed(2)}%</td> ? `<div class="invoice-item-name">${englishName}</div><div class="invoice-item-secondary">${arabicName}</div>`
<td class="text-end"><small><?= __('currency') ?></small> ${parseFloat(item.total_price).toFixed(3)}</td> : `<div class="invoice-item-name">${englishName}</div>`;
`;
body.appendChild(tr); tr.innerHTML = `
}); <td class="text-center invoice-line-no">${index + 1}</td>
<td>${itemNameHtml}</td>
<td class="text-center">${formatQty(item.quantity)}</td>
<td class="text-end invoice-amount">${formatInvoiceCurrency(item.unit_price, 3)}</td>
<td class="text-end">${(Number.isFinite(parseFloat(item.vat_rate)) ? parseFloat(item.vat_rate) : 0).toFixed(2)}%</td>
<td class="text-end invoice-amount">${formatInvoiceCurrency(item.total_price, 3)}</td>
`;
body.appendChild(tr);
});
} else {
body.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-4">No invoice items / لا توجد أصناف</td></tr>';
}
} }
const vatVal = Number.isFinite(parseFloat(data.vat_amount)) ? parseFloat(data.vat_amount) : 0; const vatVal = Number.isFinite(parseFloat(data.vat_amount)) ? parseFloat(data.vat_amount) : 0;
const totalVal = Number.isFinite(parseFloat(data.total_amount)) ? parseFloat(data.total_amount) : 0; const totalVal = Number.isFinite(parseFloat(data.total_amount)) ? parseFloat(data.total_amount) : 0;
const discountVal = Math.max(0, Number.isFinite(parseFloat(data.discount_amount)) ? parseFloat(data.discount_amount) : 0); const discountVal = Math.max(0, Number.isFinite(parseFloat(data.discount_amount)) ? parseFloat(data.discount_amount) : 0);
@ -75,63 +203,88 @@
const grandTotalParsed = parseFloat(data.total_with_vat); const grandTotalParsed = parseFloat(data.total_with_vat);
const grandTotalValue = Number.isFinite(grandTotalParsed) ? grandTotalParsed : Math.max(0, grossBeforeDiscount - discountVal); const grandTotalValue = Number.isFinite(grandTotalParsed) ? grandTotalParsed : Math.max(0, grossBeforeDiscount - discountVal);
const subtotalExVat = isPosInvoice ? Math.max(0, grossBeforeDiscount - vatVal) : totalVal; const subtotalExVat = isPosInvoice ? Math.max(0, grossBeforeDiscount - vatVal) : totalVal;
const paidAmount = Number.isFinite(parseFloat(data.paid_amount)) ? parseFloat(data.paid_amount) : 0;
const balance = Math.max(0, grandTotalValue - paidAmount);
if (document.getElementById('invSubtotal')) document.getElementById('invSubtotal').innerHTML = '<small><?= __('currency') ?></small> ' + subtotalExVat.toFixed(3); const subtotalEl = document.getElementById('invSubtotal');
if (document.getElementById('invVatAmount')) document.getElementById('invVatAmount').innerHTML = '<small><?= __('currency') ?></small> ' + vatVal.toFixed(2); if (subtotalEl) subtotalEl.innerHTML = formatInvoiceCurrency(subtotalExVat, 3);
const vatEl = document.getElementById('invVatAmount');
if (vatEl) vatEl.innerHTML = formatInvoiceCurrency(vatVal, 3);
const discountRow = document.getElementById('invDiscountRow'); const discountRow = document.getElementById('invDiscountRow');
const discountAmountEl = document.getElementById('invDiscountAmount'); const discountAmountEl = document.getElementById('invDiscountAmount');
if (discountRow && discountAmountEl) { if (discountRow && discountAmountEl) {
if (discountVal > 0) { if (discountVal > 0) {
discountRow.style.display = 'flex'; discountRow.style.display = 'flex';
discountAmountEl.innerHTML = '<small><?= __('currency') ?></small> ' + discountVal.toFixed(3); discountAmountEl.innerHTML = formatInvoiceCurrency(discountVal, 3);
} else { } else {
discountRow.style.display = 'none'; discountRow.style.display = 'none';
discountAmountEl.innerHTML = ''; discountAmountEl.innerHTML = '';
} }
} }
if (document.getElementById('invGrandTotal')) document.getElementById('invGrandTotal').innerHTML = '<small><?= __('currency') ?></small> ' + grandTotalValue.toFixed(3); const grandTotalEl = document.getElementById('invGrandTotal');
if (grandTotalEl) grandTotalEl.innerHTML = formatInvoiceCurrency(grandTotalValue, 3);
if (document.getElementById('invPaidInfo')) document.getElementById('invPaidInfo').innerHTML = '<small><?= __('currency') ?></small> ' + parseFloat(data.paid_amount || 0).toFixed(3); const paidInfoEl = document.getElementById('invPaidInfo');
const balance = Math.max(0, grandTotalValue - parseFloat(data.paid_amount || 0)); if (paidInfoEl) paidInfoEl.innerHTML = formatInvoiceCurrency(paidAmount, 3);
if (document.getElementById('invBalanceInfo')) document.getElementById('invBalanceInfo').innerHTML = '<small><?= __('currency') ?></small> ' + balance.toFixed(3);
const balanceInfoEl = document.getElementById('invBalanceInfo');
if (balanceInfoEl) balanceInfoEl.innerHTML = formatInvoiceCurrency(balance, 3);
// Generate QR Code for Zakat, Tax and Customs Authority (ZATCA) style or simple formal
const companyName = <?= json_encode($data['settings']['company_name'] ?? 'Accounting System') ?>; const companyName = <?= json_encode($data['settings']['company_name'] ?? 'Accounting System') ?>;
const vatNo = <?= json_encode($data['settings']['vat_number'] ?? '') ?>; const vatNo = <?= json_encode($data['settings']['vat_number'] ?? '') ?>;
const qrData = `Seller: ${companyName}\nVAT: ${vatNo}\nInvoice: ${invoiceDisplayNo}\nDate: ${data.invoice_date}\nTotal: ${grandTotalValue.toFixed(3)}`; const qrData = `Seller: ${companyName}\nVAT: ${vatNo}\nInvoice: ${invoiceDisplayNo}\nDate: ${data.invoice_date}\nTotal: ${grandTotalValue.toFixed(3)}`;
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=100x100&data=${encodeURIComponent(qrData)}`; const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=112x112&data=${encodeURIComponent(qrData)}`;
if (document.getElementById('invQrCode')) { const qrCode = document.getElementById('invQrCode');
document.getElementById('invQrCode').innerHTML = `<img src="${qrUrl}" alt="QR Code" style="width: 100px; height: 100px;" class="border p-1 bg-white">`; if (qrCode) {
} qrCode.innerHTML = `<img src="${qrUrl}" alt="QR Code" width="112" height="112" class="bg-white rounded" style="width: 112px; height: 112px;">`;
const viewModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('viewInvoiceModal'));
viewModal.show();
if (autoPrint) {
setTimeout(() => { window.print(); }, 1000);
} }
fetch(`index.php?action=get_payments&invoice_id=${data.id}`) const viewModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('viewInvoiceModal'));
.then(res => res.json()) viewModal.show();
.then(payments => {
const paymentsPromise = fetch(`index.php?action=get_payments&invoice_id=${data.id}`)
.then((res) => res.json())
.then((payments) => {
const paymentsBody = document.getElementById('invPaymentsBody'); const paymentsBody = document.getElementById('invPaymentsBody');
const paymentsSection = document.getElementById('invPaymentsSection'); const paymentsSection = document.getElementById('invPaymentsSection');
if (paymentsBody) paymentsBody.innerHTML = ''; if (paymentsBody) paymentsBody.innerHTML = '';
if (payments && payments.length > 0) {
if (Array.isArray(payments) && payments.length > 0) {
if (paymentsBody) { if (paymentsBody) {
payments.forEach(p => { payments.forEach((payment) => {
const tr = document.createElement('tr'); const paymentChip = document.createElement('div');
tr.innerHTML = `<td>${p.payment_date}</td><td>${p.payment_method}</td><td class="text-end fw-bold">OMR ${parseFloat(p.amount).toFixed(3)}</td>`; paymentChip.className = 'invoice-payment-pill';
paymentsBody.appendChild(tr); paymentChip.innerHTML = `
<span>${escapeHtml(payment.payment_date || '')}</span>
<span class="invoice-payment-sep"></span>
<span>${escapeHtml(humanizeInvoiceText(payment.payment_method || ''))}</span>
<span class="invoice-payment-sep"></span>
<strong>${formatInvoiceCurrency(payment.amount, 3)}</strong>
`;
paymentsBody.appendChild(paymentChip);
}); });
} }
if (paymentsSection) paymentsSection.style.display = 'block'; if (paymentsSection) paymentsSection.style.display = 'block';
} else { } else if (paymentsSection) {
if (paymentsSection) paymentsSection.style.display = 'none'; paymentsSection.style.display = 'none';
} }
}).catch(err => console.error('Error fetching payments:', err)); })
.catch((err) => {
console.error('Error fetching payments:', err);
const paymentsSection = document.getElementById('invPaymentsSection');
if (paymentsSection) paymentsSection.style.display = 'none';
});
if (autoPrint) {
paymentsPromise.finally(() => {
setTimeout(() => {
window.printInvoiceDocument();
}, 450);
});
}
}; };
<?php <?php

File diff suppressed because it is too large Load Diff

View File

@ -28,6 +28,7 @@
$total_subtotal = 0; $total_subtotal = 0;
$total_vat = 0; $total_vat = 0;
$profitLines = [];
foreach ($items as $i => $item_id) { foreach ($items as $i => $item_id) {
if (!$item_id) continue; if (!$item_id) continue;
@ -42,6 +43,11 @@
$vatAmount = $subtotal * ($vatRate / 100); $vatAmount = $subtotal * ($vatRate / 100);
$total_subtotal += $subtotal; $total_subtotal += $subtotal;
$total_vat += $vatAmount; $total_vat += $vatAmount;
$profitLines[] = [
'item_id' => (int)$item_id,
'qty' => $qty,
'unit_price' => $price,
];
} }
$gross_total = $total_subtotal + $total_vat; $gross_total = $total_subtotal + $total_vat;
@ -50,6 +56,11 @@
$discount_amount = 0.0; $discount_amount = 0.0;
if ($type === 'sale' && $hasDiscountColumn && $manualDiscountEnabled) { if ($type === 'sale' && $hasDiscountColumn && $manualDiscountEnabled) {
$discount_amount = max(0, (float)($_POST['discount_amount'] ?? 0)); $discount_amount = max(0, (float)($_POST['discount_amount'] ?? 0));
$discountMetrics = calculateManualDiscountProfitMetrics($profitLines, false);
$maxManualDiscount = min(max(0, $gross_total), max(0, (float)($discountMetrics['max_discount'] ?? 0)));
if ($discount_amount > ($maxManualDiscount + 0.0005)) {
throw new Exception(manualDiscountLimitMessage($discountMetrics, $discount_amount));
}
if ($discount_amount > $gross_total) { if ($discount_amount > $gross_total) {
$discount_amount = $gross_total; $discount_amount = $gross_total;
} }
@ -143,6 +154,7 @@
$total_subtotal = 0; $total_subtotal = 0;
$total_vat = 0; $total_vat = 0;
$profitLines = [];
foreach ($items as $i => $item_id) { foreach ($items as $i => $item_id) {
if (!$item_id) continue; if (!$item_id) continue;
@ -157,6 +169,11 @@
$vatAmount = $subtotal * ($vatRate / 100); $vatAmount = $subtotal * ($vatRate / 100);
$total_subtotal += $subtotal; $total_subtotal += $subtotal;
$total_vat += $vatAmount; $total_vat += $vatAmount;
$profitLines[] = [
'item_id' => (int)$item_id,
'qty' => $qty,
'unit_price' => $price,
];
} }
$gross_total = $total_subtotal + $total_vat; $gross_total = $total_subtotal + $total_vat;
@ -166,6 +183,11 @@
if ($hasDiscountColumn) { if ($hasDiscountColumn) {
if ($manualDiscountEnabled && array_key_exists('discount_amount', $_POST)) { if ($manualDiscountEnabled && array_key_exists('discount_amount', $_POST)) {
$discount_amount = max(0, (float)($_POST['discount_amount'] ?? 0)); $discount_amount = max(0, (float)($_POST['discount_amount'] ?? 0));
$discountMetrics = calculateManualDiscountProfitMetrics($profitLines, false);
$maxManualDiscount = min(max(0, $gross_total), max(0, (float)($discountMetrics['max_discount'] ?? 0)));
if ($discount_amount > ($maxManualDiscount + 0.0005)) {
throw new Exception(manualDiscountLimitMessage($discountMetrics, $discount_amount));
}
} else { } else {
$existingDiscountStmt = $db->prepare("SELECT discount_amount FROM $table WHERE id = ? LIMIT 1"); $existingDiscountStmt = $db->prepare("SELECT discount_amount FROM $table WHERE id = ? LIMIT 1");
$existingDiscountStmt->execute([$id]); $existingDiscountStmt->execute([$id]);

View File

@ -106,6 +106,15 @@ if (isset($_POST['update_settings'])) {
$settings['allow_zero_stock_sell'] = (($settings['allow_zero_stock_sell'] ?? '1') === '0') ? '0' : '1'; $settings['allow_zero_stock_sell'] = (($settings['allow_zero_stock_sell'] ?? '1') === '0') ? '0' : '1';
$settings['manual_discount_enabled'] = (($settings['manual_discount_enabled'] ?? '0') === '1') ? '1' : '0'; $settings['manual_discount_enabled'] = (($settings['manual_discount_enabled'] ?? '0') === '1') ? '1' : '0';
$manualDiscountProfitLimitPercent = $settings['manual_discount_profit_limit_percent'] ?? '5';
$manualDiscountProfitLimitPercent = is_numeric($manualDiscountProfitLimitPercent) ? (float)$manualDiscountProfitLimitPercent : 5.0;
if ($manualDiscountProfitLimitPercent < 0) {
$manualDiscountProfitLimitPercent = 0.0;
}
if ($manualDiscountProfitLimitPercent > 100) {
$manualDiscountProfitLimitPercent = 100.0;
}
$settings['manual_discount_profit_limit_percent'] = number_format($manualDiscountProfitLimitPercent, 3, '.', '');
$settings['loyalty_enabled'] = (($settings['loyalty_enabled'] ?? '0') === '1') ? '1' : '0'; $settings['loyalty_enabled'] = (($settings['loyalty_enabled'] ?? '0') === '1') ? '1' : '0';
$settings['smtp_enabled'] = (($settings['smtp_enabled'] ?? '0') === '1') ? '1' : '0'; $settings['smtp_enabled'] = (($settings['smtp_enabled'] ?? '0') === '1') ? '1' : '0';
$settings['wablas_enabled'] = (($settings['wablas_enabled'] ?? '0') === '1') ? '1' : '0'; $settings['wablas_enabled'] = (($settings['wablas_enabled'] ?? '0') === '1') ? '1' : '0';

View File

@ -26,7 +26,56 @@ $timezoneIdentifiers = DateTimeZone::listIdentifiers();
$smtpConfigured = !empty($data['settings']['smtp_host']) && !empty($data['settings']['smtp_user']); $smtpConfigured = !empty($data['settings']['smtp_host']) && !empty($data['settings']['smtp_user']);
$wablasConfigured = !empty($data['settings']['wablas_api_url']) && !empty($data['settings']['wablas_token']) && !empty($data['settings']['wablas_security_key']); $wablasConfigured = !empty($data['settings']['wablas_api_url']) && !empty($data['settings']['wablas_token']) && !empty($data['settings']['wablas_security_key']);
?> ?>
<div class="row justify-content-center"> <style>
.settings-page .card-header h1 {
color: #1d4ed8 !important;
}
.settings-page #settingsTabs .nav-link {
color: #111827;
transition: color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
}
.settings-page #settingsTabs .nav-link:hover,
.settings-page #settingsTabs .nav-link:focus-visible {
color: #0f172a;
}
.settings-page #settingsTabs .nav-link.active {
color: #ffffff;
}
.settings-page .tab-pane h2,
.settings-page .tab-pane h3,
.settings-page .tab-pane h4 {
letter-spacing: -0.01em;
}
.settings-page #settings-pane-company h2,
.settings-page #settings-pane-company h3,
.settings-page #settings-pane-company h4 {
color: #2563eb;
}
.settings-page #settings-pane-system h2,
.settings-page #settings-pane-system h3,
.settings-page #settings-pane-system h4 {
color: #0f766e;
}
.settings-page #settings-pane-branding h2,
.settings-page #settings-pane-branding h3,
.settings-page #settings-pane-branding h4 {
color: #d97706;
}
.settings-page #settings-pane-integrations h2,
.settings-page #settings-pane-integrations h3,
.settings-page #settings-pane-integrations h4 {
color: #059669;
}
</style>
<div class="row justify-content-center settings-page">
<div class="col-12 col-xxl-10"> <div class="col-12 col-xxl-10">
<div class="card border-0 shadow-sm rounded-4 overflow-hidden"> <div class="card border-0 shadow-sm rounded-4 overflow-hidden">
<div class="card-header bg-white py-3 border-bottom-0 d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3"> <div class="card-header bg-white py-3 border-bottom-0 d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3">
@ -72,7 +121,7 @@ $wablasConfigured = !empty($data['settings']['wablas_api_url']) && !empty($data[
<?php foreach ($settingsTabs as $tabKey => $tabMeta): ?> <?php foreach ($settingsTabs as $tabKey => $tabMeta): ?>
<li class="nav-item flex-fill" role="presentation"> <li class="nav-item flex-fill" role="presentation">
<button <button
class="nav-link rounded-3 text-start text-lg-center px-3 py-3 <?= $activeSettingsTab === $tabKey ? 'active shadow-sm' : 'text-dark' ?>" class="nav-link rounded-3 text-start text-lg-center px-3 py-3 <?= $activeSettingsTab === $tabKey ? 'active shadow-sm' : '' ?>"
id="settings-tab-<?= htmlspecialchars($tabKey) ?>" id="settings-tab-<?= htmlspecialchars($tabKey) ?>"
data-bs-toggle="tab" data-bs-toggle="tab"
data-bs-target="#settings-pane-<?= htmlspecialchars($tabKey) ?>" data-bs-target="#settings-pane-<?= htmlspecialchars($tabKey) ?>"
@ -206,6 +255,14 @@ $wablasConfigured = !empty($data['settings']['wablas_api_url']) && !empty($data[
</select> </select>
<div class="form-text" data-en="Shows a fixed discount amount field on POS and Sales invoices." data-ar="يعرض حقل خصم ثابت في نقاط البيع وفواتير المبيعات.">Shows a fixed discount amount field on POS and Sales invoices.</div> <div class="form-text" data-en="Shows a fixed discount amount field on POS and Sales invoices." data-ar="يعرض حقل خصم ثابت في نقاط البيع وفواتير المبيعات.">Shows a fixed discount amount field on POS and Sales invoices.</div>
</div> </div>
<div class="col-md-12">
<label class="form-label text-muted small fw-semibold" data-en="Manual Discount Limit (% of Invoice Profit)" data-ar="حد الخصم اليدوي (% من ربح الفاتورة)">Manual Discount Limit (% of Invoice Profit)</label>
<div class="input-group">
<input type="number" min="0" max="100" step="0.001" name="settings[manual_discount_profit_limit_percent]" class="form-control" value="<?= htmlspecialchars($data['settings']['manual_discount_profit_limit_percent'] ?? '5') ?>">
<span class="input-group-text">%</span>
</div>
<div class="form-text" data-en="Example: 5 means users can apply manual discount up to 5% of the invoice profit margin. Uses each item's cost price. 0 blocks manual discounts; 100 allows the full profit margin." data-ar="مثال: 5 يعني أن المستخدم يمكنه تطبيق خصم يدوي حتى 5٪ من هامش ربح الفاتورة. يعتمد على سعر تكلفة كل صنف. القيمة 0 تمنع الخصم اليدوي، والقيمة 100 تسمح بكامل هامش الربح.">Example: 5 means users can apply manual discount up to 5% of the invoice profit margin. Uses each item's cost price. 0 blocks manual discounts; 100 allows the full profit margin.</div>
</div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label text-muted small fw-semibold" data-en="Scale Barcode Mode" data-ar="وضع باركود الميزان">Scale Barcode Mode</label> <label class="form-label text-muted small fw-semibold" data-en="Scale Barcode Mode" data-ar="وضع باركود الميزان">Scale Barcode Mode</label>
<select name="settings[weight_barcode_mode]" class="form-select"> <select name="settings[weight_barcode_mode]" class="form-select">