Commit b16d18aa authored by DevPilot's avatar DevPilot

feat(hr,accounting): tax bracket admin screen, scoped voucher account lookup, gap drill-down

Three things that were asked for and were genuinely missing:

1. ضريبة كسب العمل had no screen at all — the brackets lived only in
   hr_tax_brackets and could only be changed with SQL. Adds a proper
   admin screen: brackets are versioned as a set per effective_date,
   old sets are kept (never deleted) so past payroll stays explainable,
   and activating a set deactivates the others. The form validates that
   brackets are contiguous, that only the last one is open-ended, and
   auto-fills the next bracket's start. A built-in simulator shows the
   tax on any annual income so the accountant can verify the set before
   running payroll.

   Also hardens the live calculator: IncomeTaxService summed every row
   flagged active regardless of effective_date, so two overlapping
   active sets produced a silently wrong tax. It now uses the newest
   active set only.

2. The voucher screen searched the entire chart of accounts for both
   sides, so you could pick a fixed-asset account as the cash side or a
   cash account as the expense side — the latter trips the "same as the
   cash account" guard and the voucher just refuses to save. The lookup
   is now scoped per side: cash/bank accounts for the counter side, and
   expense (صرف) or revenue (قبض) accounts for the line side depending
   on the voucher's direction.

3. The accounting gaps screen showed only a count per gap. Each gap now
   has a "عرض التفاصيل" button that lists the actual documents behind
   that number — id, date, party, quantity, recorded amount and status
   — so the accountant can check the cases before choosing a rate.
