Autosave: 20260508-031143
This commit is contained in:
parent
5a46bb40e1
commit
ed140fd00d
BIN
assets/pasted-20260508-022307-a1e2a6ee.png
Normal file
BIN
assets/pasted-20260508-022307-a1e2a6ee.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
assets/pasted-20260508-024023-b5ab1c09.png
Normal file
BIN
assets/pasted-20260508-024023-b5ab1c09.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
assets/pasted-20260508-025121-d187b3f9.webp
Normal file
BIN
assets/pasted-20260508-025121-d187b3f9.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
BIN
assets/vm-shot-2026-05-08T02-21-58-361Z.jpg
Normal file
BIN
assets/vm-shot-2026-05-08T02-21-58-361Z.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
257
index.php
257
index.php
@ -1861,7 +1861,7 @@ if (isset($_GET['action']) || isset($_POST['action'])) {
|
||||
|
||||
// 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']): ?>
|
||||
<img src="<?= htmlspecialchars($p['image_path']) ?>" alt="<?= htmlspecialchars($p['name_en']) ?>">
|
||||
<?php else: ?>
|
||||
@ -2014,9 +2014,23 @@ if (isset($_GET['action']) || isset($_POST['action'])) {
|
||||
$tax_amount = (float)($_POST['tax_amount'] ?? 0);
|
||||
$discount_code_id = !empty($_POST['discount_code_id']) ? (int)$_POST['discount_code_id'] : null;
|
||||
$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';
|
||||
if (!$manualDiscountEnabled && $discount_code_id === null) {
|
||||
$discount_amount = 0.0;
|
||||
if (!$manualDiscountEnabled) {
|
||||
$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) {
|
||||
$discount_amount = max(0, $total_amount);
|
||||
@ -2166,7 +2180,7 @@ if (isset($_GET['action']) || isset($_POST['action'])) {
|
||||
$invoice['party_name'] = $partyName !== '' ? $partyName : '---';
|
||||
$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]);
|
||||
$invoice['items'] = $stmtItems->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
@ -2725,6 +2739,113 @@ function getSettingValue(string $key, ?string $default = null): ?string {
|
||||
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 {
|
||||
$prefixStart = (int)(getSettingValue('weight_barcode_prefix_start', '20') ?? '20');
|
||||
$prefixEnd = (int)(getSettingValue('weight_barcode_prefix_end', '29') ?? '29');
|
||||
@ -7712,7 +7833,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
|
||||
</div>
|
||||
<div class="product-grid" id="productGrid">
|
||||
<?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']): ?>
|
||||
<img src="<?= htmlspecialchars($p['image_path']) ?>" alt="<?= htmlspecialchars($p['name_en']) ?>">
|
||||
<?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()">
|
||||
</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>
|
||||
<?php endif; ?>
|
||||
<div>
|
||||
@ -7877,6 +7999,47 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
|
||||
isManualDiscountEnabled() {
|
||||
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) {
|
||||
if (!this.discount) return 0;
|
||||
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)));
|
||||
}, 0);
|
||||
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 redeemSwitch = document.getElementById('redeemLoyalty');
|
||||
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)
|
||||
: 0;
|
||||
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() {
|
||||
const manualDiscountInput = document.getElementById('manualDiscountAmount');
|
||||
@ -7928,6 +8094,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
|
||||
const discInfo = document.getElementById('appliedDiscountInfo');
|
||||
if (discInfo) discInfo.style.display = 'none';
|
||||
}
|
||||
this.calculateTotals(true);
|
||||
this.render();
|
||||
},
|
||||
broadcast() {
|
||||
@ -7935,7 +8102,8 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
|
||||
// Ensure items is an array
|
||||
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 totalVat = totals.totalVat;
|
||||
const discountAmount = totals.discountAmount;
|
||||
@ -7978,7 +8146,10 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
|
||||
const currentStock = normalizeQuantity(product.stock_quantity);
|
||||
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 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);
|
||||
if (existing) {
|
||||
@ -7988,6 +8159,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
|
||||
}
|
||||
existing.qty = normalizeQuantity(existing.qty + addQty);
|
||||
existing.price = unitPrice;
|
||||
existing.purchasePrice = purchasePrice;
|
||||
} else {
|
||||
if (!allowZeroStock && currentStock < addQty) {
|
||||
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', 'استجابة غير صالحة من الخادم'));
|
||||
}
|
||||
const lang = document.documentElement.lang || 'en';
|
||||
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[char] || char));
|
||||
let html = '<div class="list-group list-group-flush shadow-sm rounded">';
|
||||
if (carts.length === 0) {
|
||||
html += `
|
||||
@ -8155,21 +8334,29 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
|
||||
</div>`;
|
||||
}
|
||||
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 += `
|
||||
<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="text-start">
|
||||
<div class="fw-bold text-primary">${c.cart_name}</div>
|
||||
<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 flex-grow-1 pe-2" data-held-action="resume" data-held-cart-id="${cartId}">
|
||||
<div class="fw-bold text-primary">${cartName}</div>
|
||||
<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>
|
||||
<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 class="btn-group">
|
||||
<button class="btn btn-sm btn-primary" onclick="cart.resume(${c.id})">
|
||||
<div class="btn-group flex-shrink-0" role="group">
|
||||
<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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
@ -8184,6 +8371,36 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
|
||||
width: '700px',
|
||||
customClass: {
|
||||
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) {
|
||||
@ -8271,7 +8488,8 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const totals = this.calculateTotals(false);
|
||||
const shouldSyncManualDiscountInput = !(document.activeElement && document.activeElement.id === 'manualDiscountAmount');
|
||||
const totals = this.calculateTotals(shouldSyncManualDiscountInput);
|
||||
const discountAmount = totals.discountAmount;
|
||||
const loyaltyRedeemedValue = totals.loyaltyRedeemed;
|
||||
const total = totals.total;
|
||||
@ -8514,6 +8732,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
|
||||
const subtotal = totals.subtotal;
|
||||
const totalVat = totals.totalVat;
|
||||
const discountAmount = totals.discountAmount;
|
||||
const manualDiscountAmount = totals.manualDiscount;
|
||||
const loyaltyRedeemed = totals.loyaltyRedeemed;
|
||||
|
||||
const formData = new FormData();
|
||||
@ -8524,6 +8743,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
|
||||
formData.append('tax_amount', totalVat);
|
||||
formData.append('discount_code_id', this.discount ? this.discount.id : '');
|
||||
formData.append('discount_amount', discountAmount);
|
||||
formData.append('manual_discount_amount', manualDiscountAmount);
|
||||
formData.append('loyalty_redeemed', loyaltyRedeemed);
|
||||
formData.append('items', JSON.stringify(this.items.map(i => {
|
||||
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,
|
||||
nameAr: card.dataset.nameAr,
|
||||
price: parseFloat(card.dataset.price),
|
||||
purchasePrice: parseFloat(card.dataset.purchasePrice) || 0,
|
||||
sku: card.dataset.sku,
|
||||
stock_quantity: parseFloat(card.dataset.stockQuantity),
|
||||
vatRate: parseFloat(card.dataset.vatRate) || 0
|
||||
|
||||
@ -41,10 +41,12 @@
|
||||
name_ar: item.name_ar || item.item_name_ar || '',
|
||||
sku: item.sku || '',
|
||||
vat_rate: item.vat_rate || 0,
|
||||
purchase_price: item.purchase_price || 0,
|
||||
stock_quantity: item.stock_quantity || 0
|
||||
}, tableBody, null, null, grandTotalEl, subtotalEl, totalVatEl, {
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price
|
||||
unit_price: item.unit_price,
|
||||
purchase_price: item.purchase_price || 0
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -12,6 +12,51 @@
|
||||
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 searchInput = document.getElementById(searchInputId);
|
||||
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 qty = normalizeQuantity(customData ? customData.quantity : 1);
|
||||
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 = `
|
||||
<td>
|
||||
<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-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 class="small text-muted">${item.name_ar} (${item.sku})</div>
|
||||
</td>
|
||||
@ -247,11 +296,14 @@ ${text}` : title);
|
||||
|
||||
const rawGrandTotal = subtotal + totalVat;
|
||||
const discountInput = getInvoiceDiscountInput(tableBody);
|
||||
const discountMetrics = getInvoiceManualDiscountMetrics(tableBody);
|
||||
const maxDiscountAmount = discountInput ? Math.min(rawGrandTotal, discountMetrics.maxDiscount) : rawGrandTotal;
|
||||
let discountAmount = getInvoiceDiscountValue(tableBody);
|
||||
if (discountAmount > rawGrandTotal) {
|
||||
discountAmount = rawGrandTotal;
|
||||
if (discountAmount > maxDiscountAmount) {
|
||||
discountAmount = maxDiscountAmount;
|
||||
if (discountInput) discountInput.value = discountAmount.toFixed(3);
|
||||
}
|
||||
updateInvoiceDiscountHelp(tableBody, maxDiscountAmount, discountMetrics);
|
||||
const grandTotal = Math.max(0, rawGrandTotal - discountAmount);
|
||||
|
||||
if (subtotalEl) subtotalEl.textContent = 'OMR ' + subtotal.toFixed(3);
|
||||
|
||||
@ -496,6 +496,7 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="form-text text-end" data-invoice-discount-help></div>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
@ -612,6 +613,7 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="form-text text-end" data-invoice-discount-help></div>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
|
||||
@ -1,72 +1,200 @@
|
||||
const escapeHtml = (value) => {
|
||||
const stringValue = value == null ? '' : String(value);
|
||||
return stringValue.replace(/[&<>"']/g, (character) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[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) {
|
||||
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'));
|
||||
document.getElementById('invNumber').textContent = invoiceDisplayNo;
|
||||
document.getElementById('invDate').textContent = data.invoice_date;
|
||||
document.getElementById('invPaymentType').textContent = data.payment_type ? data.payment_type.toUpperCase() : 'CASH';
|
||||
document.getElementById('invCustomerName').textContent = data.customer_name || '---';
|
||||
|
||||
const phoneEl = document.getElementById('invCustomerPhone');
|
||||
const phoneContainer = document.getElementById('invCustomerPhoneContainer');
|
||||
if (data.customer_phone) {
|
||||
phoneEl.textContent = data.customer_phone;
|
||||
phoneContainer.style.display = 'block';
|
||||
} else {
|
||||
phoneContainer.style.display = 'none';
|
||||
|
||||
const invoiceType = String(data.type || 'sale').toLowerCase() === 'purchase' ? 'purchase' : 'sale';
|
||||
const invoiceDisplayNo = data.document_no || data.transaction_no || ((invoiceType === 'purchase' ? 'PUR-' : 'INV-') + data.id.toString().padStart(5, '0'));
|
||||
const paymentText = humanizeInvoiceText(data.payment_type || 'cash');
|
||||
const statusKey = String(data.status || '').toLowerCase();
|
||||
const statusText = statusKey === 'partially_paid' ? 'Partially Paid' : humanizeInvoiceText(statusKey);
|
||||
const typeText = invoiceType === 'purchase' ? 'Purchase Invoice' : 'Sales Invoice';
|
||||
const documentTitle = invoiceType === 'purchase' ? 'Purchase Invoice / فاتورة شراء' : 'Tax Invoice / فاتورة ضريبية';
|
||||
const documentSubtitle = invoiceType === 'purchase' ? 'Official purchase record / مستند شراء رسمي' : 'Official tax document / مستند ضريبي رسمي';
|
||||
const partyLabelText = invoiceType === 'purchase' ? 'Supplier Details / بيانات المورد' : 'Bill To / بيانات العميل';
|
||||
const partyLabelEn = invoiceType === 'purchase' ? 'Supplier Details' : 'Bill To';
|
||||
const partyLabelAr = invoiceType === 'purchase' ? 'بيانات المورد' : 'بيانات العميل';
|
||||
const formatQty = typeof window.formatQuantity === 'function'
|
||||
? 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 taxIdContainer = document.getElementById('invCustomerTaxIdContainer');
|
||||
if (data.customer_tax_id) {
|
||||
taxIdEl.textContent = data.customer_tax_id;
|
||||
taxIdContainer.style.display = 'block';
|
||||
} else {
|
||||
taxIdContainer.style.display = 'none';
|
||||
const invoiceTypeLabel = document.getElementById('invoiceTypeLabel');
|
||||
if (invoiceTypeLabel) {
|
||||
invoiceTypeLabel.textContent = typeText;
|
||||
invoiceTypeLabel.className = 'invoice-pill ' + (invoiceType === 'purchase' ? 'invoice-pill--purchase' : 'invoice-pill--sale');
|
||||
}
|
||||
|
||||
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');
|
||||
let statusClass = 'bg-secondary';
|
||||
let statusEn = data.status ? (data.status.charAt(0).toUpperCase() + data.status.slice(1)) : '---';
|
||||
if (data.status === 'paid') statusClass = 'bg-success';
|
||||
else if (data.status === 'unpaid') statusClass = 'bg-danger';
|
||||
else if (data.status === 'partially_paid') {
|
||||
statusClass = 'bg-warning text-dark';
|
||||
statusEn = 'Partially Paid';
|
||||
let statusClass = 'invoice-pill--neutral';
|
||||
if (statusKey === 'paid') {
|
||||
statusClass = 'invoice-pill--paid';
|
||||
} else if (statusKey === 'unpaid') {
|
||||
statusClass = 'invoice-pill--unpaid';
|
||||
} else if (statusKey === 'partially_paid') {
|
||||
statusClass = 'invoice-pill--partial';
|
||||
}
|
||||
|
||||
statusLabel.textContent = statusEn;
|
||||
statusLabel.className = 'badge text-uppercase ' + statusClass;
|
||||
|
||||
if (statusLabel) {
|
||||
statusLabel.textContent = statusText;
|
||||
statusLabel.className = 'invoice-pill ' + statusClass;
|
||||
}
|
||||
|
||||
const body = document.getElementById('invItemsBody');
|
||||
body.innerHTML = '';
|
||||
if (data.items) {
|
||||
data.items.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${item.name_en} / ${item.name_ar}</td>
|
||||
<td class="text-center">${formatQuantity(item.quantity)}</td>
|
||||
<td class="text-end"><small><?= __('currency') ?></small> ${parseFloat(item.unit_price).toFixed(3)}</td>
|
||||
<td class="text-end">${parseFloat(item.vat_rate || 0).toFixed(2)}%</td>
|
||||
<td class="text-end"><small><?= __('currency') ?></small> ${parseFloat(item.total_price).toFixed(3)}</td>
|
||||
`;
|
||||
body.appendChild(tr);
|
||||
});
|
||||
if (body) {
|
||||
body.innerHTML = '';
|
||||
if (Array.isArray(data.items) && data.items.length > 0) {
|
||||
data.items.forEach((item, index) => {
|
||||
const tr = document.createElement('tr');
|
||||
const englishName = escapeHtml(item.name_en || item.name || 'Item');
|
||||
const arabicName = escapeHtml(item.name_ar || '');
|
||||
const itemNameHtml = arabicName
|
||||
? `<div class="invoice-item-name">${englishName}</div><div class="invoice-item-secondary">${arabicName}</div>`
|
||||
: `<div class="invoice-item-name">${englishName}</div>`;
|
||||
|
||||
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 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);
|
||||
@ -75,63 +203,88 @@
|
||||
const grandTotalParsed = parseFloat(data.total_with_vat);
|
||||
const grandTotalValue = Number.isFinite(grandTotalParsed) ? grandTotalParsed : Math.max(0, grossBeforeDiscount - discountVal);
|
||||
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);
|
||||
if (document.getElementById('invVatAmount')) document.getElementById('invVatAmount').innerHTML = '<small><?= __('currency') ?></small> ' + vatVal.toFixed(2);
|
||||
const subtotalEl = document.getElementById('invSubtotal');
|
||||
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 discountAmountEl = document.getElementById('invDiscountAmount');
|
||||
if (discountRow && discountAmountEl) {
|
||||
if (discountVal > 0) {
|
||||
discountRow.style.display = 'flex';
|
||||
discountAmountEl.innerHTML = '<small><?= __('currency') ?></small> ' + discountVal.toFixed(3);
|
||||
discountAmountEl.innerHTML = formatInvoiceCurrency(discountVal, 3);
|
||||
} else {
|
||||
discountRow.style.display = 'none';
|
||||
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 balance = Math.max(0, grandTotalValue - parseFloat(data.paid_amount || 0));
|
||||
if (document.getElementById('invBalanceInfo')) document.getElementById('invBalanceInfo').innerHTML = '<small><?= __('currency') ?></small> ' + balance.toFixed(3);
|
||||
const paidInfoEl = document.getElementById('invPaidInfo');
|
||||
if (paidInfoEl) paidInfoEl.innerHTML = formatInvoiceCurrency(paidAmount, 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 vatNo = <?= json_encode($data['settings']['vat_number'] ?? '') ?>;
|
||||
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)}`;
|
||||
if (document.getElementById('invQrCode')) {
|
||||
document.getElementById('invQrCode').innerHTML = `<img src="${qrUrl}" alt="QR Code" style="width: 100px; height: 100px;" class="border p-1 bg-white">`;
|
||||
}
|
||||
|
||||
const viewModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('viewInvoiceModal'));
|
||||
viewModal.show();
|
||||
|
||||
if (autoPrint) {
|
||||
setTimeout(() => { window.print(); }, 1000);
|
||||
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=112x112&data=${encodeURIComponent(qrData)}`;
|
||||
const qrCode = document.getElementById('invQrCode');
|
||||
if (qrCode) {
|
||||
qrCode.innerHTML = `<img src="${qrUrl}" alt="QR Code" width="112" height="112" class="bg-white rounded" style="width: 112px; height: 112px;">`;
|
||||
}
|
||||
|
||||
fetch(`index.php?action=get_payments&invoice_id=${data.id}`)
|
||||
.then(res => res.json())
|
||||
.then(payments => {
|
||||
const viewModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('viewInvoiceModal'));
|
||||
viewModal.show();
|
||||
|
||||
const paymentsPromise = fetch(`index.php?action=get_payments&invoice_id=${data.id}`)
|
||||
.then((res) => res.json())
|
||||
.then((payments) => {
|
||||
const paymentsBody = document.getElementById('invPaymentsBody');
|
||||
const paymentsSection = document.getElementById('invPaymentsSection');
|
||||
if (paymentsBody) paymentsBody.innerHTML = '';
|
||||
if (payments && payments.length > 0) {
|
||||
|
||||
if (Array.isArray(payments) && payments.length > 0) {
|
||||
if (paymentsBody) {
|
||||
payments.forEach(p => {
|
||||
const tr = document.createElement('tr');
|
||||
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>`;
|
||||
paymentsBody.appendChild(tr);
|
||||
payments.forEach((payment) => {
|
||||
const paymentChip = document.createElement('div');
|
||||
paymentChip.className = 'invoice-payment-pill';
|
||||
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';
|
||||
} else {
|
||||
if (paymentsSection) paymentsSection.style.display = 'none';
|
||||
} else if (paymentsSection) {
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -28,6 +28,7 @@
|
||||
|
||||
$total_subtotal = 0;
|
||||
$total_vat = 0;
|
||||
$profitLines = [];
|
||||
|
||||
foreach ($items as $i => $item_id) {
|
||||
if (!$item_id) continue;
|
||||
@ -42,6 +43,11 @@
|
||||
$vatAmount = $subtotal * ($vatRate / 100);
|
||||
$total_subtotal += $subtotal;
|
||||
$total_vat += $vatAmount;
|
||||
$profitLines[] = [
|
||||
'item_id' => (int)$item_id,
|
||||
'qty' => $qty,
|
||||
'unit_price' => $price,
|
||||
];
|
||||
}
|
||||
|
||||
$gross_total = $total_subtotal + $total_vat;
|
||||
@ -50,6 +56,11 @@
|
||||
$discount_amount = 0.0;
|
||||
if ($type === 'sale' && $hasDiscountColumn && $manualDiscountEnabled) {
|
||||
$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) {
|
||||
$discount_amount = $gross_total;
|
||||
}
|
||||
@ -143,6 +154,7 @@
|
||||
|
||||
$total_subtotal = 0;
|
||||
$total_vat = 0;
|
||||
$profitLines = [];
|
||||
|
||||
foreach ($items as $i => $item_id) {
|
||||
if (!$item_id) continue;
|
||||
@ -157,6 +169,11 @@
|
||||
$vatAmount = $subtotal * ($vatRate / 100);
|
||||
$total_subtotal += $subtotal;
|
||||
$total_vat += $vatAmount;
|
||||
$profitLines[] = [
|
||||
'item_id' => (int)$item_id,
|
||||
'qty' => $qty,
|
||||
'unit_price' => $price,
|
||||
];
|
||||
}
|
||||
|
||||
$gross_total = $total_subtotal + $total_vat;
|
||||
@ -166,6 +183,11 @@
|
||||
if ($hasDiscountColumn) {
|
||||
if ($manualDiscountEnabled && array_key_exists('discount_amount', $_POST)) {
|
||||
$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 {
|
||||
$existingDiscountStmt = $db->prepare("SELECT discount_amount FROM $table WHERE id = ? LIMIT 1");
|
||||
$existingDiscountStmt->execute([$id]);
|
||||
|
||||
@ -106,6 +106,15 @@ if (isset($_POST['update_settings'])) {
|
||||
|
||||
$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';
|
||||
$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['smtp_enabled'] = (($settings['smtp_enabled'] ?? '0') === '1') ? '1' : '0';
|
||||
$settings['wablas_enabled'] = (($settings['wablas_enabled'] ?? '0') === '1') ? '1' : '0';
|
||||
|
||||
@ -26,7 +26,56 @@ $timezoneIdentifiers = DateTimeZone::listIdentifiers();
|
||||
$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']);
|
||||
?>
|
||||
<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="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">
|
||||
@ -72,7 +121,7 @@ $wablasConfigured = !empty($data['settings']['wablas_api_url']) && !empty($data[
|
||||
<?php foreach ($settingsTabs as $tabKey => $tabMeta): ?>
|
||||
<li class="nav-item flex-fill" role="presentation">
|
||||
<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) ?>"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#settings-pane-<?= htmlspecialchars($tabKey) ?>"
|
||||
@ -206,6 +255,14 @@ $wablasConfigured = !empty($data['settings']['wablas_api_url']) && !empty($data[
|
||||
</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>
|
||||
<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">
|
||||
<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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user