Commit 60962799 authored by DevPilot's avatar DevPilot

feat(accounting): المحاسبة المالية — قائمة بثلاث مستويات وكشف حساب موحّد

القائمة:
- «المحاسبة والدفتر العام» بقت «المحاسبة المالية»، وتحتها مجموعات:
  المؤشرات المالية، حسابات البنوك، المراجعة الداخلية والإقفال،
  القوائم المالية، والإعدادات المالية.
- مراكز التكلفة والموازنات خرجوا لقائمة «المحاسبة الإدارية» — دول أدوات
  قرار مش جزء من دورة القيد والترحيل.
- الشريط الجانبي كان بيدعم مستويين بس، فاتعمل يدعم أي عمق: الترشيح
  بالصلاحيات، وحالة «مفتوح»، والبحث كلهم بيمشوا على الشجرة كلها.

كشوف الحسابات:
- «كشف حساب عضو» و«كشف حساب عميل» كانوا شاشتين على نفس العضو. اتوحّدوا
  مع كشف المورد في شاشة واحدة بتختار فيها نوع الطرف، وبتعرض دفتر الأستاذ
  والحساب المساعد جنب بعض وتقارن بينهم — لأن اختلافهم هو بالظبط اللي
  المحاسب محتاج يشوفه. الروابط القديمة بتحوّل عليها بنفس الطرف والفترة.

البحث:
- نواة واحدة (arabic-search.js) بتطبّع الهمزات والتشكيل والأرقام العربية
  وبتتحمّل الأخطاء المطبعية وبترتّب بالأقرب. كان في تلات تطبيقات مختلفة،
  واحدة منهم بتعمل indexOf عادي فـ«احمد» ما كانتش بتلاقي «أحمد».
- أي قايمة فيها ١٢ خيار أو أكتر بتتحوّل لقايمة بحث تلقائيًا.

tools/menu_link_check.py بيتأكد إن كل بند قائمة وكل لينك في الشاشات له
route فعلي — ٢٨١ بند و٠ مكسور.
parent 2b4a3fba
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\Accounting\Services\PartyStatementService;
/**
* كشوف الحسابات — شاشة واحدة لأي طرف (عضو أو مورد).
*
* بتجمع اللي كان متفرّق على تلات شاشات (كشف حساب عضو / عميل / مورد)
* وبتزوّد عليهم المطابقة بين دفتر الأستاذ والحساب المساعد.
*/
class PartyStatementController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('accounting.statements.view');
$type = (string) $request->get('type', 'member');
if (!isset(PartyStatementService::TYPES[$type])) {
$type = 'member';
}
$partyId = (int) $request->get('party_id', 0);
$from = (string) $request->get('date_from', date('Y-01-01'));
$to = (string) $request->get('date_to', date('Y-m-d'));
$search = trim((string) $request->get('q', ''));
$party = $partyId > 0 ? PartyStatementService::party($type, $partyId) : null;
if (!$party) {
$partyId = 0;
}
$ledger = $subledger = $recon = null;
if ($partyId > 0) {
$ledger = PartyStatementService::ledger($type, $partyId, $from, $to);
$subledger = PartyStatementService::subledger($type, $partyId, $from, $to);
$recon = PartyStatementService::reconcile($ledger, $subledger);
}
return $this->view('Accounting.Views.statements.party', [
'type' => $type,
'types' => PartyStatementService::TYPES,
'parties' => PartyStatementService::parties($type, $search, 300),
'party' => $party,
'partyId' => $partyId,
'search' => $search,
'dateFrom' => $from,
'dateTo' => $to,
'ledger' => $ledger,
'subledger' => $subledger,
'recon' => $recon,
]);
}
/**
* الروابط القديمة بتوصّل للشاشة الموحّدة بنفس الطرف والفترة،
* عشان أي bookmark أو لينك قديم يفضل شغّال.
*/
public function legacyCustomer(Request $request): Response
{
return $this->redirect('/accounting/statements?' . http_build_query([
'type' => 'member',
'party_id' => (int) $request->get('member_id', 0),
'date_from' => $request->get('date_from', date('Y-01-01')),
'date_to' => $request->get('date_to', date('Y-m-d')),
]));
}
public function legacySupplier(Request $request): Response
{
return $this->redirect('/accounting/statements?' . http_build_query([
'type' => 'supplier',
'party_id' => (int) $request->get('supplier_id', 0),
'date_from' => $request->get('date_from', date('Y-01-01')),
'date_to' => $request->get('date_to', date('Y-m-d')),
]));
}
public function legacyMember(Request $request): Response
{
return $this->redirect('/accounting/statements?' . http_build_query([
'type' => 'member',
'party_id' => (int) $request->get('member_id', 0),
'date_from' => $request->get('date_from', date('Y-01-01')),
'date_to' => $request->get('date_to', date('Y-m-d')),
]));
}
}
...@@ -140,13 +140,15 @@ return [ ...@@ -140,13 +140,15 @@ return [
['GET', '/accounting/reports/equity-statement', 'Accounting\Controllers\ReportController@equityStatement', ['auth'], 'accounting.reports.equity_statement'], ['GET', '/accounting/reports/equity-statement', 'Accounting\Controllers\ReportController@equityStatement', ['auth'], 'accounting.reports.equity_statement'],
['GET', '/accounting/reports/accounts-receivable', 'Accounting\Controllers\ReportController@accountsReceivable', ['auth'], 'accounting.reports.ar'], ['GET', '/accounting/reports/accounts-receivable', 'Accounting\Controllers\ReportController@accountsReceivable', ['auth'], 'accounting.reports.ar'],
['GET', '/accounting/reports/accounts-payable', 'Accounting\Controllers\ReportController@accountsPayable', ['auth'], 'accounting.reports.ap'], ['GET', '/accounting/reports/accounts-payable', 'Accounting\Controllers\ReportController@accountsPayable', ['auth'], 'accounting.reports.ap'],
['GET', '/accounting/reports/member-statement', 'Accounting\Controllers\ReportController@memberStatement', ['auth'], 'accounting.reports.member_statement'], ['GET', '/accounting/reports/member-statement', 'Accounting\Controllers\PartyStatementController@legacyMember', ['auth'], 'accounting.reports.member_statement'],
['GET', '/accounting/reports/treasury', 'Accounting\Controllers\ReportController@treasury', ['auth'], 'accounting.reports.treasury'], ['GET', '/accounting/reports/treasury', 'Accounting\Controllers\ReportController@treasury', ['auth'], 'accounting.reports.treasury'],
['GET', '/accounting/reports/revenue-analysis', 'Accounting\Controllers\ReportController@revenueAnalysis', ['auth'], 'accounting.reports.revenue_analysis'], ['GET', '/accounting/reports/revenue-analysis', 'Accounting\Controllers\ReportController@revenueAnalysis', ['auth'], 'accounting.reports.revenue_analysis'],
// ── Account Statements ────────────────────────────────── // ── Account Statements ──────────────────────────────────
['GET', '/accounting/statements/customer', 'Accounting\Controllers\AccountStatementController@customerStatement', ['auth'], 'accounting.statements.view'], // كشف الحساب الموحّد — الروابط القديمة بتحوّل عليه بنفس الطرف والفترة
['GET', '/accounting/statements/supplier', 'Accounting\Controllers\AccountStatementController@supplierStatement', ['auth'], 'accounting.statements.view'], ['GET', '/accounting/statements', 'Accounting\Controllers\PartyStatementController@index', ['auth'], 'accounting.statements.view'],
['GET', '/accounting/statements/customer', 'Accounting\Controllers\PartyStatementController@legacyCustomer', ['auth'], 'accounting.statements.view'],
['GET', '/accounting/statements/supplier', 'Accounting\Controllers\PartyStatementController@legacySupplier', ['auth'], 'accounting.statements.view'],
['GET', '/accounting/statements/daily-cash', 'Accounting\Controllers\AccountStatementController@dailyCash', ['auth'], 'accounting.cash.view'], ['GET', '/accounting/statements/daily-cash', 'Accounting\Controllers\AccountStatementController@dailyCash', ['auth'], 'accounting.cash.view'],
// ── Bank Loans ────────────────────────────────────────── // ── Bank Loans ──────────────────────────────────────────
......
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
/**
* كشف حساب موحّد لأي طرف — عضو أو مورد.
*
* ليه اتوحّدوا: كان في «كشف حساب عضو» و«كشف حساب عميل» وكل واحد شاشة
* لوحده، والاتنين على نفس العضو. الفرق الحقيقي مش في الطرف، الفرق في
* المصدر:
*
* • دفتر الأستاذ (journal_entry_lines) — الصورة المحاسبية المرحّلة.
* • الحساب المساعد (customer_transactions / supplier_transactions) —
* الحركة التشغيلية للمدينين والدائنين.
*
* المفروض الاتنين يطلّعوا نفس الرصيد. لو اختلفوا يبقى في حركة اتسجّلت في
* ناحية وما اتقيّدتش في التانية، وده بالظبط اللي المحاسب محتاج يشوفه.
* فبدل شاشتين بيقولوا نص الحكاية، شاشة واحدة بتقارن الاتنين.
*/
final class PartyStatementService
{
public const TYPES = [
'member' => 'عضو',
'supplier' => 'مورد',
];
/** بحث عن الطرف بالاسم أو الرقم — بيغذّي القايمة المنسدلة. */
public static function parties(string $type, string $q = '', int $limit = 50): array
{
$db = App::getInstance()->db();
$like = '%' . $q . '%';
if ($type === 'supplier') {
$sql = "SELECT id, name_ar AS name, code AS ref
FROM suppliers
WHERE is_archived = 0";
$params = [];
if ($q !== '') {
$sql .= " AND (name_ar LIKE ? OR code LIKE ?)";
$params = [$like, $like];
}
$sql .= " ORDER BY name_ar LIMIT {$limit}";
return $db->select($sql, $params);
}
$sql = "SELECT id, full_name_ar AS name, membership_number AS ref
FROM members
WHERE is_archived = 0";
$params = [];
if ($q !== '') {
$sql .= " AND (full_name_ar LIKE ? OR membership_number LIKE ? OR phone_mobile LIKE ?)";
$params = [$like, $like, $like];
}
$sql .= " ORDER BY full_name_ar LIMIT {$limit}";
return $db->select($sql, $params);
}
public static function party(string $type, int $id): ?array
{
$db = App::getInstance()->db();
if ($type === 'supplier') {
$row = $db->selectOne(
"SELECT id, name_ar AS name, code AS ref, credit_limit, credit_balance
FROM suppliers WHERE id = ?",
[$id]
);
} else {
$row = $db->selectOne(
"SELECT id, full_name_ar AS name, membership_number AS ref, credit_limit, credit_balance
FROM members WHERE id = ?",
[$id]
);
}
return $row ?: null;
}
/**
* الحساب المساعد — المدينون أو الدائنون.
*
* @return array{rows:array, opening:string, debit:string, credit:string, closing:string}
*/
public static function subledger(string $type, int $id, string $from, string $to): array
{
$db = App::getInstance()->db();
$table = $type === 'supplier' ? 'supplier_transactions' : 'customer_transactions';
$key = $type === 'supplier' ? 'supplier_id' : 'member_id';
$prior = $db->selectOne(
"SELECT COALESCE(SUM(debit) - SUM(credit), 0) AS balance
FROM {$table} WHERE {$key} = ? AND transaction_date < ?",
[$id, $from]
);
$opening = (string) ($prior['balance'] ?? '0.00');
$rows = $db->select(
"SELECT transaction_date, document_type, document_number, description, debit, credit
FROM {$table}
WHERE {$key} = ? AND transaction_date BETWEEN ? AND ?
ORDER BY transaction_date ASC, id ASC",
[$id, $from, $to]
);
return self::withRunningBalance($rows, $opening);
}
/**
* دفتر الأستاذ — سطور القيود المرحّلة المربوطة بالطرف ده.
*
* @return array{rows:array, opening:string, debit:string, credit:string, closing:string}
*/
public static function ledger(string $type, int $id, string $from, string $to): array
{
$db = App::getInstance()->db();
$key = $type === 'supplier' ? 'jel.supplier_id' : 'jel.member_id';
$prior = $db->selectOne(
"SELECT COALESCE(SUM(jel.debit) - SUM(jel.credit), 0) AS balance
FROM journal_entry_lines jel
JOIN journal_entries je ON je.id = jel.journal_entry_id
WHERE {$key} = ? AND je.status = 'posted'
AND je.is_archived = 0 AND je.entry_date < ?",
[$id, $from]
);
$opening = (string) ($prior['balance'] ?? '0.00');
$rows = $db->select(
"SELECT je.entry_date AS transaction_date,
je.entry_number AS document_number,
je.reference_type AS document_type,
COALESCE(NULLIF(jel.description_ar, ''), je.description_ar) AS description,
coa.account_code, coa.name_ar AS account_name,
jel.debit, jel.credit,
je.id AS journal_entry_id
FROM journal_entry_lines jel
JOIN journal_entries je ON je.id = jel.journal_entry_id
JOIN chart_of_accounts coa ON coa.id = jel.account_id
WHERE {$key} = ? AND je.status = 'posted'
AND je.is_archived = 0
AND je.entry_date BETWEEN ? AND ?
ORDER BY je.entry_date ASC, je.id ASC, jel.id ASC",
[$id, $from, $to]
);
return self::withRunningBalance($rows, $opening);
}
/**
* المطابقة بين الدفترين.
*
* @return array{matches:bool, difference:string, ledger_closing:string, subledger_closing:string}
*/
public static function reconcile(array $ledger, array $subledger): array
{
$diff = bcsub($ledger['closing'], $subledger['closing'], 2);
return [
'matches' => bccomp($diff, '0.00', 2) === 0,
'difference' => $diff,
'ledger_closing' => $ledger['closing'],
'subledger_closing' => $subledger['closing'],
];
}
/** @return array{rows:array, opening:string, debit:string, credit:string, closing:string} */
private static function withRunningBalance(array $rows, string $opening): array
{
$balance = $opening;
$debit = '0.00';
$credit = '0.00';
foreach ($rows as &$r) {
$d = (string) ($r['debit'] ?? '0.00');
$c = (string) ($r['credit'] ?? '0.00');
$debit = bcadd($debit, $d, 2);
$credit = bcadd($credit, $c, 2);
$balance = bcsub(bcadd($balance, $d, 2), $c, 2);
$r['running_balance'] = $balance;
}
unset($r);
return [
'rows' => $rows,
'opening' => $opening,
'debit' => $debit,
'credit' => $credit,
'closing' => $balance,
];
}
}
...@@ -132,15 +132,15 @@ function addLine() { ...@@ -132,15 +132,15 @@ function addLine() {
const firstRow = tbody.querySelector('tr'); const firstRow = tbody.querySelector('tr');
const newRow = firstRow.cloneNode(true); const newRow = firstRow.cloneNode(true);
// الصف المنسوخ بيجي شايل خانة البحث بتاعة الحساب، فبنرجّعه select عادي // الصف المنسوخ بيجي شايل واجهة البحث بتاعة الحساب، فبنرجّعه select عادي
// الأول وبعدين نفعّل البحث من جديد على النسخة. // الأول وبعدين نفعّل البحث من جديد على النسخة.
if (window.resetSearchableSelects) window.resetSearchableSelects(newRow); if (window.SearchableSelect) SearchableSelect.reset(newRow);
newRow.querySelectorAll('input').forEach(i => { if(i.type === 'number') i.value = '0.00'; else i.value = ''; }); newRow.querySelectorAll('input').forEach(i => { if(i.type === 'number') i.value = '0.00'; else i.value = ''; });
newRow.querySelectorAll('select').forEach(s => s.selectedIndex = 0); newRow.querySelectorAll('select').forEach(s => s.selectedIndex = 0);
tbody.appendChild(newRow); tbody.appendChild(newRow);
if (window.enhanceSearchableSelects) window.enhanceSearchableSelects(newRow); if (window.SearchableSelect) SearchableSelect.initNew(newRow);
} }
function removeLine(btn) { function removeLine(btn) {
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>كشوف الحسابات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- اختيار الطرف والفترة -->
<div class="card" style="margin-bottom:18px;">
<div style="padding:12px 16px;border-bottom:1px solid #E5E7EB;">
<strong style="font-size:14px;">كشف حساب</strong>
<span style="font-size:12px;color:#6B7280;margin-inline-start:8px;">
اختار الطرف والفترة — الشاشة بتعرض دفتر الأستاذ والحساب المساعد وتقارن بينهم
</span>
</div>
<form method="GET" action="/accounting/statements">
<div style="padding:14px 16px;display:grid;grid-template-columns:150px 1fr 180px 180px auto;gap:12px;align-items:end;">
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">نوع الطرف</label>
<select name="type" class="form-select" data-no-search onchange="this.form.submit()">
<?php foreach ($types as $k => $label): ?>
<option value="<?= e($k) ?>" <?= $type === $k ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">
<?= $type === 'supplier' ? 'المورد' : 'العضو' ?>
</label>
<select name="party_id" class="form-select" data-searchable
data-placeholder="اكتب الاسم أو الرقم للبحث…">
<option value="">— اختار <?= e($types[$type]) ?></option>
<?php foreach ($parties as $p): ?>
<option value="<?= (int) $p['id'] ?>" <?= $partyId === (int) $p['id'] ? 'selected' : '' ?>>
<?= e($p['name']) ?><?= !empty($p['ref']) ? ' — ' . e($p['ref']) : '' ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">من تاريخ</label>
<input type="date" name="date_from" class="form-input" value="<?= e($dateFrom) ?>">
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">إلى تاريخ</label>
<input type="date" name="date_to" class="form-input" value="<?= e($dateTo) ?>">
</div>
<button type="submit" class="btn btn-primary">عرض الكشف</button>
</div>
</form>
</div>
<?php if (!$party): ?>
<div class="card">
<div style="padding:40px 20px;text-align:center;color:#6B7280;">
اختار <?= e($types[$type]) ?> من فوق عشان يظهر كشف حسابه.
</div>
</div>
<?php else: ?>
<!-- بيانات الطرف والمطابقة -->
<div class="card" style="margin-bottom:18px;">
<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>
<h3 style="margin:0;font-size:16px;"><?= e($party['name']) ?></h3>
<p style="margin:4px 0 0;color:#6B7280;font-size:13px;">
<?= e($types[$type]) ?><?= !empty($party['ref']) ? ' — ' . e($party['ref']) : '' ?>
· الفترة من <?= e($dateFrom) ?> إلى <?= e($dateTo) ?>
</p>
</div>
<div style="text-align:left;">
<div style="color:#6B7280;font-size:11px;">رصيد آخر المدة (دفتر الأستاذ)</div>
<div style="font-size:22px;font-weight:800;direction:ltr;"><?= money($ledger['closing']) ?></div>
</div>
</div>
<?php
$ok = $recon['matches'];
$bg = $ok ? '#ECFDF5' : '#FEF2F2';
$bd = $ok ? '#A7F3D0' : '#FECACA';
$fg = $ok ? '#047857' : '#B91C1C';
?>
<div style="padding:12px 18px;background:<?= $bg ?>;border-top:1px solid <?= $bd ?>;font-size:13px;color:<?= $fg ?>;">
<?php if ($ok): ?>
<strong>الدفتران متطابقان.</strong>
رصيد دفتر الأستاذ يساوي رصيد الحساب المساعد (<?= money($ledger['closing']) ?>).
<?php else: ?>
<strong>في فرق بين الدفترين قدره <?= money(abs((float) $recon['difference'])) ?>.</strong>
دفتر الأستاذ <?= money($recon['ledger_closing']) ?> والحساب المساعد <?= money($recon['subledger_closing']) ?>.
معنى كده إن في حركة اتسجّلت في ناحية وما اتقيّدتش في التانية — راجع «سد الفجوات».
<?php endif; ?>
</div>
</div>
<!-- الدفترين جنب بعض -->
<div style="display:grid;grid-template-columns:1fr;gap:18px;">
<?php
$blocks = [
['دفتر الأستاذ — القيود المرحّلة', $ledger, true],
[$type === 'supplier' ? 'الحساب المساعد — الدائنون' : 'الحساب المساعد — المدينون', $subledger, false],
];
foreach ($blocks as [$title, $data, $isLedger]):
?>
<div class="card">
<div style="padding:12px 16px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<strong style="font-size:14px;"><?= e($title) ?> (<?= count($data['rows']) ?>)</strong>
<span style="font-size:12.5px;color:#6B7280;">
رصيد أول المدة <strong style="direction:ltr;display:inline-block;"><?= money($data['opening']) ?></strong>
· مدين <strong style="direction:ltr;display:inline-block;"><?= money($data['debit']) ?></strong>
· دائن <strong style="direction:ltr;display:inline-block;"><?= money($data['credit']) ?></strong>
· رصيد آخر المدة <strong style="direction:ltr;display:inline-block;"><?= money($data['closing']) ?></strong>
</span>
</div>
<?php if (!empty($data['rows'])): ?>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>التاريخ</th>
<th>المستند</th>
<?php if ($isLedger): ?><th>الحساب</th><?php endif; ?>
<th>البيان</th>
<th>مدين</th>
<th>دائن</th>
<th>الرصيد</th>
</tr>
</thead>
<tbody>
<tr style="background:#F9FAFB;">
<td colspan="<?= $isLedger ? 5 : 4 ?>" style="font-weight:600;">رصيد أول المدة</td>
<td></td>
<td style="direction:ltr;text-align:left;font-weight:700;"><?= money($data['opening']) ?></td>
</tr>
<?php foreach ($data['rows'] as $r): ?>
<tr>
<td style="white-space:nowrap;"><?= e($r['transaction_date']) ?></td>
<td style="font-size:12.5px;">
<?php if ($isLedger && !empty($r['journal_entry_id'])): ?>
<a href="/accounting/journal-entries/<?= (int) $r['journal_entry_id'] ?>">
<?= e($r['document_number'] ?: '—') ?>
</a>
<?php else: ?>
<?= e($r['document_number'] ?: '—') ?>
<?php endif; ?>
</td>
<?php if ($isLedger): ?>
<td style="font-size:12px;color:#6B7280;white-space:nowrap;">
<?= e($r['account_code'] ?? '') ?> <?= e($r['account_name'] ?? '') ?>
</td>
<?php endif; ?>
<td style="font-size:12.5px;"><?= e($r['description'] ?: '—') ?></td>
<td style="direction:ltr;text-align:left;"><?= bccomp((string) $r['debit'], '0.00', 2) > 0 ? money($r['debit']) : '—' ?></td>
<td style="direction:ltr;text-align:left;"><?= bccomp((string) $r['credit'], '0.00', 2) > 0 ? money($r['credit']) : '—' ?></td>
<td style="direction:ltr;text-align:left;font-weight:600;"><?= money($r['running_balance']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr style="background:#F0FDF4;font-weight:700;">
<td colspan="<?= $isLedger ? 4 : 3 ?>">الإجمالي</td>
<td style="direction:ltr;text-align:left;"><?= money($data['debit']) ?></td>
<td style="direction:ltr;text-align:left;"><?= money($data['credit']) ?></td>
<td style="direction:ltr;text-align:left;"><?= money($data['closing']) ?></td>
</tr>
</tfoot>
</table>
</div>
<?php else: ?>
<div style="padding:26px 20px;text-align:center;color:#6B7280;">
مفيش حركة في الفترة دي.
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
This diff is collapsed.
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
<div style="display:flex;gap:8px;"> <div style="display:flex;gap:8px;">
<a href="/sa/mirror" class="btn btn-primary" style="font-size:12px;"><i data-lucide="monitor" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i>المرآة</a> <a href="/sa/mirror" class="btn btn-primary" style="font-size:12px;"><i data-lucide="monitor" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i>المرآة</a>
<a href="/sa/bookings/wizard" class="btn btn-outline" style="font-size:12px;"><i data-lucide="plus-circle" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i>حجز جديد</a> <a href="/sa/bookings/wizard" class="btn btn-outline" style="font-size:12px;"><i data-lucide="plus-circle" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i>حجز جديد</a>
<a href="/sa/registrations/wizard" class="btn btn-outline" style="font-size:12px;"><i data-lucide="user-plus" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i>تسجيل جديد</a> <a href="/sa/registration" class="btn btn-outline" style="font-size:12px;"><i data-lucide="user-plus" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i>تسجيل جديد</a>
</div> </div>
</div> </div>
...@@ -320,7 +320,7 @@ ...@@ -320,7 +320,7 @@
<div class="card" style="margin-bottom:20px;"> <div class="card" style="margin-bottom:20px;">
<div style="padding:12px 15px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;"> <div style="padding:12px 15px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<span style="font-weight:700;font-size:14px;">تسجيلات آخر 7 أيام</span> <span style="font-weight:700;font-size:14px;">تسجيلات آخر 7 أيام</span>
<a href="/sa/registrations" style="font-size:12px;color:#3B82F6;text-decoration:none;">عرض الكل →</a> <a href="/sa/registration" style="font-size:12px;color:#3B82F6;text-decoration:none;">عرض الكل →</a>
</div> </div>
<table style="width:100%;border-collapse:collapse;font-size:12px;"> <table style="width:100%;border-collapse:collapse;font-size:12px;">
<thead> <thead>
......
<?php
/**
* بحث جوه أي قائمة منسدلة طويلة.
*
* المشكلة: قوايم زي دليل الحسابات فيها مئات الخيارات، والمستخدم مضطر يدوّر
* بعينه. المكوّن ده بيحوّل أي <select> طويل لقائمة بيتكتب فيها فيتفلتر
* المحتوى بالاسم أو بالرقم.
*
* بيشتغل لوحده على أي select فيه خيارات أكتر من الحد، من غير ما الشاشة
* تعمل أي حاجة. ولو عايز تفعّله على قائمة قصيرة حط data-searchable عليها،
* ولو عايز تمنعه حط data-no-search.
*/
?>
<style>
.ss-wrap { position: relative; }
.ss-wrap .ss-native { position: absolute; opacity: 0; pointer-events: none; height: 0; width: 0; }
.ss-input { width: 100%; cursor: text; }
.ss-input.ss-empty { color: #9CA3AF; }
.ss-menu {
position: absolute; z-index: 1200; inset-inline-start: 0; inset-inline-end: 0; top: 100%;
background: #fff; border: 1px solid #D1D5DB; border-radius: 6px; margin-top: 2px;
max-height: 260px; overflow-y: auto; box-shadow: 0 8px 20px rgba(0,0,0,.12); display: none;
}
.ss-menu.open { display: block; }
.ss-opt { padding: 7px 11px; font-size: 13px; cursor: pointer; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.ss-opt:hover, .ss-opt.ss-active { background: #F0FDFA; }
.ss-opt.ss-chosen { font-weight: 700; }
.ss-empty-msg { padding: 12px; text-align: center; color: #9CA3AF; font-size: 12.5px; }
</style>
<script>
(function () {
'use strict';
var MIN_OPTIONS = 12; // أقل من كده القائمة قصيرة وما تحتاجش بحث
// بنشيل التشكيل والهمزات عشان البحث بالعربي ما يتعلقش على شكل الحرف
function norm(s) {
return (s || '').toString().toLowerCase()
.replace(/[ً-ْـ]/g, '')
.replace(/[أإآ]/g, 'ا')
.replace(/ى/g, 'ي')
.replace(/ة/g, 'ه')
.trim();
}
function labelOf(select) {
var o = select.options[select.selectedIndex];
return o && o.value !== '' ? o.textContent.trim() : '';
}
function enhance(select) {
if (select.dataset.ssDone === '1') return;
if (select.multiple || select.disabled) return;
if (select.hasAttribute('data-no-search')) return;
if (select.options.length < MIN_OPTIONS && !select.hasAttribute('data-searchable')) return;
select.dataset.ssDone = '1';
var wrap = document.createElement('div');
wrap.className = 'ss-wrap';
select.parentNode.insertBefore(wrap, select);
wrap.appendChild(select);
select.classList.add('ss-native');
var input = document.createElement('input');
input.type = 'text';
input.className = 'form-input ss-input';
input.autocomplete = 'off';
input.placeholder = select.dataset.searchPlaceholder || 'اكتب للبحث…';
// الـ required بتفضل على الـ select الأصلي عشان التحقق يشتغل زي ما هو
wrap.appendChild(input);
var menu = document.createElement('div');
menu.className = 'ss-menu';
wrap.appendChild(menu);
var items = [];
for (var i = 0; i < select.options.length; i++) {
var o = select.options[i];
items.push({ value: o.value, text: o.textContent.trim(), key: norm(o.textContent) });
}
var active = -1;
function syncInput() {
var l = labelOf(select);
input.value = l;
input.classList.toggle('ss-empty', l === '');
}
function render(filter) {
var q = norm(filter);
menu.innerHTML = '';
active = -1;
var shown = 0;
items.forEach(function (it) {
if (q && it.key.indexOf(q) === -1) return;
var d = document.createElement('div');
d.className = 'ss-opt' + (it.value === select.value && it.value !== '' ? ' ss-chosen' : '');
d.textContent = it.text;
d.dataset.value = it.value;
d.addEventListener('mousedown', function (e) {
e.preventDefault(); // قبل الـ blur عشان الاختيار ما يضيعش
pick(it.value);
});
menu.appendChild(d);
shown++;
});
if (!shown) {
var m = document.createElement('div');
m.className = 'ss-empty-msg';
m.textContent = 'مفيش نتيجة مطابقة';
menu.appendChild(m);
}
}
function pick(value) {
select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true }));
syncInput();
close();
}
function open() {
render('');
menu.classList.add('open');
}
function close() {
menu.classList.remove('open');
}
function move(step) {
var opts = menu.querySelectorAll('.ss-opt');
if (!opts.length) return;
if (active >= 0) opts[active].classList.remove('ss-active');
active = (active + step + opts.length) % opts.length;
opts[active].classList.add('ss-active');
opts[active].scrollIntoView({ block: 'nearest' });
}
input.addEventListener('focus', function () { input.select(); open(); });
input.addEventListener('input', function () { render(input.value); menu.classList.add('open'); });
input.addEventListener('blur', function () { setTimeout(function () { close(); syncInput(); }, 120); });
input.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { e.preventDefault(); if (!menu.classList.contains('open')) open(); move(1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
else if (e.key === 'Enter') {
var opts = menu.querySelectorAll('.ss-opt');
if (menu.classList.contains('open') && active >= 0 && opts[active]) {
e.preventDefault();
pick(opts[active].dataset.value);
}
} else if (e.key === 'Escape') { close(); syncInput(); }
});
// لو حد غيّر الـ select من كود تاني، الخانة تتحدّث معاه
select.addEventListener('change', syncInput);
syncInput();
}
/** بيفعّل البحث على أي select جديد جوه العنصر ده (أو الصفحة كلها). */
function enhanceAll(root) {
(root || document).querySelectorAll('select').forEach(enhance);
}
/**
* بعد نسخ صف فيه select متفعّل عليه البحث، لازم ننضّف النسخة الأول
* وإلا هتفضل شايلة خانة البحث القديمة بقيمتها.
*/
function resetSearchable(root) {
(root || document).querySelectorAll('.ss-wrap').forEach(function (wrap) {
var select = wrap.querySelector('select');
if (!select) { wrap.remove(); return; }
select.classList.remove('ss-native');
delete select.dataset.ssDone;
wrap.parentNode.insertBefore(select, wrap);
wrap.remove();
});
}
window.enhanceSearchableSelects = enhanceAll;
window.resetSearchableSelects = resetSearchable;
document.addEventListener('DOMContentLoaded', function () { enhanceAll(document); });
})();
</script>
...@@ -139,6 +139,73 @@ $searchText = function(array $entry): string { ...@@ -139,6 +139,73 @@ $searchText = function(array $entry): string {
return trim(implode(' ', array_filter($parts, fn($p) => $p !== ''))); return trim(implode(' ', array_filter($parts, fn($p) => $p !== '')));
}; };
/**
* يرشّح شجرة البنود حسب الصلاحيات ويرتّبها.
*
* القائمة بتدعم أكتر من مستوى (مثلًا: المحاسبة المالية » حسابات البنوك »
* المطابقة البنكية)، فالترشيح لازم يمشي على الشجرة كلها مش على مستوى واحد.
* والمجموعة اللي كل اللي جواها متخفي بتختفي هي كمان بدل ما تفضل فاضية.
*/
$visibleTree = function (array $nodes) use (&$visibleTree, $hasPerm): array {
$out = [];
foreach ($nodes as $node) {
$kids = !empty($node['children']) ? $visibleTree($node['children']) : [];
$isGroup = !empty($node['children']);
if ($isGroup && empty($kids)) {
continue; // مجموعة فاضية بعد الترشيح
}
if (!empty($node['permission']) && !$hasPerm($node['permission']) && empty($kids)) {
continue;
}
$node['children'] = $kids;
$out[] = $node;
}
usort($out, fn($a, $b) => ($a['order'] ?? 999) <=> ($b['order'] ?? 999));
return $out;
};
/** هل في أي بند شغّال جوه الفرع ده (على أي عمق)؟ */
$branchActive = function (array $nodes) use (&$branchActive, $isActive): bool {
foreach ($nodes as $node) {
if ($isActive($node['route'] ?? '')) return true;
if (!empty($node['children']) && $branchActive($node['children'])) return true;
}
return false;
};
/** يرسم المستويات الداخلية — مجموعة بتتفتح، أو رابط عادي. */
$renderChildren = function (array $nodes, int $depth) use (&$renderChildren, $isActive, $branchActive, $searchText): void {
foreach ($nodes as $node) {
$route = $node['route'] ?? '#';
$kids = $node['children'] ?? [];
if (!empty($kids)) {
$open = $isActive($route) || $branchActive($kids);
?>
<li class="sidebar-subgroup<?= $open ? ' open' : '' ?>" data-search="<?= e($searchText($node)) ?>">
<a href="javascript:void(0)" class="sidebar-sublink sidebar-subgroup-toggle" onclick="toggleSubmenu(this)">
<span class="sidebar-text"><?= e($node['label_ar']) ?></span>
<span class="sidebar-arrow"><i data-lucide="chevron-down"></i></span>
</a>
<ul class="sidebar-submenu sidebar-submenu-nested"<?= $open ? '' : ' style="display:none;"' ?>>
<?php $renderChildren($kids, $depth + 1); ?>
</ul>
</li>
<?php
} else {
?>
<li data-search="<?= e($searchText($node)) ?>">
<a href="<?= e($route) ?>" class="sidebar-sublink<?= $isActive($route) ? ' active' : '' ?>">
<?= e($node['label_ar']) ?>
</a>
</li>
<?php
}
}
};
// Get menu items from registry // Get menu items from registry
$menuItems = MenuRegistry::getAll(); $menuItems = MenuRegistry::getAll();
usort($menuItems, fn($a, $b) => ($a['order'] ?? 999) <=> ($b['order'] ?? 999)); usort($menuItems, fn($a, $b) => ($a['order'] ?? 999) <=> ($b['order'] ?? 999));
...@@ -189,24 +256,12 @@ if (empty($menuItems)) { ...@@ -189,24 +256,12 @@ if (empty($menuItems)) {
continue; continue;
} }
$children = $item['children'] ?? []; $visibleChildren = $visibleTree($item['children'] ?? []);
$visibleChildren = [];
foreach ($children as $child) {
if (empty($child['permission']) || $hasPerm($child['permission'])) {
$visibleChildren[] = $child;
}
}
$hasChildren = !empty($visibleChildren); $hasChildren = !empty($visibleChildren);
$itemRoute = $item['route'] ?? '#'; $itemRoute = $item['route'] ?? '#';
$itemActive = $isActive($itemRoute); $itemActive = $isActive($itemRoute);
$isOpen = $itemActive || $branchActive($visibleChildren);
$childActive = false;
foreach ($visibleChildren as $child) {
if ($isActive($child['route'] ?? '')) { $childActive = true; break; }
}
$isOpen = $itemActive || $childActive;
$iconName = $getIcon($item['icon'] ?? ''); $iconName = $getIcon($item['icon'] ?? '');
?> ?>
<li class="sidebar-item<?= $isOpen && $hasChildren ? ' open' : '' ?>" data-search="<?= e($searchText($item)) ?>"> <li class="sidebar-item<?= $isOpen && $hasChildren ? ' open' : '' ?>" data-search="<?= e($searchText($item)) ?>">
...@@ -217,14 +272,7 @@ if (empty($menuItems)) { ...@@ -217,14 +272,7 @@ if (empty($menuItems)) {
<span class="sidebar-arrow"><i data-lucide="chevron-down"></i></span> <span class="sidebar-arrow"><i data-lucide="chevron-down"></i></span>
</a> </a>
<ul class="sidebar-submenu"<?= $isOpen ? '' : ' style="display:none;"' ?>> <ul class="sidebar-submenu"<?= $isOpen ? '' : ' style="display:none;"' ?>>
<?php foreach ($visibleChildren as $child): ?> <?php $renderChildren($visibleChildren, 1); ?>
<?php $childRoute = $child['route'] ?? '#'; ?>
<li data-search="<?= e($searchText($child)) ?>">
<a href="<?= e($childRoute) ?>" class="sidebar-sublink<?= $isActive($childRoute) ? ' active' : '' ?>">
<?= e($child['label_ar']) ?>
</a>
</li>
<?php endforeach; ?>
</ul> </ul>
<?php else: ?> <?php else: ?>
<a href="<?= e($itemRoute) ?>" class="sidebar-link<?= $itemActive ? ' active' : '' ?>"> <a href="<?= e($itemRoute) ?>" class="sidebar-link<?= $itemActive ? ' active' : '' ?>">
......
...@@ -103,6 +103,7 @@ $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); ...@@ -103,6 +103,7 @@ $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
</div> </div>
<script src="<?= url('assets/js/app.js') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/js/app.js') ?: time() ?>"></script> <script src="<?= url('assets/js/app.js') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/js/app.js') ?: time() ?>"></script>
<script src="<?= url('assets/js/arabic-search.js') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/js/arabic-search.js') ?: time() ?>"></script>
<script src="<?= url('assets/js/searchable-select.js') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/js/searchable-select.js') ?: time() ?>"></script> <script src="<?= url('assets/js/searchable-select.js') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/js/searchable-select.js') ?: time() ?>"></script>
<script src="<?= url('assets/js/sidebar-search.js') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/js/sidebar-search.js') ?: time() ?>"></script> <script src="<?= url('assets/js/sidebar-search.js') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/js/sidebar-search.js') ?: time() ?>"></script>
<script src="<?= url('assets/js/forms-engine.js') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/js/forms-engine.js') ?: time() ?>"></script> <script src="<?= url('assets/js/forms-engine.js') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/js/forms-engine.js') ?: time() ?>"></script>
...@@ -196,7 +197,6 @@ window.addEventListener('load', function() { ...@@ -196,7 +197,6 @@ window.addEventListener('load', function() {
} }
}); });
</script> </script>
<?php $__template->include('Shared.Components.searchable_select'); ?>
<?= $__template->yield('scripts', '') ?> <?= $__template->yield('scripts', '') ?>
<?php if (str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/tutorials/')): ?> <?php if (str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/tutorials/')): ?>
<script src="/assets/js/tutorial-screenshots.js"></script> <script src="/assets/js/tutorial-screenshots.js"></script>
......
...@@ -476,6 +476,52 @@ code { ...@@ -476,6 +476,52 @@ code {
opacity: 1; opacity: 1;
} }
/* ── مجموعة داخل مجموعة (مستوى ثالث) ─────────────────────────────
مثال: المحاسبة المالية » حسابات البنوك » المطابقة البنكية.
لازم تبان مجموعة مش رابط، عشان المستخدم يعرف إنها بتتفتح. */
.sidebar-subgroup > .sidebar-subgroup-toggle {
justify-content: space-between;
cursor: pointer;
font-weight: 600;
color: rgba(214, 224, 236, 0.92);
}
.sidebar-subgroup > .sidebar-subgroup-toggle::before {
/* المجموعة ماليهاش نقطة زي الرابط — ليها سهم بدلها */
display: none;
}
.sidebar-subgroup > .sidebar-subgroup-toggle .sidebar-arrow {
display: inline-flex;
align-items: center;
transition: transform var(--duration-normal) var(--ease-out);
opacity: 0.55;
}
.sidebar-subgroup > .sidebar-subgroup-toggle .sidebar-arrow i,
.sidebar-subgroup > .sidebar-subgroup-toggle .sidebar-arrow svg {
width: 14px;
height: 14px;
}
.sidebar-subgroup.open > .sidebar-subgroup-toggle .sidebar-arrow {
transform: rotate(-90deg);
}
.sidebar-subgroup.open > .sidebar-subgroup-toggle {
color: #e8eef6;
}
/* بنود المستوى الثالث بتتزاح شوية عشان التدرّج يبان */
.sidebar-submenu-nested {
padding-block: 0 4px;
}
.sidebar-submenu-nested .sidebar-sublink {
margin-inline-end: 42px;
font-size: 12.5px;
}
/* Sidebar Footer */ /* Sidebar Footer */
.sidebar-footer { .sidebar-footer {
padding: 16px 20px; padding: 16px 20px;
......
/**
* نواة البحث المشتركة — تطبيع عربي/إنجليزي ومطابقة تقريبية.
*
* كان في تلات أماكن بتعمل بحث بتلات طرق مختلفة: البحث في القائمة الجانبية
* كان بيطبّع الهمزات ويتحمّل الأخطاء المطبعية، والقوايم المنسدلة كانت
* بتعمل indexOf عادي فـ«احمد» ما بتلاقيش «أحمد». دلوقتي كلهم بيقروا من هنا.
*
* القواعد:
* - الهمزات كلها ترجع ألف، والتاء المربوطة هاء، والألف المقصورة ياء.
* - التشكيل والتطويل بيتشالوا.
* - الأرقام العربية والفارسية بترجع أرقام إنجليزي.
* - الإنجليزي بيتحوّل lowercase.
* - المطابقة: احتواء الأول (سريع)، وبعدين مطابقة تقريبية بالكلمة
* بتتحمّل خطأ حرف أو اتنين حسب طول ما المستخدم كتبه.
*
* window.ArabicSearch = { normalize, matches, score, rank }
*/
(function (root) {
'use strict';
var FOLD = {
'أ': 'ا', 'إ': 'ا', 'آ': 'ا', 'ٱ': 'ا', 'ٲ': 'ا', 'ٳ': 'ا',
'ى': 'ي', 'ئ': 'ي', 'ة': 'ه', 'ؤ': 'و',
'ک': 'ك', 'گ': 'ك', 'ی': 'ي', 'ي': 'ي',
'پ': 'ب', 'چ': 'ج', 'ژ': 'ز', 'ڤ': 'ف'
};
var DIGITS = {
'٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4',
'٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9',
'۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4',
'۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9'
};
/** يشيل التشكيل والتطويل. */
function stripDiacritics(str) {
return str.replace(/[ً-ْٰـۖ-ۭ]/g, '');
}
function normalize(str) {
if (str === null || str === undefined) return '';
str = String(str).trim().toLowerCase();
str = stripDiacritics(str);
str = str.replace(/[أإآٱٲٳىئةؤکگیپچژڤ]/g, function (c) { return FOLD[c] || c; });
str = str.replace(/[٠-٩۰-۹]/g, function (c) { return DIGITS[c] || c; });
// الشرطات والنقط بين الكلمات تتعامل كمسافة عشان أكواد الحسابات
str = str.replace(/[._\-\/\\]+/g, ' ');
return str.replace(/\s+/g, ' ').trim();
}
function levenshtein(a, b) {
if (a === b) return 0;
if (!a.length) return b.length;
if (!b.length) return a.length;
var prev = [], i, j, cur, cost;
for (j = 0; j <= b.length; j++) prev[j] = j;
for (i = 1; i <= a.length; i++) {
cur = [i];
for (j = 1; j <= b.length; j++) {
cost = a[i - 1] === b[j - 1] ? 0 : 1;
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
}
prev = cur;
}
return prev[b.length];
}
/** كام غلطة نسمح بيها حسب طول اللي المستخدم كتبه. */
function tolerance(len) {
if (len <= 3) return 0;
if (len <= 6) return 1;
return 2;
}
/**
* درجة المطابقة: 0 = مش مطابق، والأعلى أحسن.
* بنرجّع درجة مش true/false عشان نقدر نرتّب النتايج بالأقرب.
*/
function score(query, haystack) {
if (!query) return 1;
if (!haystack) return 0;
if (haystack === query) return 1000;
if (haystack.indexOf(query) === 0) return 900;
var pos = haystack.indexOf(query);
if (pos !== -1) return 800 - Math.min(pos, 200);
var words = haystack.split(' ');
var i, w, best = 0;
// بداية أي كلمة
for (i = 0; i < words.length; i++) {
if (words[i].indexOf(query) === 0) return 700;
}
// كل حروف ما كتبه بالترتيب جوه النص (زي بحث الملفات في المحررات)
if (subsequence(query, haystack)) best = 400;
// مطابقة تقريبية بالكلمة — بتمسك الأخطاء المطبعية
var tol = tolerance(query.length);
if (tol > 0) {
for (i = 0; i < words.length; i++) {
w = words[i];
if (!w) continue;
if (Math.abs(w.length - query.length) > tol + 2) continue;
var slice = w.length > query.length ? w.slice(0, query.length + tol) : w;
var d = levenshtein(slice, query);
if (d <= tol) best = Math.max(best, 600 - d * 50);
}
}
return best;
}
function subsequence(needle, hay) {
var i = 0;
for (var j = 0; j < hay.length && i < needle.length; j++) {
if (hay[j] === needle[i]) i++;
}
return i === needle.length;
}
/**
* هل النص ده مطابق؟ بيطبّع الطرفين لوحده.
* لو عايز تطبّع مرة واحدة برّه استخدم matchesNormalized.
*/
function matches(query, haystack) {
return score(normalize(query), normalize(haystack)) > 0;
}
function matchesNormalized(normQuery, normHaystack) {
return score(normQuery, normHaystack) > 0;
}
/**
* بيرتّب مصفوفة حسب قربها من البحث.
* @param {string} query
* @param {Array} items
* @param {function} textOf بيرجّع النص اللي هنبحث فيه من كل عنصر
* @returns {Array} العناصر المطابقة مرتّبة بالأقرب
*/
function rank(query, items, textOf) {
var q = normalize(query);
if (!q) return items.slice();
var out = [];
for (var i = 0; i < items.length; i++) {
var s = score(q, normalize(textOf ? textOf(items[i]) : items[i]));
if (s > 0) out.push({ item: items[i], s: s, i: i });
}
out.sort(function (a, b) { return b.s - a.s || a.i - b.i; });
return out.map(function (o) { return o.item; });
}
root.ArabicSearch = {
normalize: normalize,
score: score,
matches: matches,
matchesNormalized: matchesNormalized,
rank: rank,
levenshtein: levenshtein
};
})(window);
...@@ -11,13 +11,29 @@ var SearchableSelect = (function() { ...@@ -11,13 +11,29 @@ var SearchableSelect = (function() {
var instances = []; var instances = [];
function init() { // أي قايمة أطول من كده بتتحوّل لقايمة بحث تلقائيًا، من غير ما الشاشة
var selects = document.querySelectorAll('select[data-searchable]'); // تعمل حاجة. ده عشان ما يبقاش في ليستة طويلة من غير بحث في أي مكان.
selects.forEach(function(sel) { var AUTO_MIN_OPTIONS = 12;
if (sel._ssInitialized) return;
sel._ssInitialized = true; /** هل القايمة دي تستاهل بحث؟ */
instances.push(new Instance(sel)); function shouldEnhance(sel) {
}); if (sel._ssInitialized) return false;
if (sel.multiple || sel.disabled) return false;
if (sel.hasAttribute('data-no-search')) return false;
if (sel.hasAttribute('data-searchable')) return true;
if (sel.closest('[data-no-search]')) return false;
return sel.options.length >= AUTO_MIN_OPTIONS;
}
function enhance(sel) {
if (!shouldEnhance(sel)) return;
sel._ssInitialized = true;
sel.setAttribute('data-ss-done', '1');
instances.push(new Instance(sel));
}
function init(container) {
(container || document).querySelectorAll('select').forEach(enhance);
} }
function Instance(originalSelect) { function Instance(originalSelect) {
...@@ -175,7 +191,7 @@ var SearchableSelect = (function() { ...@@ -175,7 +191,7 @@ var SearchableSelect = (function() {
Instance.prototype.filter = function(query) { Instance.prototype.filter = function(query) {
var self = this; var self = this;
query = query.trim().toLowerCase(); query = (query || '').trim();
if (self.ajaxUrl) { if (self.ajaxUrl) {
if (query.length < self.ajaxMin) { if (query.length < self.ajaxMin) {
...@@ -191,8 +207,11 @@ var SearchableSelect = (function() { ...@@ -191,8 +207,11 @@ var SearchableSelect = (function() {
if (query === '') { if (query === '') {
self.filteredOptions = self.options.slice(); self.filteredOptions = self.options.slice();
} else { } else {
self.filteredOptions = self.options.filter(function(o) { // نواة البحث المشتركة: بتطبّع الهمزات والتشكيل والأرقام العربية،
return o.text.toLowerCase().indexOf(query) !== -1 || o.value.indexOf(query) !== -1; // وبتتحمّل الأخطاء المطبعية، وبترتّب النتايج بالأقرب للي اتكتب.
// البحث بيشمل النص وقيمة الخيار (كود الحساب مثلًا).
self.filteredOptions = window.ArabicSearch.rank(query, self.options, function (o) {
return o.text + ' ' + o.value;
}); });
} }
self.highlightIndex = -1; self.highlightIndex = -1;
...@@ -356,19 +375,31 @@ var SearchableSelect = (function() { ...@@ -356,19 +375,31 @@ var SearchableSelect = (function() {
} }
function initNew(container) { function initNew(container) {
var selects = (container || document).querySelectorAll('select[data-searchable]:not([data-ss-done])'); init(container);
selects.forEach(function(sel) { }
if (sel._ssInitialized) return;
sel._ssInitialized = true; /**
sel.setAttribute('data-ss-done', '1'); * بعد نسخ صف فيه قايمة متفعّل عليها البحث (زي «+ سطر جديد» في القيد)،
instances.push(new Instance(sel)); * النسخة بتيجي شايلة الواجهة القديمة. بننضّفها عشان تتفعّل من جديد.
*/
function reset(container) {
(container || document).querySelectorAll('.ss-wrapper').forEach(function (wrap) {
var sel = wrap.parentNode ? wrap.parentNode.querySelector('select') : null;
wrap.remove();
if (sel) {
sel.style.display = '';
sel._ssInitialized = false;
sel.removeAttribute('data-ss-done');
}
}); });
} }
return { return {
init: init, init: init,
refresh: refreshAll, refresh: refreshAll,
initNew: initNew initNew: initNew,
enhance: init,
reset: reset
}; };
})(); })();
......
/** /**
* Sidebar menu search — filters sidebar-menu items as the user types. * البحث في القائمة الجانبية — بيفلتر البنود وانت بتكتب.
* Normalizes Arabic orthographic variants (hamza forms, ta marbuta, alef maksura, * التطبيع والمطابقة التقريبية من النواة المشتركة في arabic-search.js.
* Arabic-Indic digits) and does approximate (typo-tolerant) matching so users
* don't need to type an exact match.
*/ */
(function () { (function () {
'use strict'; 'use strict';
var ARABIC_FOLD_MAP = { var AS = window.ArabicSearch;
'أ': 'ا', 'إ': 'ا', 'آ': 'ا', 'ٱ': 'ا',
'ى': 'ي', 'ة': 'ه', 'ؤ': 'و', 'ئ': 'ي'
};
var DIGIT_MAP = { function normalize(str) { return AS.normalize(str); }
'٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4', '٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9', function fuzzyMatch(query, haystack) { return AS.matchesNormalized(query, haystack); }
'۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4', '۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9'
};
function stripDiacritics(str) {
return str.replace(/[ً-ٰٟۖ-ۭـ]/g, '');
}
function normalize(str) {
if (!str) return '';
str = str.toString().trim().toLowerCase();
str = stripDiacritics(str);
str = str.replace(/[أإآٱىةؤئ]/g, function (c) { return ARABIC_FOLD_MAP[c] || c; });
str = str.replace(/[٠-٩۰-۹]/g, function (c) { return DIGIT_MAP[c] || c; });
return str.replace(/\s+/g, ' ').trim();
}
function levenshtein(a, b) {
if (a === b) return 0;
if (!a.length) return b.length;
if (!b.length) return a.length;
var prev = [];
for (var j = 0; j <= b.length; j++) prev[j] = j;
for (var i = 1; i <= a.length; i++) {
var cur = [i];
for (var j2 = 1; j2 <= b.length; j2++) {
var cost = a[i - 1] === b[j2 - 1] ? 0 : 1;
cur[j2] = Math.min(prev[j2] + 1, cur[j2 - 1] + 1, prev[j2 - 1] + cost);
}
prev = cur;
}
return prev[b.length];
}
// Substring match first (fast path), then per-word approximate match
// so short typos ("ميمبر" vs "ممبر") still find the right entry.
function fuzzyMatch(query, haystack) {
if (!query) return true;
if (!haystack) return false;
if (haystack.indexOf(query) !== -1) return true;
var words = haystack.split(' ');
var tolerance = query.length <= 3 ? 0 : (query.length <= 6 ? 1 : 2);
if (tolerance === 0) return false;
for (var i = 0; i < words.length; i++) {
var w = words[i];
if (!w || Math.abs(w.length - query.length) > tolerance + 2) continue;
if (w.indexOf(query) === 0) return true;
var slice = w.length > query.length ? w.slice(0, query.length + tolerance) : w;
if (levenshtein(slice, query) <= tolerance) return true;
}
return false;
}
function initSidebarSearch() { function initSidebarSearch() {
var input = document.getElementById('sidebar-search-input'); var input = document.getElementById('sidebar-search-input');
...@@ -78,28 +20,59 @@ ...@@ -78,28 +20,59 @@
var items = Array.prototype.slice.call(menu.querySelectorAll(':scope > .sidebar-item')); var items = Array.prototype.slice.call(menu.querySelectorAll(':scope > .sidebar-item'));
var sections = Array.prototype.slice.call(menu.querySelectorAll(':scope > .sidebar-section-label')); var sections = Array.prototype.slice.call(menu.querySelectorAll(':scope > .sidebar-section-label'));
items.forEach(function (item) { /**
item.__searchText = normalize(item.getAttribute('data-search') || ''); * القائمة بقت أكتر من مستوين (قسم » مجموعة » بند)، فالفهرسة والفلترة
item.__submenu = item.querySelector('.sidebar-submenu'); * لازم يمشوا على الشجرة كلها. لو فضلنا على مستوى واحد، بند زي
item.__children = item.__submenu ? Array.prototype.slice.call(item.__submenu.children) : []; * «المطابقة البنكية» اللي جوه مجموعة مش هيتلاقى في البحث خالص.
item.__children.forEach(function (child) { */
child.__searchText = normalize(child.getAttribute('data-search') || ''); function indexNode(li) {
}); li.__searchText = normalize(li.getAttribute('data-search') || '');
item.__wasOpen = item.classList.contains('open'); li.__submenu = li.querySelector(':scope > .sidebar-submenu');
}); li.__children = li.__submenu
? Array.prototype.slice.call(li.__submenu.children).filter(function (c) { return c.tagName === 'LI'; })
: [];
li.__wasOpen = li.classList.contains('open');
li.__children.forEach(indexNode);
}
items.forEach(indexNode);
function restoreNode(li) {
li.style.display = '';
li.classList.toggle('open', li.__wasOpen);
if (li.__submenu) li.__submenu.style.display = li.__wasOpen ? '' : 'none';
li.__children.forEach(restoreNode);
}
function restoreDefaultState() { function restoreDefaultState() {
items.forEach(function (item) { items.forEach(restoreNode);
item.style.display = '';
item.classList.toggle('open', item.__wasOpen);
item.__children.forEach(function (child) { child.style.display = ''; });
if (item.__submenu) {
item.__submenu.style.display = item.__wasOpen ? '' : 'none';
}
});
sections.forEach(function (s) { s.style.display = ''; }); sections.forEach(function (s) { s.style.display = ''; });
} }
/**
* بيرجّع true لو البند ده أو أي حاجة تحته مطابقة.
* forceShow = الأب نفسه مطابق، فبنعرض كل اللي تحته.
*/
function filterNode(li, query, forceShow) {
var selfMatch = fuzzyMatch(query, li.__searchText);
var show = forceShow || selfMatch;
var anyChild = false;
li.__children.forEach(function (child) {
if (filterNode(child, query, show)) anyChild = true;
});
var visible = show || anyChild;
li.style.display = visible ? '' : 'none';
if (li.__submenu && visible && (anyChild || show)) {
li.__submenu.style.display = '';
li.classList.add('open');
}
return visible;
}
function applyFilter(rawQuery) { function applyFilter(rawQuery) {
var query = normalize(rawQuery); var query = normalize(rawQuery);
clearBtn.classList.toggle('visible', !!rawQuery); clearBtn.classList.toggle('visible', !!rawQuery);
...@@ -111,25 +84,8 @@ ...@@ -111,25 +84,8 @@
} }
var anyVisible = false; var anyVisible = false;
items.forEach(function (item) { items.forEach(function (item) {
var selfMatch = fuzzyMatch(query, item.__searchText); if (filterNode(item, query, false)) anyVisible = true;
var childMatches = item.__children.filter(function (child) {
return fuzzyMatch(query, child.__searchText);
});
var visible = selfMatch || childMatches.length > 0;
item.style.display = visible ? '' : 'none';
if (visible) anyVisible = true;
if (item.__submenu) {
if (visible) {
item.__children.forEach(function (child) {
child.style.display = (selfMatch || childMatches.indexOf(child) !== -1) ? '' : 'none';
});
item.__submenu.style.display = '';
item.classList.add('open');
}
}
}); });
sections.forEach(function (section) { sections.forEach(function (section) {
......
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
فحص روابط القوائم والشاشات.
بعد أي إعادة ترتيب للقائمة الجانبية، أسهل غلطة إن بند يفضل مشاور على
مسار اتشال أو اتغيّر، والمستخدم يكتشفها بنفسه. الأداة دي بتقارن:
١. كل بند في القائمة → لازم يكون له GET route.
٢. كل href في الـ views → لازم يكون له route (بتتجاهل الروابط الخارجية).
٣. الـ routes اللي مفيش أي بند قائمة أو لينك بيوصّلها (شاشات يتيمة).
python3 tools/menu_link_check.py
python3 tools/menu_link_check.py --orphans # يعرض الشاشات اليتيمة كمان
بيرجّع exit 1 لو في رابط مكسور.
"""
import argparse
import glob
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def routes():
"""كل الـ routes: {(method, regex_pattern, raw_path)}"""
out = []
for f in sorted(glob.glob(os.path.join(ROOT, 'app/Modules/*/Routes.php'))):
src = open(f, encoding='utf-8').read()
for m in re.finditer(r"\[\s*'(GET|POST|PUT|PATCH|DELETE)'\s*,\s*'([^']+)'", src):
out.append((m.group(1), m.group(2)))
return out
def to_regex(path):
"""/members/{id:\\d+}/edit → ^/members/[^/]+/edit$"""
pattern = re.sub(r'\{[^}]+\}', '[^/]+', path)
return re.compile('^' + pattern + '$')
def menu_entries():
"""كل بنود القائمة مع مسارها — بيمشي على أي عمق تداخل."""
entries = []
for f in sorted(glob.glob(os.path.join(ROOT, 'app/Modules/*/bootstrap.php'))):
module = f.split(os.sep)[-2]
src = open(f, encoding='utf-8').read()
# كل سطر فيه label_ar و route مع بعض
for m in re.finditer(
r"'label_ar'\s*=>\s*'([^']*)'.*?'route'\s*=>\s*'([^']*)'",
src, re.S
):
label, route = m.group(1), m.group(2)
if not route or route == '#':
continue
# نتجاهل الملفات البعيدة عن تعريف القائمة
entries.append((module, label, route))
# إزالة التكرار
seen, out = set(), []
for e in entries:
if e[1:] in seen:
continue
seen.add(e[1:])
out.append(e)
return out
def view_links():
"""كل href داخلي مكتوب في الـ views."""
out = []
for f in glob.glob(os.path.join(ROOT, 'app/Modules/*/Views/**/*.php'), recursive=True) + \
glob.glob(os.path.join(ROOT, 'app/Shared/**/*.php'), recursive=True):
try:
src = open(f, encoding='utf-8').read()
except Exception:
continue
rel = os.path.relpath(f, ROOT)
for m in re.finditer(r'href="(/[^"#?]*?)(<\?|")', src):
href = m.group(1).rstrip('/')
# الرابط اللي بينتهي بكود PHP معناه إن فيه جزء متغيّر (رقم سجل
# مثلًا)، فبنتعامل معاه كبداية مسار مش كمسار كامل.
dynamic = m.group(2) == '<?'
# /storage و /assets ملفات ثابتة مش routes
if not href or href.startswith(('/assets', '/storage', '/uploads')):
continue
out.append((rel, src.count('\n', 0, m.start()) + 1, href, dynamic))
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--orphans', action='store_true')
args = ap.parse_args()
all_routes = routes()
get_paths = [p for (meth, p) in all_routes if meth == 'GET']
get_regex = [(p, to_regex(p)) for p in get_paths]
def resolves(url, prefix=False):
url = url.split('?')[0].rstrip('/') or '/'
for raw, rx in get_regex:
if rx.match(url) or rx.match(url + '/'):
return raw
if prefix:
# رابط فيه جزء متغيّر: يكفي إن في route الجزء الثابت ده بدايته.
# المقارنة بالمقاطع عشان {type} في الـ route تقابل «members» في اللينك.
want = [seg for seg in url.split('/') if seg]
for raw in get_paths:
have = [seg for seg in raw.split('/') if seg]
if len(have) < len(want):
continue
if all(h == w or h.startswith('{') for h, w in zip(have, want)):
return raw
return None
problems = 0
print('── بنود القائمة ─────────────────────────────')
hit = set()
for module, label, route in menu_entries():
target = resolves(route)
if target is None:
print(f' ✗ {module:22} «{label}» → {route} (مفيش route)')
problems += 1
else:
hit.add(target)
print(f' {len(menu_entries())} بند، {problems} مكسور')
print('\n── روابط الشاشات ────────────────────────────')
broken_links = {}
for rel, line, href, dynamic in view_links():
target = resolves(href, prefix=dynamic)
if target is None:
broken_links.setdefault(href, []).append(f'{rel}:{line}')
else:
hit.add(target)
for href, where in sorted(broken_links.items()):
print(f' ✗ {href} ({where[0]}' + (f' +{len(where)-1}' if len(where) > 1 else '') + ')')
problems += 1
print(f' {len(broken_links)} رابط مكسور')
if args.orphans:
print('\n── شاشات مفيش حاجة بتوصّلها ─────────────────')
orphans = [p for p in get_paths if p not in hit and '{' not in p]
for p in sorted(orphans):
print(f' · {p}')
print(f' {len(orphans)} شاشة')
print(f'\n{problems} مشكلة')
return 1 if problems else 0
if __name__ == '__main__':
sys.exit(main())
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