parent 33dc1451
......@@ -37,6 +37,17 @@ class GapController extends Controller
]);
}
/**
* الحالات اللي جوّه فجوة معيّنة — بيتفتحوا من زر «عرض التفاصيل» في الشاشة.
*/
public function details(Request $request): Response
{
$this->authorize('accounting.gaps.view');
$stream = (string) $request->get('stream', '');
return $this->json(GapToolService::details($stream));
}
/**
* What a proposed rate would book. Answered without writing anything, so the
* accountant can try three numbers before picking one.
......
......@@ -66,6 +66,63 @@ class VoucherController extends Controller
]);
}
/**
* استعلام الحسابات المناسبة لكل طرف في السند.
*
* السند له طرفان: طرف النقدية/البنك (counter) وطرف المصروف أو الإيراد (line).
* البحث الحر في كل دليل الحسابات كان بيرجّع حسابات لا تصلح للطرف المطلوب —
* فبقى ممكن تختار حساب أصل ثابت كطرف نقدية، أو حساب نقدية كطرف مصروف،
* والقيد يطلع غلط أو يلغي نفسه. هنا بنضيّق النتائج حسب الطرف واتجاه السند.
*
* side=counter → النقدية والبنوك فقط
* side=line + outflow صرف → المصروفات والأصول والالتزامات (اللي بيتصرف عليها)
* side=line + inflow قبض → الإيرادات والأصول والالتزامات (اللي بيتقبض منها)
*/
public function searchAccounts(Request $request): Response
{
$this->authorize('accounting.voucher.view');
$db = App::getInstance()->db();
$q = trim((string) $request->get('q', ''));
$side = (string) $request->get('side', 'line');
$dir = (string) $request->get('direction', 'outflow');
$where = ['is_archived = 0', 'is_active = 1', 'is_header = 0'];
$params = [];
if ($q !== '') {
$where[] = '(account_code LIKE ? OR name_ar LIKE ? OR name_en LIKE ?)';
$like = '%' . $q . '%';
$params[] = $like; $params[] = $like; $params[] = $like;
}
if ($side === 'counter') {
// النقدية وما في حكمها فقط: الصناديق والبنوك
$where[] = "account_type = 'asset'";
$where[] = "(is_bank_account = 1
OR account_code LIKE '1206%'
OR name_ar LIKE '%نقدية%' OR name_ar LIKE '%صندوق%'
OR name_ar LIKE '%بنك%' OR name_ar LIKE '%خزينة%')";
} else {
// طرف المصروف/الإيراد — ونستبعد حسابات النقدية عشان ما يتكررش الطرفين
$where[] = $dir === 'inflow'
? "account_type IN ('revenue','asset','liability')"
: "account_type IN ('expense','asset','liability')";
$where[] = "NOT (is_bank_account = 1 OR account_code LIKE '1206%')";
}
return $this->json([
'accounts' => $db->select(
"SELECT id, account_code, name_ar, account_type
FROM chart_of_accounts
WHERE " . implode(' AND ', $where) . "
ORDER BY account_code
LIMIT 60",
$params
),
]);
}
public function create(Request $request): Response
{
$this->authorize('accounting.voucher.create');
......
......@@ -202,6 +202,7 @@ return [
// ── Gap tools (close what the scanner will not guess at) ─
['GET', '/accounting/gaps', 'Accounting\Controllers\GapController@index', ['auth'], 'accounting.gaps.view'],
['GET', '/accounting/gaps/preview', 'Accounting\Controllers\GapController@preview', ['auth'], 'accounting.gaps.view'],
['GET', '/accounting/gaps/details', 'Accounting\\Controllers\\GapController@details', ['auth'], 'accounting.gaps.view'],
['POST', '/accounting/gaps', 'Accounting\Controllers\GapController@save', ['auth', 'csrf'], 'accounting.gaps.manage'],
['POST', '/accounting/gaps/academy-contracts', 'Accounting\Controllers\GapController@importAcademyContracts', ['auth', 'csrf'], 'accounting.gaps.manage'],
......@@ -229,6 +230,7 @@ return [
// ── Vouchers (payment / receipt) ────────────────────────
['GET', '/accounting/vouchers', 'Accounting\\Controllers\\VoucherController@index', ['auth'], 'accounting.voucher.view'],
['GET', '/accounting/vouchers/search-accounts', 'Accounting\\Controllers\\VoucherController@searchAccounts', ['auth'], 'accounting.voucher.view'],
['GET', '/accounting/vouchers/create', 'Accounting\\Controllers\\VoucherController@create', ['auth'], 'accounting.voucher.create'],
['POST', '/accounting/vouchers', 'Accounting\\Controllers\\VoucherController@store', ['auth', 'csrf'], 'accounting.voucher.create'],
['GET', '/accounting/vouchers/types', 'Accounting\\Controllers\\VoucherController@types', ['auth'], 'accounting.voucher.manage'],
......
......@@ -41,6 +41,17 @@ final class GapToolService
'unit_sql' => "SELECT COUNT(*) AS n, COALESCE(SUM(current_occupancy),0) AS attendees
FROM sa_pool_zone_bookings
WHERE COALESCE(status,'') NOT IN ('cancelled')",
'detail_sql' => "SELECT b.id,
COALESCE(b.booking_date, DATE(b.created_at)) AS the_date,
COALESCE(z.name_ar, CONCAT('منطقة #', b.zone_id)) AS party,
b.current_occupancy AS qty,
NULL AS amount,
b.status AS status
FROM sa_pool_zone_bookings b
LEFT JOIN sa_pool_zones z ON z.id = b.zone_id
WHERE COALESCE(b.status,'') NOT IN ('cancelled')
ORDER BY the_date DESC, b.id DESC
LIMIT 300",
'amount_sql' => null,
'note' => 'العدّاد شغّال على الحجوزات غير الملغية. لو اخترت «لكل حاضر» '
. 'وعدد الحاضرين متسجّل صفر، مش هينزل حاجة — استخدم «لكل حجز».',
......@@ -53,6 +64,17 @@ final class GapToolService
'unit_sql' => "SELECT COUNT(*) AS n, COUNT(*) AS attendees
FROM sa_player_cards
WHERE COALESCE(status,'') NOT IN ('cancelled','expired')",
'detail_sql' => "SELECT c.id,
COALESCE(c.issue_date, DATE(c.created_at)) AS the_date,
COALESCE(m.full_name_ar, CONCAT('لاعب #', c.player_id)) AS party,
1 AS qty,
NULL AS amount,
c.status AS status
FROM sa_player_cards c
LEFT JOIN members m ON m.id = c.player_id
WHERE COALESCE(c.status,'') NOT IN ('cancelled','expired')
ORDER BY the_date DESC, c.id DESC
LIMIT 300",
'amount_sql' => null,
'note' => 'رسم إصدار الكارنيه. لو الرسم بيختلف حسب النوع، حدّد الأشيع '
. 'هنا وعدّل الباقي يدوي.',
......@@ -67,6 +89,18 @@ final class GapToolService
FROM pool_bookings
WHERE COALESCE(status,'') NOT IN ('cancelled')
AND payment_id IS NULL",
'detail_sql' => "SELECT b.id,
COALESCE(b.booking_date, DATE(b.created_at)) AS the_date,
COALESCE(m.full_name_ar, b.guest_name, 'زائر') AS party,
COALESCE(b.actual_swimmers, b.expected_swimmers, 0) AS qty,
b.total_amount AS amount,
b.status AS status
FROM pool_bookings b
LEFT JOIN members m ON m.id = b.member_id
WHERE COALESCE(b.status,'') NOT IN ('cancelled')
AND b.payment_id IS NULL
ORDER BY the_date DESC, b.id DESC
LIMIT 300",
'amount_sql' => "SELECT COALESCE(SUM(total_amount),0) AS total
FROM pool_bookings
WHERE COALESCE(status,'') NOT IN ('cancelled') AND payment_id IS NULL",
......@@ -83,6 +117,17 @@ final class GapToolService
FROM private_match_bookings
WHERE COALESCE(status,'') NOT IN ('cancelled')
AND COALESCE(payment_status,'') <> 'paid'",
'detail_sql' => "SELECT b.id,
COALESCE(b.booking_date, DATE(b.created_at)) AS the_date,
COALESCE(m.full_name_ar, b.guest_name, 'زائر') AS party,
1 AS qty,
b.total_amount AS amount,
b.status AS status
FROM private_match_bookings b
LEFT JOIN members m ON m.id = b.member_id
WHERE COALESCE(b.status,'') NOT IN ('cancelled')
ORDER BY the_date DESC, b.id DESC
LIMIT 300",
'amount_sql' => "SELECT COALESCE(SUM(COALESCE(total_cost, deposit_paid, 0)),0) AS total
FROM private_match_bookings
WHERE COALESCE(status,'') NOT IN ('cancelled')
......@@ -93,6 +138,31 @@ final class GapToolService
];
/** @return array<int, array> */
/**
* الحالات الفعلية جوّه فجوة معيّنة.
*
* العدّاد في الشاشة بيقول «٣١٤ حالة» — وده مش كفاية عشان المحاسب يتأكد.
* هنا بنرجّع الصفوف نفسها (رقم، تاريخ، الطرف، الكمية، المبلغ، الحالة) عشان
* يقدر يراجعها قبل ما يقرّر السعر اللي هيسدّ بيه الفجوة.
*
* @return array{available:bool, rows:array, label:string}
*/
public static function details(string $streamCode): array
{
$gap = self::GAPS[$streamCode] ?? null;
if ($gap === null || empty($gap['detail_sql'])) {
return ['available' => false, 'rows' => [], 'label' => $streamCode];
}
try {
$rows = App::getInstance()->db()->select($gap['detail_sql']);
} catch (\Throwable $e) {
return ['available' => false, 'rows' => [], 'label' => $gap['label']];
}
return ['available' => true, 'rows' => $rows, 'label' => $gap['label']];
}
public static function gaps(): array
{
$db = App::getInstance()->db();
......
......@@ -76,6 +76,12 @@
<div style="font-size:11px;color:#065F46;margin-top:6px;">هيتقيّد بالوضع الحالي</div>
<div style="font-size:17px;font-weight:700;color:#065F46;"><?= money($g['projected']) ?></div>
<?php endif; ?>
<?php if ((int) $g['units'] > 0): ?>
<button type="button" class="btn btn-sm btn-outline gap-details-btn"
style="margin-top:8px;font-size:12px;"
data-stream="<?= e($g['stream_code']) ?>"
data-label="<?= e($g['label']) ?>">عرض التفاصيل</button>
<?php endif; ?>
</div>
</div>
......@@ -376,4 +382,68 @@ foreach ($brokenLinks as $bl) {
<?php endif; ?>
<!-- تفاصيل الفجوة -->
<div id="gap-details-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:80;align-items:flex-start;justify-content:center;padding:40px 20px;overflow:auto;">
<div class="card" style="max-width:1000px;width:100%;background:#fff;">
<div style="padding:14px 18px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 id="gap-details-title" style="margin:0;font-size:15px;color:#0D7377;">تفاصيل الفجوة</h3>
<button type="button" id="gap-details-close" class="btn btn-sm btn-outline">إغلاق</button>
</div>
<div id="gap-details-body" style="padding:16px 18px;max-height:70vh;overflow:auto;">
<p style="color:#6B7280;">جارٍ التحميل…</p>
</div>
</div>
</div>
<script>
(function () {
var modal = document.getElementById('gap-details-modal');
var body = document.getElementById('gap-details-body');
var title = document.getElementById('gap-details-title');
document.getElementById('gap-details-close').addEventListener('click', function () {
modal.style.display = 'none';
});
modal.addEventListener('click', function (e) { if (e.target === modal) modal.style.display = 'none'; });
document.querySelectorAll('.gap-details-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
title.textContent = 'تفاصيل: ' + (btn.dataset.label || '');
body.innerHTML = '<p style="color:#6B7280;">جارٍ التحميل…</p>';
modal.style.display = 'flex';
fetch('/accounting/gaps/details?stream=' + encodeURIComponent(btn.dataset.stream))
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.available || !d.rows || !d.rows.length) {
body.innerHTML = '<p style="color:#6B7280;">لا توجد تفاصيل متاحة لهذه الفجوة على هذا التركيب.</p>';
return;
}
var html = '<p style="color:#6B7280;font-size:12px;margin:0 0 10px;">أول ' + d.rows.length +
' حالة — دي المستندات اللي العدّاد بيحسبها.</p>' +
'<div class="table-responsive"><table class="data-table"><thead><tr>' +
'<th>#</th><th>التاريخ</th><th>الطرف</th><th>العدد</th><th>المبلغ المسجّل</th><th>الحالة</th>' +
'</tr></thead><tbody>';
d.rows.forEach(function (r) {
html += '<tr>' +
'<td style="font-family:monospace;font-size:12px;">' + (r.id || '') + '</td>' +
'<td>' + (r.the_date || '—') + '</td>' +
'<td>' + (r.party || '—') + '</td>' +
'<td>' + (r.qty == null ? '—' : r.qty) + '</td>' +
'<td style="direction:ltr;text-align:left;">' + (r.amount == null ? '— غير مسجّل' : r.amount) + '</td>' +
'<td>' + (r.status || '—') + '</td>' +
'</tr>';
});
html += '</tbody></table></div>';
body.innerHTML = html;
})
.catch(function () {
body.innerHTML = '<p style="color:#DC2626;">تعذر تحميل التفاصيل.</p>';
});
});
});
})();
</script>
<?php $__template->endSection(); ?>
......@@ -162,14 +162,23 @@
var typeSel = document.getElementById('v-type');
var method = document.getElementById('v-method');
function wireSearch(input, hidden, results) {
function currentDirection() {
var o = typeSel.options[typeSel.selectedIndex];
return (o && o.dataset.direction === 'inflow') ? 'inflow' : 'outflow';
}
// side = 'counter' (النقدية/البنك) أو 'line' (المصروف/الإيراد) — الاستعلام بيرجّع
// الحسابات المناسبة للطرف ده فقط بدل كل دليل الحسابات.
function wireSearch(input, hidden, results, side) {
var timer = null;
input.addEventListener('input', function () {
clearTimeout(timer);
var q = input.value.trim();
if (q.length < 2) { results.innerHTML = ''; return; }
timer = setTimeout(function () {
fetch('/accounting/revenue-mapping/search-accounts?q=' + encodeURIComponent(q))
fetch('/accounting/vouchers/search-accounts?side=' + (side || 'line')
+ '&direction=' + currentDirection()
+ '&q=' + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (d) {
results.innerHTML = '';
......@@ -193,13 +202,13 @@
});
}
wireSearch(document.getElementById('counter-search'), document.getElementById('counter-id'), document.getElementById('counter-results'));
wireSearch(document.getElementById('counter-search'), document.getElementById('counter-id'), document.getElementById('counter-results'), 'counter');
function addLine() {
var node = tpl.content.cloneNode(true);
var el = node.querySelector('.v-line');
linesBox.appendChild(node);
wireSearch(el.querySelector('.l-search'), el.querySelector('.l-acct'), el.querySelector('.acct-results'));
wireSearch(el.querySelector('.l-search'), el.querySelector('.l-acct'), el.querySelector('.acct-results'), 'line');
el.querySelector('.l-amount').addEventListener('input', render);
el.querySelector('.l-tax').addEventListener('change', render);
el.querySelector('.l-del').addEventListener('click', function () { el.remove(); render(); });
......
<?php
declare(strict_types=1);
namespace App\Modules\HR\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\HR\Services\TaxBracketAdminService;
/**
* شرائح ضريبة كسب العمل — إدخالها وتعديلها من الشاشة بدل قاعدة البيانات.
*/
class TaxBracketController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('hr.tax.brackets.manage');
$income = (string) $request->get('simulate', '120000');
return $this->view('HR.Views.tax.brackets', [
'sets' => TaxBracketAdminService::sets(),
'simulation' => TaxBracketAdminService::simulate($income),
'income' => $income,
]);
}
public function create(Request $request): Response
{
$this->authorize('hr.tax.brackets.manage');
// «نسخة من» تملأ الشاشة بشرائح مجموعة قائمة عشان تعدّل فيها بدل ما تكتبها من أول
$copyFrom = (string) $request->get('copy_from', '');
$rows = $copyFrom !== '' ? TaxBracketAdminService::set($copyFrom) : TaxBracketAdminService::activeSet();
return $this->view('HR.Views.tax.bracket_form', [
'rows' => $rows,
'copyFrom' => $copyFrom,
]);
}
public function store(Request $request): Response
{
$this->authorize('hr.tax.brackets.manage');
$froms = (array) $request->post('from_amount', []);
$tos = (array) $request->post('to_amount', []);
$rates = (array) $request->post('rate', []);
$rows = [];
for ($i = 0; $i < count($froms); $i++) {
$rows[] = [
'from' => $froms[$i] ?? '',
'to' => $tos[$i] ?? '',
'rate' => $rates[$i] ?? '',
];
}
$rows['exemption'] = $request->post('annual_exemption', '0');
$result = TaxBracketAdminService::saveSet(
(string) $request->postDate('effective_date'),
$rows,
$request->post('activate') === '1'
);
if (!$result['success']) {
return $this->redirect('/hr/tax/brackets/create')->withError($result['error']);
}
return $this->redirect('/hr/tax/brackets')->withSuccess('تم حفظ شرائح الضريبة');
}
public function activate(Request $request): Response
{
$this->authorize('hr.tax.brackets.manage');
$result = TaxBracketAdminService::activate((string) $request->post('effective_date', ''));
if (!$result['success']) {
return $this->redirect('/hr/tax/brackets')->withError($result['error']);
}
return $this->redirect('/hr/tax/brackets')->withSuccess('تم تفعيل المجموعة — الرواتب الجاية هتتحسب بيها');
}
}
......@@ -113,6 +113,10 @@ return [
['GET', '/hr/insurance/form6', 'HR\Controllers\InsuranceController@form6', ['auth'], 'hr.insurance.manage'],
// ── Tax ──
['GET', '/hr/tax/brackets', 'HR\Controllers\TaxBracketController@index', ['auth'], 'hr.tax.brackets.manage'],
['GET', '/hr/tax/brackets/create', 'HR\Controllers\TaxBracketController@create', ['auth'], 'hr.tax.brackets.manage'],
['POST', '/hr/tax/brackets', 'HR\Controllers\TaxBracketController@store', ['auth', 'csrf'], 'hr.tax.brackets.manage'],
['POST', '/hr/tax/brackets/activate', 'HR\Controllers\TaxBracketController@activate', ['auth', 'csrf'], 'hr.tax.brackets.manage'],
['GET', '/hr/tax', 'HR\Controllers\TaxController@index', ['auth'], 'hr.tax.view'],
['GET', '/hr/tax/employee/{employeeId:\d+}', 'HR\Controllers\TaxController@employeeHistory', ['auth'], 'hr.tax.view'],
......
......@@ -15,8 +15,11 @@ use App\Core\Logger;
* 55,001 - 70,000: 15%
* 70,001 - 200,000: 20%
* 200,001 - 400,000: 22.5%
* 400,001 - 1,200,000: 25%
* Above 1,200,000: 27.5%
* 400,001 - 600,000: 25%
* Above 600,000: 27.5%
*
* These are defaults only — the live brackets come from hr_tax_brackets and are
* edited from «الموارد البشرية » شرائح ضريبة كسب العمل».
*
* Personal exemption: 20,000 EGP/year
* Insurance exemption: employee's social insurance contribution
......@@ -170,10 +173,17 @@ final class IncomeTaxService
$db = App::getInstance()->db();
// Try dedicated hr_tax_brackets table first
// Only the newest active set. Brackets are versioned by effective_date, and
// summing two active sets together silently produces a nonsense tax — so the
// set with the latest effective_date wins even if an old one is still flagged
// active from a manual edit.
$dbBrackets = $db->select(
"SELECT from_amount, to_amount, rate, annual_exemption
FROM hr_tax_brackets
WHERE is_active = 1
AND effective_date = (
SELECT MAX(effective_date) FROM hr_tax_brackets WHERE is_active = 1
)
ORDER BY bracket_order ASC"
);
......
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>مجموعة شرائح ضريبية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/hr/tax/brackets" class="btn btn-outline">
<i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> رجوع
</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:16px;padding:14px 18px;background:#FFFBEB;border:1px solid #FDE68A;">
<p style="margin:0;font-size:13px;line-height:2;color:#92400E;">
اكتب الشرائح زي ما هي في القانون. قواعد لازم تتحقق وإلا الحفظ هيترفض:
<br>• الشرائح لازم تكون <strong>متصلة</strong> — نهاية كل شريحة = بداية اللي بعدها.
<br>• الشريحة الأخيرة تُترك <strong>نهايتها فاضية</strong> (يعني «فأكثر»).
<br><strong>الإعفاء الشخصي السنوي</strong> بيتخصم من الدخل قبل تطبيق الشرائح.
<br>• لما تفعّل المجموعة دي، أي مجموعة تانية بتتوقف تلقائيًا.
</p>
</div>
<div class="card">
<div style="padding:16px 18px;">
<form method="POST" action="/hr/tax/brackets">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">تاريخ سريان القانون <span style="color:#DC2626;">*</span></label>
<input type="date" name="effective_date" class="form-input" required value="<?= e(date('Y-01-01')) ?>">
</div>
<div class="form-group">
<label class="form-label">الإعفاء الشخصي السنوي</label>
<input type="number" name="annual_exemption" class="form-input" step="0.01" min="0"
style="direction:ltr;text-align:left;"
value="<?= e($rows[0]['annual_exemption'] ?? '0') ?>">
</div>
<div class="form-group">
<label class="form-label">التفعيل</label>
<label style="display:flex;align-items:center;gap:8px;margin-top:8px;font-size:13px;">
<input type="checkbox" name="activate" value="1" checked>
اجعلها المجموعة السارية
</label>
</div>
</div>
<div class="card" style="margin-top:10px;">
<div style="padding:12px 16px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<strong style="font-size:14px;">الشرائح</strong>
<button type="button" id="add-row" class="btn btn-sm btn-outline">+ شريحة</button>
</div>
<div class="table-responsive">
<table class="data-table" id="brackets-table">
<thead>
<tr><th style="width:34%;">من (سنوي)</th><th style="width:34%;">إلى (اتركها فاضية للأخيرة)</th><th style="width:22%;">النسبة %</th><th style="width:10%;"></th></tr>
</thead>
<tbody id="rows">
<?php
$seed = [];
foreach ($rows as $r) {
$to = (string) ($r['to_amount'] ?? '');
$seed[] = [
'from' => (string) ($r['from_amount'] ?? ''),
'to' => ($to !== '' && bccomp($to, '99999999', 2) >= 0) ? '' : $to,
'rate' => isset($r['rate']) ? rtrim(rtrim(number_format((float) $r['rate'] * 100, 2, '.', ''), '0'), '.') : '',
];
}
if (empty($seed)) {
$seed = [['from' => '0', 'to' => '', 'rate' => '0']];
}
foreach ($seed as $r):
?>
<tr>
<td><input type="number" name="from_amount[]" class="form-input" step="0.01" min="0" value="<?= e($r['from']) ?>" style="direction:ltr;text-align:left;"></td>
<td><input type="number" name="to_amount[]" class="form-input" step="0.01" min="0" value="<?= e($r['to']) ?>" style="direction:ltr;text-align:left;"></td>
<td><input type="number" name="rate[]" class="form-input" step="0.01" min="0" max="100" value="<?= e($r['rate']) ?>" style="direction:ltr;text-align:left;"></td>
<td><button type="button" class="btn btn-sm del-row" style="background:#FEE2E2;color:#DC2626;border:none;">حذف</button></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<button type="submit" class="btn btn-primary" style="margin-top:14px;">حفظ الشرائح</button>
</form>
</div>
</div>
<script>
(function () {
var rows = document.getElementById('rows');
document.getElementById('add-row').addEventListener('click', function () {
var tr = document.createElement('tr');
tr.innerHTML =
'<td><input type="number" name="from_amount[]" class="form-input" step="0.01" min="0" style="direction:ltr;text-align:left;"></td>' +
'<td><input type="number" name="to_amount[]" class="form-input" step="0.01" min="0" style="direction:ltr;text-align:left;"></td>' +
'<td><input type="number" name="rate[]" class="form-input" step="0.01" min="0" max="100" style="direction:ltr;text-align:left;"></td>' +
'<td><button type="button" class="btn btn-sm del-row" style="background:#FEE2E2;color:#DC2626;border:none;">حذف</button></td>';
rows.appendChild(tr);
});
rows.addEventListener('click', function (e) {
if (e.target.classList.contains('del-row')) {
if (rows.children.length > 1) e.target.closest('tr').remove();
}
});
// لما تكتب نهاية شريحة، بداية اللي بعدها بتتملى تلقائيًا عشان ما يحصلش فجوة
rows.addEventListener('change', function (e) {
if (e.target.name !== 'to_amount[]') return;
var tr = e.target.closest('tr');
var next = tr.nextElementSibling;
if (next && e.target.value !== '') {
var f = next.querySelector('input[name="from_amount[]"]');
if (f && !f.value) f.value = e.target.value;
}
});
})();
document.addEventListener('DOMContentLoaded', function () { if (typeof lucide !== 'undefined') lucide.createIcons(); });
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>شرائح ضريبة كسب العمل<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/hr/tax/brackets/create" class="btn btn-primary">
<i data-lucide="plus" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> مجموعة شرائح جديدة
</a>
<a href="/hr/tax" class="btn btn-outline">سجلات الضرائب</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:18px;padding:14px 18px;background:#F0FDFA;border:1px solid #99F6E4;">
<p style="margin:0;font-size:13px;line-height:2;color:#134E4A;">
الشرائح دي هي اللي محرك الرواتب بيحسب بيها ضريبة كسب العمل لكل موظف كل شهر.
كل تعديل في القانون بيتسجّل كـ<strong>مجموعة جديدة بتاريخ سريانها</strong>، والقديمة بتتوقف ولا تتحذف
عشان رواتب الشهور اللي فاتت تفضل مفهومة بالقانون اللي كان ساري وقتها.
<strong>مجموعة واحدة بس هي اللي بتكون سارية في أي وقت.</strong>
</p>
</div>
<!-- محاكاة سريعة -->
<div class="card" style="margin-bottom:18px;">
<div style="padding:14px 18px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:15px;color:#0D7377;">تجربة الحساب على دخل سنوي</h3>
</div>
<div style="padding:16px 18px;">
<form method="GET" action="/hr/tax/brackets" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;margin-bottom:14px;">
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">الدخل السنوي الخاضع (بعد التأمينات)</label>
<input type="number" name="simulate" class="form-input" step="0.01" min="0"
value="<?= e($income) ?>" style="direction:ltr;text-align:left;max-width:200px;">
</div>
<button type="submit" class="btn btn-outline">احسب</button>
</form>
<?php if (!empty($simulation['available']) && !empty($simulation['lines'])): ?>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr><th>من</th><th>إلى</th><th>النسبة</th><th>الجزء الخاضع</th><th>الضريبة</th></tr>
</thead>
<tbody>
<?php foreach ($simulation['lines'] as $l): ?>
<tr>
<td style="direction:ltr;text-align:left;"><?= money($l['from']) ?></td>
<td style="direction:ltr;text-align:left;"><?= money($l['to']) ?></td>
<td><?= e(rtrim(rtrim((string) $l['rate'], '0'), '.')) ?>%</td>
<td style="direction:ltr;text-align:left;"><?= money($l['slice']) ?></td>
<td style="direction:ltr;text-align:left;font-weight:700;"><?= money($l['tax']) ?></td>
</tr>
<?php endforeach; ?>
<tr style="background:#F9FAFB;font-weight:700;">
<td colspan="4">الإعفاء الشخصي المطبّق: <?= money($simulation['exemption']) ?></td>
<td style="direction:ltr;text-align:left;"><?= money($simulation['total']) ?> سنويًا</td>
</tr>
<tr>
<td colspan="4">الاستقطاع الشهري التقريبي</td>
<td style="direction:ltr;text-align:left;font-weight:700;color:#0D7377;"><?= money($simulation['monthly'] ?? '0.00') ?></td>
</tr>
</tbody>
</table>
</div>
<?php else: ?>
<p style="color:#DC2626;margin:0;">لا توجد مجموعة شرائح سارية — الضريبة مش هتتحسب. أنشئ مجموعة وفعّلها.</p>
<?php endif; ?>
</div>
</div>
<!-- المجموعات -->
<?php if (!empty($sets)): ?>
<?php foreach ($sets as $set): ?>
<?php $isActive = (int) $set['is_active'] === 1; ?>
<div class="card" style="margin-bottom:14px;border-right:3px solid <?= $isActive ? '#059669' : '#D1D5DB' ?>;">
<div style="padding:14px 18px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;">
<div>
<strong style="font-size:15px;">سريان من <?= e($set['effective_date']) ?></strong>
<span style="color:#6B7280;font-size:12px;margin-inline-start:8px;">
<?= (int) $set['bracket_count'] ?> شرائح — إعفاء شخصي <?= money($set['annual_exemption'] ?? 0) ?>
</span>
<?php if ($isActive): ?>
<span style="background:#ECFDF5;color:#059669;font-size:12px;font-weight:700;padding:2px 10px;border-radius:10px;margin-inline-start:8px;">سارية الآن</span>
<?php else: ?>
<span style="background:#F3F4F6;color:#6B7280;font-size:12px;padding:2px 10px;border-radius:10px;margin-inline-start:8px;">متوقفة</span>
<?php endif; ?>
</div>
<div style="display:flex;gap:8px;">
<a href="/hr/tax/brackets/create?copy_from=<?= e($set['effective_date']) ?>" class="btn btn-sm btn-outline">تعديل / نسخة منها</a>
<?php if (!$isActive): ?>
<form method="POST" action="/hr/tax/brackets/activate" style="display:inline;">
<?= csrf_field() ?>
<input type="hidden" name="effective_date" value="<?= e($set['effective_date']) ?>">
<button type="submit" class="btn btn-sm" style="background:#059669;color:#fff;border:none;"
onclick="return confirm('تفعيل المجموعة دي وإيقاف الباقي؟');">تفعيل</button>
</form>
<?php endif; ?>
</div>
</div>
<div class="table-responsive">
<table class="data-table">
<thead><tr><th>#</th><th>من</th><th>إلى</th><th>النسبة</th></tr></thead>
<tbody>
<?php foreach ($set['brackets'] as $b): ?>
<tr>
<td><?= (int) $b['bracket_order'] ?></td>
<td style="direction:ltr;text-align:left;"><?= money($b['from_amount']) ?></td>
<td style="direction:ltr;text-align:left;">
<?= bccomp((string) $b['to_amount'], '99999999', 2) >= 0 ? 'فأكثر' : money($b['to_amount']) ?>
</td>
<td><?= e(rtrim(rtrim(number_format((float) $b['rate'] * 100, 2, '.', ''), '0'), '.')) ?>%</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endforeach; ?>
<?php else: ?>
<div class="card" style="padding:40px;text-align:center;color:#6B7280;">
لا توجد شرائح مسجلة. اضغط «مجموعة شرائح جديدة» لإدخال شرائح القانون.
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function () { if (typeof lucide !== 'undefined') lucide.createIcons(); });
</script>
<?php $__template->endSection(); ?>
......@@ -52,6 +52,7 @@ PermissionRegistry::register('hr', [
// Tax
'hr.tax.view' => ['ar' => 'عرض سجلات الضرائب', 'en' => 'View Tax Records'],
'hr.tax.brackets.manage' => ['ar' => 'إدارة شرائح ضريبة كسب العمل', 'en' => 'Manage Income Tax Brackets'],
// Disciplinary
'hr.disciplinary.view' => ['ar' => 'عرض الإجراءات التأديبية', 'en' => 'View Disciplinary Actions'],
......@@ -127,6 +128,8 @@ MenuRegistry::register('hr', [
['label_ar' => 'أنواع الإجازات', 'label_en' => 'Leave Types', 'route' => '/hr/leave-types', 'permission' => 'hr.leave.manage', 'order' => 8],
['label_ar' => 'كشوف الرواتب', 'label_en' => 'Payroll', 'route' => '/hr/payroll', 'permission' => 'hr.payroll.view', 'order' => 8],
['label_ar' => 'التأمينات الاجتماعية', 'label_en' => 'Social Insurance', 'route' => '/hr/insurance', 'permission' => 'hr.insurance.view', 'order' => 9],
['label_ar' => 'ضريبة كسب العمل', 'label_en' => 'Income Tax', 'route' => '/hr/tax', 'permission' => 'hr.tax.view', 'order' => 9],
['label_ar' => 'شرائح ضريبة كسب العمل','label_en' => 'Income Tax Brackets','route' => '/hr/tax/brackets', 'permission' => 'hr.tax.brackets.manage', 'order' => 9],
['label_ar' => 'السلف والقروض', 'label_en' => 'Loans', 'route' => '/hr/loans', 'permission' => 'hr.loan.view', 'order' => 10],
['label_ar' => 'الإجراءات التأديبية', 'label_en' => 'Disciplinary', 'route' => '/hr/disciplinary', 'permission' => 'hr.disciplinary.view', 'order' => 11],
['label_ar' => 'نهاية الخدمة', 'label_en' => 'End of Service', 'route' => '/hr/end-of-service', 'permission' => 'hr.eos.view', 'order' => 12],
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment