Commit 4f63ae92 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fixes

parent e23d5df3
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Club/sports facility ERP system built on a **custom PHP 8.1+ framework** (NOT Laravel, NOT Symfony). Arabic-first, RTL, multi-branch. All views are in Arabic. The framework is hand-built with plain PDO, custom autoloader, custom template engine, and modular MVC architecture.
## Commands
```bash
php cli.php migrate # Run pending database migrations
php cli.php migrate:rollback # Undo last batch
php cli.php migrate:status # Show migration status
php cli.php seed # Run pending seeds
php cli.php seed:run <Name> # Run specific seed
php cli.php cron # Run cron jobs
```
No composer, no npm, no build step. The app uses a custom PSR-4-style autoloader (`App\Core\Autoloader`). No test framework is configured.
## Architecture
### Entry Points
- **Web**: `public/index.php` — boots App singleton, dispatches router, sends response
- **CLI**: `cli.php` — minimal bootstrap (no sessions/routing), handles migrate/seed/cron
### App Boot Sequence
`App::getInstance()->boot()`: timezone (Africa/Cairo) -> .env -> config files -> DB connect -> session start -> load `system_config` table overrides -> execute all `app/Modules/*/bootstrap.php` (sorted) -> init router -> load all `app/Modules/*/Routes.php`
### Module Structure
Each module lives in `app/Modules/{Name}/` with:
- `bootstrap.php` — registers permissions via `PermissionRegistry`, sidebar menu via `MenuRegistry`, event listeners via `EventBus::listen()`
- `Routes.php` — returns array of `[METHOD, PATH, HANDLER, MIDDLEWARE_ARRAY, PERMISSION]`
- `Controllers/`, `Models/`, `Services/`, `Views/`
### Routing
Route format: `['GET', '/members/{id:\d+}', 'Members\Controllers\MemberController@show', ['auth'], 'member.view']`
Handler string resolves to `App\Modules\{handler}`. Controller method receives `(Request $request, ...$routeParams)` and returns `Response`.
Middleware names map to classes: `auth` -> AuthMiddleware, `csrf` -> CSRFMiddleware, `permission` -> PermissionMiddleware.
### Database Layer
`App\Core\Database` wraps PDO directly. Key methods:
- `select($sql, $params)` / `selectOne($sql, $params)` — prepared statements
- `insert($table, $data)` / `update($table, $data, $where, $params)` / `delete($table, $where, $params)`
- `query($sql, $params)` returns PDOStatement; `raw($sql)` executes without params
**No ORM relations.** Relationships are loaded via manual SQL joins or separate queries.
**IMPORTANT**: MySQL `SHOW TABLES LIKE ?` does NOT work with prepared statement placeholders. Use `information_schema.tables` query or hardcoded string instead.
### Model Layer
`App\Core\Model` provides: `$table`, `$fillable`, `$timestamps`, `$softDelete` (uses `is_archived` column). Static methods: `find()`, `findOrFail()`, `create()`, `query()` (returns QueryBuilder). Instance methods: `save()`, `update()`, `archive()`. Models emit events via EventBus: `{singular}.created`, `{singular}.updated`, `{singular}.archived`.
### QueryBuilder
`Model::query()->where('status','=','active')->orderBy('name','ASC')->get()`. Supports `where`, `whereIn`, `whereNull`, `join`, `leftJoin`, `groupBy`, `having`, `limit`, `offset`, `paginate`. Soft delete filtering is automatic (`withoutArchived()` by default).
### Template Engine
Plain PHP templates with layout/section support. View path uses dot notation: `'Members.Views.show'` -> `app/Modules/Members/Views/show.php`. Layout files: `'Layout.main'` -> `app/Shared/Layout/main.php`.
In views: `$__template->layout('Layout.main')`, `$__template->section('content')` / `$__template->endSection()`, `$__template->yield('content')`, `$__template->include('path', $data)`.
### EventBus
`EventBus::listen('event.name', $callback, $priority)` / `EventBus::dispatch('event.name', $data)`. Used heavily for cross-module integration (e.g., Accounting listens to payment/sale/fine events to auto-post journal entries).
### Flash Messages
`$session->flash('_alerts', [['type' => 'success', 'message' => '...']])`. Response helpers: `->withSuccess()`, `->withError()`, `->withWarning()`.
### Authorization
Routes specify a permission string. `Controller::authorize($permission)` throws RuntimeException(403) if denied. Employee permissions come from `role_permissions` + `employee_permissions` tables. Super admin check: query `employee_roles` joined with `roles` where `role_code = 'super_admin'`.
## Key Conventions
### Migration Naming
`database/migrations/Phase_NN_NNN_description.php` — returns `['up' => $sql, 'down' => $sql]` or a closure receiving `Database`. Multiple SQL statements in `up` are split by `;\n` (semicolon + newline).
### Seed Naming
`database/seeds/Phase_NN_NNN_description.php` — returns closure receiving `Database`. Tracked in `seeds` table; won't re-run unless forced.
### Permission Keys
Format: `module.action` (e.g., `member.view`, `accounting.journal.create`). Registered in each module's `bootstrap.php` via `PermissionRegistry::register()`.
### Global Helpers
`e()` for HTML escaping, `money()` for currency formatting, `csrf_field()` for form tokens, `old()` for repopulating forms after validation failure, `arabic_date()` for localized dates, `now()` / `today()` for current datetime/date.
### Controller Patterns
- `$this->view('Module.Views.path', $data)` to render
- `$this->json($data)` for API responses
- `$this->redirect('/path')` for redirects
- `$this->validate($_POST, $rules)` validates and flashes errors on failure
- `$this->authorize('permission.key')` checks permission
### Response Chaining
`return $this->redirect('/path')->withSuccess('message')` — flash + redirect in one call.
### Database Config
Connection params from `config/database.php` which reads from `.env`: `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS`. Constructor: `new Database(string $host, int $port, string $dbName, string $user, string $pass, string $charset)`.
## Common Pitfalls
- **CLI context**: `App::getInstance()->db()` is null in CLI. Seeds receive `Database` as parameter; use `App::getInstance()->setDb($db)` if services need the App singleton.
- **Table existence checks**: Never use `SHOW TABLES LIKE ?` with params. Use `SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?`.
- **Column names vary by table**: Not all tables have `is_archived` — check the migration before assuming. `fines` has no `is_archived` or `imposed_date` (use `created_at`). `hr_payroll_runs` uses `paid_at` not `payment_date`. `installment_plans` has no `is_archived`.
- **`declare(strict_types=1)`**: Used in all PHP files. Type mismatches (e.g., passing array to string param) cause fatal errors.
- **ExceptionHandler**: Respects exception code for HTTP status (401, 403, 404 return proper pages; everything else is 500).
......@@ -29,10 +29,18 @@ final class ExceptionHandler
$debug = env('APP_DEBUG', false);
}
http_response_code(500);
$httpCode = in_array($e->getCode(), [401, 403, 404], true) ? $e->getCode() : 500;
http_response_code($httpCode);
header('Content-Type: text/html; charset=utf-8');
if ($debug) {
if ($httpCode === 403) {
echo '<html dir="rtl" lang="ar"><head><meta charset="utf-8"><title>غير مصرح</title>'
. '<style>body{font-family:Cairo,sans-serif;text-align:center;padding:60px;background:#f9fafb;}'
. 'h1{color:#DC2626;font-size:48px;}p{color:#6B7280;font-size:16px;}'
. 'a{color:#0D7377;text-decoration:none;}</style></head>'
. '<body><h1>403</h1><p>ليس لديك صلاحية للوصول إلى هذه الصفحة.</p>'
. '<a href="/dashboard">العودة للوحة التحكم</a></body></html>';
} elseif ($debug) {
echo '<html dir="rtl" lang="ar"><head><meta charset="utf-8"><title>خطأ</title>'
. '<style>body{font-family:monospace,Cairo;padding:20px;background:#fef2f2;direction:ltr;text-align:left;}'
. 'h1{color:#DC2626;}pre{background:#1a1a2e;color:#e5e7eb;padding:15px;border-radius:8px;overflow-x:auto;white-space:pre-wrap;}'
......
......@@ -17,21 +17,38 @@ class BankReconciliationController extends Controller
{
$this->authorize('accounting.bank_recon.view');
$reconciliations = BankReconciliation::query()
->where('is_archived', '=', 0)
->orderBy('reconciliation_date', 'DESC')
->limit(50)
->get();
$db = App::getInstance()->db();
foreach ($reconciliations as &$r) {
$ba = $db->selectOne("SELECT account_name_ar FROM bank_accounts WHERE id = ?", [(int) $r['bank_account_id']]);
$r['bank_account_name'] = $ba['account_name_ar'] ?? '—';
$statusFilter = $_GET['status'] ?? '';
$bankAccountFilter = !empty($_GET['bank_account_id']) ? (int) $_GET['bank_account_id'] : 0;
$where = 'br.is_archived = 0';
$params = [];
if ($statusFilter !== '') {
$where .= ' AND br.status = ?';
$params[] = $statusFilter;
}
if ($bankAccountFilter > 0) {
$where .= ' AND br.bank_account_id = ?';
$params[] = $bankAccountFilter;
}
unset($r);
$reconciliations = $db->select(
"SELECT br.*, ba.account_name_ar as bank_account_name
FROM bank_reconciliations br
LEFT JOIN bank_accounts ba ON ba.id = br.bank_account_id
WHERE {$where}
ORDER BY br.reconciliation_date DESC
LIMIT 100",
$params
);
$bankAccounts = $db->select("SELECT id, account_name_ar FROM bank_accounts WHERE is_archived = 0 ORDER BY account_name_ar");
return $this->view('Accounting/Views/bank_reconciliation/index', [
'reconciliations' => $reconciliations,
'bank_accounts' => $bankAccounts,
'filters' => ['status' => $statusFilter, 'bank_account_id' => $bankAccountFilter],
]);
}
......
......@@ -16,10 +16,15 @@ class ChartOfAccountsController extends Controller
{
$this->authorize('accounting.coa.view');
$search = trim($_GET['search'] ?? '');
$typeFilter = $_GET['type'] ?? '';
$tree = Account::getTree();
return $this->view('Accounting/Views/chart_of_accounts/index', [
'tree' => $tree,
'tree' => $tree,
'search' => $search,
'type' => $typeFilter,
]);
}
......
......@@ -206,30 +206,48 @@ class ReportController extends Controller
{
$this->authorize('accounting.reports.general_ledger');
$db = App::getInstance()->db();
$accountId = (int) ($_GET['account_id'] ?? 0);
$accountSearch = trim($_GET['account_search'] ?? '');
$currentFY = FiscalYear::current();
$dateFrom = $_GET['date_from'] ?? ($currentFY ? $currentFY->start_date : date('Y-01-01'));
$dateTo = $_GET['date_to'] ?? date('Y-m-d');
$costCenterId = !empty($_GET['cost_center_id']) ? (int) $_GET['cost_center_id'] : null;
$branchId = !empty($_GET['branch_id']) ? (int) $_GET['branch_id'] : null;
if ($accountId === 0 && $accountSearch !== '') {
$found = $db->selectOne(
"SELECT id FROM chart_of_accounts WHERE is_archived = 0 AND (account_code = ? OR name_ar = ? OR name_en = ?) LIMIT 1",
[$accountSearch, $accountSearch, $accountSearch]
);
if (!$found) {
$found = $db->selectOne(
"SELECT id FROM chart_of_accounts WHERE is_archived = 0 AND (account_code LIKE ? OR name_ar LIKE ? OR name_en LIKE ?) LIMIT 1",
["%{$accountSearch}%", "%{$accountSearch}%", "%{$accountSearch}%"]
);
}
if ($found) {
$accountId = (int) $found['id'];
}
}
$ledger = null;
if ($accountId > 0) {
$ledger = LedgerService::getAccountLedger($accountId, $dateFrom, $dateTo, $costCenterId, $branchId);
}
$costCenters = CostCenter::getActive();
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar");
return $this->view('Accounting/Views/reports/general_ledger', [
'ledger' => $ledger,
'account_id' => $accountId,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'cost_centers' => $costCenters,
'branches' => $branches,
'filters' => [
'ledger' => $ledger,
'account_id' => $accountId,
'account_search' => $accountSearch,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'cost_centers' => $costCenters,
'branches' => $branches,
'filters' => [
'cost_center_id' => $costCenterId,
'branch_id' => $branchId,
],
......@@ -338,24 +356,42 @@ class ReportController extends Controller
{
$this->authorize('accounting.reports.member_statement');
$db = App::getInstance()->db();
$memberId = (int) ($_GET['member_id'] ?? 0);
$memberSearch = trim($_GET['member_search'] ?? '');
$dateFrom = $_GET['date_from'] ?? date('Y-01-01');
$dateTo = $_GET['date_to'] ?? date('Y-m-d');
if ($memberId === 0 && $memberSearch !== '') {
$found = $db->selectOne(
"SELECT id FROM members WHERE form_number = ? OR membership_number = ? LIMIT 1",
[$memberSearch, $memberSearch]
);
if (!$found) {
$found = $db->selectOne(
"SELECT id FROM members WHERE full_name_ar LIKE ? OR full_name_en LIKE ? OR phone LIKE ? LIMIT 1",
["%{$memberSearch}%", "%{$memberSearch}%", "%{$memberSearch}%"]
);
}
if ($found) {
$memberId = (int) $found['id'];
}
}
$entries = [];
$member = null;
if ($memberId > 0) {
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT id, full_name_ar, form_number FROM members WHERE id = ?", [$memberId]);
$entries = LedgerService::getMemberStatement($memberId, $dateFrom, $dateTo);
}
return $this->view('Accounting/Views/reports/member_statement', [
'member' => $member,
'entries' => $entries,
'member_id' => $memberId,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'member' => $member,
'entries' => $entries,
'member_id' => $memberId,
'member_search' => $memberSearch,
'date_from' => $dateFrom,
'date_to' => $dateTo,
]);
}
......
......@@ -107,9 +107,10 @@ class JournalEntry extends Model
$params[] = $filters['fiscal_year_id'];
}
if (!empty($filters['search'])) {
$where[] = '(je.entry_number LIKE ? OR je.description_ar LIKE ? OR je.description_en LIKE ? OR je.reference_number LIKE ?)';
$where[] = '(je.entry_number LIKE ? OR je.description_ar LIKE ? OR je.description_en LIKE ? OR je.reference_number LIKE ?'
. ' OR EXISTS (SELECT 1 FROM journal_entry_lines jl JOIN chart_of_accounts ca ON ca.id = jl.account_id WHERE jl.journal_entry_id = je.id AND (ca.account_code LIKE ? OR ca.name_ar LIKE ?)))';
$term = '%' . $filters['search'] . '%';
$params = array_merge($params, [$term, $term, $term, $term]);
$params = array_merge($params, [$term, $term, $term, $term, $term, $term]);
}
$whereClause = implode(' AND ', $where);
......
......@@ -7,6 +7,16 @@
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="flex:2;">
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" id="ba-search" class="form-input" placeholder="اسم الحساب، البنك، رقم الحساب...">
</div>
</div>
</div>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
......@@ -21,9 +31,9 @@
<th></th>
</tr>
</thead>
<tbody>
<tbody id="ba-tbody">
<?php foreach ($accounts as $acc): ?>
<tr>
<tr data-search="<?= e($acc['account_name_ar'] . ' ' . ($acc['account_name_en'] ?? '') . ' ' . $acc['bank_name_ar'] . ' ' . ($acc['bank_name_en'] ?? '') . ' ' . $acc['account_number'] . ' ' . ($acc['iban'] ?? '') . ' ' . $acc['currency']) ?>">
<td style="font-weight:600;"><?= e($acc['account_name_ar']) ?><?= (int)$acc['is_default'] ? ' <span style="color:#0D7377;font-size:11px;">(افتراضي)</span>' : '' ?></td>
<td><?= e($acc['bank_name_ar']) ?></td>
<td style="direction:ltr;text-align:right;"><?= e($acc['account_number']) ?></td>
......@@ -40,4 +50,12 @@
</table>
</div>
</div>
<script>
(function(){
var s=document.getElementById('ba-search'),rows=document.querySelectorAll('#ba-tbody tr[data-search]');
function f(){var q=(s.value||'').toLowerCase();rows.forEach(function(r){
r.style.display=!q||(r.getAttribute('data-search')||'').toLowerCase().indexOf(q)!==-1?'':'none';});}
if(s)s.addEventListener('input',f);
})();
</script>
<?php $__template->endSection(); ?>
......@@ -7,6 +7,32 @@
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<form method="GET" action="/accounting/bank-reconciliation" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">الحساب البنكي</label>
<select name="bank_account_id" class="form-select">
<option value="">الكل</option>
<?php foreach ($bank_accounts as $ba): ?>
<option value="<?= (int)$ba['id'] ?>" <?= (int)($filters['bank_account_id'] ?? 0) === (int)$ba['id'] ? 'selected' : '' ?>><?= e($ba['account_name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-select">
<option value="">الكل</option>
<option value="draft" <?= ($filters['status'] ?? '') === 'draft' ? 'selected' : '' ?>>مسودة</option>
<option value="in_progress" <?= ($filters['status'] ?? '') === 'in_progress' ? 'selected' : '' ?>>جاري العمل</option>
<option value="completed" <?= ($filters['status'] ?? '') === 'completed' ? 'selected' : '' ?>>مكتملة</option>
<option value="approved" <?= ($filters['status'] ?? '') === 'approved' ? 'selected' : '' ?>>معتمدة</option>
</select>
</div>
<button type="submit" class="btn btn-outline">بحث</button>
</form>
</div>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
......
......@@ -7,11 +7,33 @@
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="flex:2;">
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" id="coa-search" class="form-input" value="<?= e($search ?? '') ?>" placeholder="كود الحساب، الاسم، الوصف...">
</div>
<div>
<label class="form-label" style="font-size:12px;">النوع</label>
<select id="coa-type-filter" class="form-select">
<option value="">الكل</option>
<option value="asset" <?= ($type ?? '') === 'asset' ? 'selected' : '' ?>>أصول</option>
<option value="liability" <?= ($type ?? '') === 'liability' ? 'selected' : '' ?>>خصوم</option>
<option value="equity" <?= ($type ?? '') === 'equity' ? 'selected' : '' ?>>حقوق ملكية</option>
<option value="revenue" <?= ($type ?? '') === 'revenue' ? 'selected' : '' ?>>إيرادات</option>
<option value="expense" <?= ($type ?? '') === 'expense' ? 'selected' : '' ?>>مصروفات</option>
</select>
</div>
</div>
</div>
</div>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">دليل الحسابات (شجرة الحسابات)</h3>
<h3 style="margin:0;">دليل الحسابات (شجرة الحسابات) <span id="coa-count" style="font-size:13px;color:#6B7280;font-weight:400;"></span></h3>
</div>
<div style="padding:15px 20px;">
<div style="padding:15px 20px;" id="coa-tree">
<?php if (empty($tree)): ?>
<p style="color:#6B7280;text-align:center;padding:40px 0;">لا توجد حسابات — <a href="/accounting/chart-of-accounts/create">أضف حساباً جديداً</a></p>
<?php else: ?>
......@@ -38,7 +60,7 @@
default => '',
};
?>
<div style="display:flex;align-items:center;padding:8px 0;border-bottom:1px solid #F3F4F6;margin-right:<?= $indent ?>px;">
<div class="coa-row" data-code="<?= e($acc['account_code']) ?>" data-name="<?= e($acc['name_ar'] . ' ' . ($acc['name_en'] ?? '')) ?>" data-type="<?= e($acc['account_type']) ?>" data-desc="<?= e(($acc['description_ar'] ?? '') . ' ' . ($acc['description_en'] ?? '')) ?>" style="display:flex;align-items:center;padding:8px 0;border-bottom:1px solid #F3F4F6;margin-right:<?= $indent ?>px;">
<span style="width:80px;direction:ltr;text-align:right;color:<?= $color ?>;font-weight:600;font-size:13px;"><?= e($acc['account_code']) ?></span>
<span style="flex:1;margin:0 15px;font-weight:<?= $weight ?>;"><?= e($acc['name_ar']) ?></span>
<span style="width:80px;font-size:12px;color:#6B7280;"><?= $typeLabel ?></span>
......@@ -56,4 +78,32 @@
<?php endif; ?>
</div>
</div>
<script>
(function() {
var searchEl = document.getElementById('coa-search');
var typeEl = document.getElementById('coa-type-filter');
var countEl = document.getElementById('coa-count');
var rows = document.querySelectorAll('.coa-row');
function filter() {
var q = (searchEl.value || '').toLowerCase();
var t = typeEl.value;
var shown = 0;
rows.forEach(function(row) {
var code = (row.getAttribute('data-code') || '').toLowerCase();
var name = (row.getAttribute('data-name') || '').toLowerCase();
var desc = (row.getAttribute('data-desc') || '').toLowerCase();
var type = row.getAttribute('data-type') || '';
var matchSearch = !q || code.indexOf(q) !== -1 || name.indexOf(q) !== -1 || desc.indexOf(q) !== -1;
var matchType = !t || type === t;
if (matchSearch && matchType) { row.style.display = ''; shown++; }
else { row.style.display = 'none'; }
});
countEl.textContent = q || t ? '(' + shown + ' نتيجة)' : '';
}
if (searchEl) searchEl.addEventListener('input', filter);
if (typeEl) typeEl.addEventListener('change', filter);
filter();
})();
</script>
<?php $__template->endSection(); ?>
......@@ -7,6 +7,24 @@
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="flex:2;">
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" id="cc-search" class="form-input" placeholder="الكود، الاسم...">
</div>
<div>
<label class="form-label" style="font-size:12px;">النوع</label>
<select id="cc-type" class="form-select">
<option value="">الكل</option>
<option value="cost_center">مركز تكلفة</option>
<option value="profit_center">مركز ربحية</option>
</select>
</div>
</div>
</div>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
......@@ -20,13 +38,13 @@
<th></th>
</tr>
</thead>
<tbody>
<tbody id="cc-tbody">
<?php
$branchMap = [];
foreach ($branches as $b) $branchMap[(int)$b['id']] = $b['name_ar'];
?>
<?php foreach ($centers as $cc): ?>
<tr>
<tr data-code="<?= e($cc['code']) ?>" data-name="<?= e($cc['name_ar'] . ' ' . ($cc['name_en'] ?? '')) ?>" data-type="<?= e($cc['type']) ?>">
<td style="font-weight:600;direction:ltr;text-align:right;"><?= e($cc['code']) ?></td>
<td><?= e($cc['name_ar']) ?></td>
<td><?= $cc['type'] === 'cost_center' ? 'مركز تكلفة' : 'مركز ربحية' ?></td>
......@@ -42,4 +60,13 @@
</table>
</div>
</div>
<script>
(function(){
var s=document.getElementById('cc-search'),t=document.getElementById('cc-type'),rows=document.querySelectorAll('#cc-tbody tr[data-code]');
function f(){var q=(s.value||'').toLowerCase(),tv=t.value;rows.forEach(function(r){
var mc=!q||(r.getAttribute('data-code')||'').toLowerCase().indexOf(q)!==-1||(r.getAttribute('data-name')||'').toLowerCase().indexOf(q)!==-1;
var mt=!tv||r.getAttribute('data-type')===tv;r.style.display=mc&&mt?'':'none';});}
if(s)s.addEventListener('input',f);if(t)t.addEventListener('change',f);
})();
</script>
<?php $__template->endSection(); ?>
......@@ -7,6 +7,25 @@
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="flex:2;">
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" id="fy-search" class="form-input" placeholder="اسم السنة المالية...">
</div>
<div>
<label class="form-label" style="font-size:12px;">الحالة</label>
<select id="fy-status" class="form-select">
<option value="">الكل</option>
<option value="open">مفتوحة</option>
<option value="closing">قيد الإقفال</option>
<option value="closed">مغلقة</option>
</select>
</div>
</div>
</div>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
......@@ -20,7 +39,7 @@
<th></th>
</tr>
</thead>
<tbody>
<tbody id="fy-tbody">
<?php foreach ($years as $fy): ?>
<?php
$statusColor = match($fy['status']) {
......@@ -36,7 +55,7 @@
default => $fy['status'],
};
?>
<tr>
<tr data-name="<?= e($fy['name_ar'] . ' ' . ($fy['name_en'] ?? '')) ?>" data-status="<?= e($fy['status']) ?>">
<td style="font-weight:600;"><?= e($fy['name_ar']) ?></td>
<td><?= e($fy['start_date']) ?></td>
<td><?= e($fy['end_date']) ?></td>
......@@ -52,4 +71,14 @@
</table>
</div>
</div>
<script>
(function(){
var s=document.getElementById('fy-search'),st=document.getElementById('fy-status');
var rows=document.querySelectorAll('#fy-tbody tr[data-name]');
function f(){var q=(s.value||'').toLowerCase(),sv=st.value;
rows.forEach(function(r){var ms=!q||(r.getAttribute('data-name')||'').toLowerCase().indexOf(q)!==-1;
var mt=!sv||r.getAttribute('data-status')===sv;r.style.display=ms&&mt?'':'none';});}
if(s)s.addEventListener('input',f);if(st)st.addEventListener('change',f);
})();
</script>
<?php $__template->endSection(); ?>
......@@ -13,7 +13,7 @@
<form method="GET" action="/accounting/journal-entries" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" name="search" class="form-input" value="<?= e($filters['search'] ?? '') ?>" placeholder="رقم القيد، الوصف...">
<input type="text" name="search" class="form-input" value="<?= e($filters['search'] ?? '') ?>" placeholder="رقم القيد، الوصف، كود الحساب، اسم الحساب...">
</div>
<div>
<label class="form-label" style="font-size:12px;">الحالة</label>
......
......@@ -31,9 +31,28 @@
</div>
</div>
<!-- Search/Filter -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="flex:2;">
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" id="ap-search" class="form-input" placeholder="اسم المورد، رقم الفاتورة...">
</div>
<div>
<label class="form-label" style="font-size:12px;">الحالة</label>
<select id="ap-status" class="form-select">
<option value="">الكل</option>
<option value="overdue">متأخر فقط</option>
</select>
</div>
</div>
</div>
</div>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">الدائنون المستحقة</h3>
<h3 style="margin:0;">الدائنون المستحقة <span id="ap-count" style="font-size:13px;color:#6B7280;font-weight:400;"></span></h3>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
......@@ -48,10 +67,10 @@
<th>الحالة</th>
</tr>
</thead>
<tbody>
<tbody id="ap-tbody">
<?php foreach ($outstanding as $ap): ?>
<?php $isOverdue = !empty($ap['due_date']) && strtotime($ap['due_date']) < time(); ?>
<tr>
<tr data-search="<?= e(($ap['supplier_name'] ?? '') . ' ' . ($ap['invoice_number'] ?? '')) ?>" data-overdue="<?= $isOverdue ? '1' : '0' ?>">
<td><?= e($ap['supplier_name'] ?? '—') ?></td>
<td style="direction:ltr;text-align:right;"><?= e($ap['invoice_number']) ?></td>
<td style="<?= $isOverdue ? 'color:#DC2626;font-weight:600;' : '' ?>"><?= e($ap['due_date']) ?></td>
......@@ -68,4 +87,16 @@
</table>
</div>
</div>
<script>
(function(){
var s=document.getElementById('ap-search'),st=document.getElementById('ap-status'),c=document.getElementById('ap-count');
var rows=document.querySelectorAll('#ap-tbody tr[data-search]');
function f(){var q=(s.value||'').toLowerCase(),sv=st.value,n=0;
rows.forEach(function(r){var ms=!q||(r.getAttribute('data-search')||'').toLowerCase().indexOf(q)!==-1;
var mo=sv!=='overdue'||r.getAttribute('data-overdue')==='1';
if(ms&&mo){r.style.display='';n++;}else{r.style.display='none';}});
c.textContent=(q||sv)?'('+n+' نتيجة)':'';}
if(s)s.addEventListener('input',f);if(st)st.addEventListener('change',f);
})();
</script>
<?php $__template->endSection(); ?>
......@@ -31,10 +31,39 @@
</div>
</div>
<!-- Search/Filter -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="flex:2;">
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" id="ar-search" class="form-input" placeholder="اسم العضو، النوع، الوصف...">
</div>
<div>
<label class="form-label" style="font-size:12px;">النوع</label>
<select id="ar-type" class="form-select">
<option value="">الكل</option>
<option value="installment">أقساط</option>
<option value="subscription">اشتراك</option>
<option value="fine">غرامة</option>
<option value="membership_fee">عضوية</option>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">الحالة</label>
<select id="ar-status" class="form-select">
<option value="">الكل</option>
<option value="overdue">متأخر فقط</option>
</select>
</div>
</div>
</div>
</div>
<!-- Outstanding List -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">المدينين المستحقة</h3>
<h3 style="margin:0;">المدينين المستحقة <span id="ar-count" style="font-size:13px;color:#6B7280;font-weight:400;"></span></h3>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
......@@ -50,7 +79,7 @@
<th>الحالة</th>
</tr>
</thead>
<tbody>
<tbody id="ar-tbody">
<?php foreach ($outstanding as $ar): ?>
<?php
$typeLabel = match($ar['document_type'] ?? '') {
......@@ -62,7 +91,7 @@
};
$isOverdue = !empty($ar['due_date']) && strtotime($ar['due_date']) < time();
?>
<tr>
<tr data-search="<?= e(($ar['member_name'] ?? '') . ' ' . ($ar['description_ar'] ?? '') . ' ' . $typeLabel) ?>" data-type="<?= e($ar['document_type'] ?? '') ?>" data-overdue="<?= $isOverdue ? '1' : '0' ?>">
<td><?= e($ar['member_name'] ?? '—') ?></td>
<td style="font-size:12px;"><?= $typeLabel ?></td>
<td style="font-size:13px;"><?= e($ar['description_ar']) ?></td>
......@@ -80,4 +109,17 @@
</table>
</div>
</div>
<script>
(function(){
var s=document.getElementById('ar-search'),t=document.getElementById('ar-type'),st=document.getElementById('ar-status'),c=document.getElementById('ar-count');
var rows=document.querySelectorAll('#ar-tbody tr[data-search]');
function f(){var q=(s.value||'').toLowerCase(),tv=t.value,sv=st.value,n=0;
rows.forEach(function(r){var ms=!q||(r.getAttribute('data-search')||'').toLowerCase().indexOf(q)!==-1;
var mt=!tv||r.getAttribute('data-type')===tv;
var mo=sv!=='overdue'||r.getAttribute('data-overdue')==='1';
if(ms&&mt&&mo){r.style.display='';n++;}else{r.style.display='none';}});
c.textContent=(q||tv||sv)?'('+n+' نتيجة)':'';}
if(s)s.addEventListener('input',f);if(t)t.addEventListener('change',f);if(st)st.addEventListener('change',f);
})();
</script>
<?php $__template->endSection(); ?>
......@@ -20,6 +20,15 @@
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">مركز التكلفة</label>
<select name="cost_center_id" class="form-select">
<option value="">الكل</option>
<?php foreach ($cost_centers as $cc): ?>
<option value="<?= (int)$cc['id'] ?>" <?= (int)($filters['cost_center_id'] ?? 0) === (int)$cc['id'] ? 'selected' : '' ?>><?= e($cc['code'] . ' — ' . $cc['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-primary">عرض</button>
</form>
</div>
......
......@@ -8,8 +8,10 @@
<div style="padding:15px 20px;">
<form method="GET" action="/accounting/reports/general-ledger" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="flex:2;">
<label class="form-label" style="font-size:12px;">الحساب</label>
<input type="number" name="account_id" class="form-input" value="<?= e($account_id) ?>" placeholder="رقم الحساب (ID)">
<label class="form-label" style="font-size:12px;">الحساب (كود أو اسم)</label>
<input type="text" name="account_search" class="form-input" value="<?= e($account_search ?? '') ?>" placeholder="مثال: 1101 أو النقدية بالخزينة" list="account-list">
<input type="hidden" name="account_id" value="<?= (int)$account_id ?>">
<datalist id="account-list"></datalist>
</div>
<div>
<label class="form-label" style="font-size:12px;">من تاريخ</label>
......@@ -79,7 +81,47 @@
</div>
<?php elseif ($account_id > 0): ?>
<div class="card" style="padding:40px;text-align:center;color:#6B7280;">الحساب غير موجود</div>
<?php elseif (!empty($account_search)): ?>
<div class="card" style="padding:40px;text-align:center;color:#DC2626;">لم يتم العثور على حساب بهذا الاسم أو الكود: "<?= e($account_search) ?>"</div>
<?php else: ?>
<div class="card" style="padding:40px;text-align:center;color:#6B7280;">اختر حساباً لعرض دفتر الأستاذ</div>
<?php endif; ?>
<script>
(function(){
var input = document.querySelector('[name="account_search"]');
var hidden = document.querySelector('[name="account_id"]');
var list = document.getElementById('account-list');
var timer;
if (!input) return;
input.addEventListener('input', function(){
clearTimeout(timer);
var q = input.value.trim();
if (q.length < 2) { list.innerHTML = ''; return; }
hidden.value = '0';
timer = setTimeout(function(){
fetch('/accounting/chart-of-accounts/search?q=' + encodeURIComponent(q))
.then(function(r){ return r.json(); })
.then(function(data){
list.innerHTML = '';
(data || []).slice(0, 15).forEach(function(a){
var opt = document.createElement('option');
opt.value = a.account_code + ' — ' + a.name_ar;
opt.setAttribute('data-id', a.id);
list.appendChild(opt);
});
});
}, 300);
});
input.addEventListener('change', function(){
var opts = list.querySelectorAll('option');
for (var i = 0; i < opts.length; i++) {
if (opts[i].value === input.value) {
hidden.value = opts[i].getAttribute('data-id');
return;
}
}
});
})();
</script>
<?php $__template->endSection(); ?>
......@@ -24,6 +24,15 @@
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">مركز التكلفة</label>
<select name="cost_center_id" class="form-select">
<option value="">الكل</option>
<?php foreach ($cost_centers as $cc): ?>
<option value="<?= (int)$cc['id'] ?>" <?= (int)($filters['cost_center_id'] ?? 0) === (int)$cc['id'] ? 'selected' : '' ?>><?= e($cc['code'] . ' — ' . $cc['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-primary">عرض</button>
</form>
</div>
......
......@@ -7,9 +7,10 @@
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<form method="GET" action="/accounting/reports/member-statement" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">رقم العضو</label>
<input type="number" name="member_id" class="form-input" value="<?= e($member_id) ?>" placeholder="ID العضو">
<div style="flex:2;">
<label class="form-label" style="font-size:12px;">العضو (رقم الاستمارة، الاسم، أو الهاتف)</label>
<input type="text" name="member_search" class="form-input" value="<?= e($member_search ?? ($member ? $member['form_number'] . ' — ' . $member['full_name_ar'] : '')) ?>" placeholder="مثال: 1234 أو أحمد محمد">
<input type="hidden" name="member_id" value="<?= (int)$member_id ?>">
</div>
<div>
<label class="form-label" style="font-size:12px;">من تاريخ</label>
......@@ -63,6 +64,8 @@
</table>
</div>
</div>
<?php elseif (!empty($member_search)): ?>
<div class="card" style="padding:40px;text-align:center;color:#DC2626;">لم يتم العثور على عضو بهذا البحث: "<?= e($member_search) ?>"</div>
<?php elseif ($member_id > 0): ?>
<div class="card" style="padding:40px;text-align:center;color:#DC2626;">العضو غير موجود</div>
<?php endif; ?>
......
<?php
declare(strict_types=1);
namespace App\Modules\Cashier\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Cashier\Services\PaymentRequestService;
class CashierController extends Controller
{
public function queue(Request $request): Response
{
$this->authorize('cashier.view_queue');
$branch = App::getInstance()->currentBranch();
$branchId = $branch ? (int) $branch['id'] : null;
$filters = [
'status' => trim((string) $request->get('status', '')),
'payment_type' => trim((string) $request->get('payment_type', '')),
'search' => trim((string) $request->get('search', '')),
];
$requests = PaymentRequestService::getPendingQueue($branchId, $filters);
return $this->view('Cashier.Views.queue', [
'requests' => $requests,
'filters' => $filters,
]);
}
public function process(Request $request, string $id): Response
{
$this->authorize('cashier.process_payment');
$db = App::getInstance()->db();
$pr = $db->selectOne(
"SELECT pr.*, m.full_name_ar as member_name, m.form_number, m.membership_number, m.phone_mobile,
e.full_name_ar as requested_by_name
FROM payment_requests pr
LEFT JOIN members m ON m.id = pr.member_id
LEFT JOIN employees e ON e.id = pr.requested_by
WHERE pr.id = ? AND pr.is_voided = 0",
[(int) $id]
);
if (!$pr) {
return $this->redirect('/cashier')->withError('طلب الدفع غير موجود');
}
if ($pr['status'] === 'completed') {
return $this->redirect('/cashier')->withError('طلب الدفع مكتمل بالفعل');
}
return $this->view('Cashier.Views.process', [
'pr' => $pr,
]);
}
public function complete(Request $request, string $id): Response
{
$this->authorize('cashier.process_payment');
$paymentMethod = trim((string) $request->post('payment_method', 'cash'));
if ($paymentMethod === '') {
return $this->redirect('/cashier/' . $id)->withError('طريقة الدفع مطلوبة');
}
$result = PaymentRequestService::processRequest((int) $id, $paymentMethod);
if (!$result['success']) {
return $this->redirect('/cashier/' . $id)->withError($result['error']);
}
return $this->redirect('/cashier')->withSuccess(
'تم تحصيل الدفعة — إيصال: ' . ($result['receipt_number'] ?? '')
);
}
public function cancel(Request $request, string $id): Response
{
$this->authorize('cashier.cancel_request');
$reason = trim((string) $request->post('reason', ''));
$result = PaymentRequestService::cancelRequest((int) $id, $reason);
if (!$result['success']) {
return $this->redirect('/cashier/' . $id)->withError($result['error']);
}
return $this->redirect('/cashier')->withSuccess('تم إلغاء طلب الدفع');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Cashier\Models;
use App\Core\Model;
class PaymentRequest extends Model
{
protected static string $table = 'payment_requests';
protected static array $fillable = [
'request_number', 'member_id', 'payment_type', 'amount', 'currency',
'description_ar', 'related_entity_type', 'related_entity_id', 'status',
'payment_id', 'receipt_number', 'requested_by', 'processed_by', 'processed_at',
'branch_id', 'notes',
];
protected static bool $timestamps = true;
protected static bool $softDelete = false;
public static function pendingForMember(int $memberId, ?string $paymentType = null): ?array
{
$sql = "SELECT * FROM payment_requests WHERE member_id = ? AND status IN ('pending','processing') AND is_voided = 0";
$params = [$memberId];
if ($paymentType !== null) {
$sql .= ' AND payment_type = ?';
$params[] = $paymentType;
}
$sql .= ' ORDER BY id DESC LIMIT 1';
return \App\Core\App::getInstance()->db()->selectOne($sql, $params);
}
public static function completedForMember(int $memberId, string $paymentType): ?array
{
return \App\Core\App::getInstance()->db()->selectOne(
"SELECT * FROM payment_requests WHERE member_id = ? AND payment_type = ? AND status = 'completed' AND is_voided = 0 ORDER BY id DESC LIMIT 1",
[$memberId, $paymentType]
);
}
}
<?php
declare(strict_types=1);
return [
['GET', '/cashier', 'Cashier\Controllers\CashierController@queue', ['auth'], 'cashier.view_queue'],
['GET', '/cashier/{id}', 'Cashier\Controllers\CashierController@process', ['auth'], 'cashier.process_payment'],
['POST', '/cashier/{id}/complete', 'Cashier\Controllers\CashierController@complete', ['auth'], 'cashier.process_payment'],
['POST', '/cashier/{id}/cancel', 'Cashier\Controllers\CashierController@cancel', ['auth'], 'cashier.cancel_request'],
];
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تحصيل طلب دفع<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/cashier" class="btn btn-outline">&#8592; العودة للطابور</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$typeLabel = \App\Modules\Cashier\Services\PaymentRequestService::getPaymentTypeLabel($pr['payment_type']);
$statusColor = match($pr['status']) {
'pending' => '#F59E0B',
'processing' => '#3B82F6',
default => '#6B7280',
};
$statusLabel = match($pr['status']) {
'pending' => 'معلق',
'processing' => 'قيد التنفيذ',
default => $pr['status'],
};
?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;max-width:900px;">
<!-- Request Details -->
<div class="card" style="padding:25px;">
<h3 style="margin:0 0 20px;color:#1A1A2E;border-bottom:2px solid #E5E7EB;padding-bottom:10px;">بيانات الطلب</h3>
<table style="width:100%;font-size:14px;">
<tr>
<td style="padding:8px 0;color:#6B7280;width:40%;">رقم الطلب</td>
<td style="padding:8px 0;font-weight:700;direction:ltr;text-align:right;"><?= e($pr['request_number']) ?></td>
</tr>
<tr>
<td style="padding:8px 0;color:#6B7280;">نوع الدفعة</td>
<td style="padding:8px 0;font-weight:600;"><?= $typeLabel ?></td>
</tr>
<tr>
<td style="padding:8px 0;color:#6B7280;">المبلغ</td>
<td style="padding:8px 0;font-weight:700;font-size:24px;color:#059669;direction:ltr;text-align:right;"><?= money($pr['amount']) ?></td>
</tr>
<tr>
<td style="padding:8px 0;color:#6B7280;">العملة</td>
<td style="padding:8px 0;"><?= e($pr['currency']) ?></td>
</tr>
<tr>
<td style="padding:8px 0;color:#6B7280;">الحالة</td>
<td style="padding:8px 0;"><span style="color:<?= $statusColor ?>;font-weight:700;"><?= $statusLabel ?></span></td>
</tr>
<?php if ($pr['description_ar']): ?>
<tr>
<td style="padding:8px 0;color:#6B7280;">الوصف</td>
<td style="padding:8px 0;"><?= e($pr['description_ar']) ?></td>
</tr>
<?php endif; ?>
<?php if ($pr['notes']): ?>
<tr>
<td style="padding:8px 0;color:#6B7280;">ملاحظات</td>
<td style="padding:8px 0;"><?= e($pr['notes']) ?></td>
</tr>
<?php endif; ?>
<tr>
<td style="padding:8px 0;color:#6B7280;">تاريخ الطلب</td>
<td style="padding:8px 0;"><?= e($pr['created_at']) ?></td>
</tr>
<tr>
<td style="padding:8px 0;color:#6B7280;">بواسطة</td>
<td style="padding:8px 0;"><?= e($pr['requested_by_name'] ?? '—') ?></td>
</tr>
</table>
</div>
<!-- Member Info + Payment Form -->
<div>
<!-- Member Card -->
<div class="card" style="padding:20px;margin-bottom:20px;background:linear-gradient(135deg, #F0FDF4, #ECFDF5);border:1px solid #A7F3D0;">
<h4 style="margin:0 0 15px;color:#065F46;">بيانات العضو</h4>
<table style="width:100%;font-size:14px;">
<tr>
<td style="padding:6px 0;color:#6B7280;">الاسم</td>
<td style="padding:6px 0;font-weight:700;">
<a href="/members/<?= (int)$pr['member_id'] ?>" style="color:#0D7377;"><?= e($pr['member_name'] ?? '—') ?></a>
</td>
</tr>
<tr>
<td style="padding:6px 0;color:#6B7280;">رقم الاستمارة</td>
<td style="padding:6px 0;font-weight:700;color:#D97706;"><?= e($pr['form_number'] ?? '—') ?></td>
</tr>
<?php if ($pr['membership_number']): ?>
<tr>
<td style="padding:6px 0;color:#6B7280;">رقم العضوية</td>
<td style="padding:6px 0;font-weight:700;color:#0D7377;"><?= e($pr['membership_number']) ?></td>
</tr>
<?php endif; ?>
<?php if ($pr['phone_mobile']): ?>
<tr>
<td style="padding:6px 0;color:#6B7280;">الهاتف</td>
<td style="padding:6px 0;direction:ltr;text-align:right;"><?= e($pr['phone_mobile']) ?></td>
</tr>
<?php endif; ?>
</table>
</div>
<!-- Payment Action -->
<div class="card" style="padding:25px;border:2px solid #0D7377;">
<h3 style="margin:0 0 20px;color:#0D7377;">تحصيل الدفعة</h3>
<form method="POST" action="/cashier/<?= (int)$pr['id'] ?>/complete" id="paymentForm">
<?= csrf_field() ?>
<div class="form-group" style="margin-bottom:20px;">
<label class="form-label" style="font-weight:600;">طريقة الدفع</label>
<select name="payment_method" class="form-select" style="font-size:16px;padding:12px;">
<option value="cash">نقدي</option>
<option value="visa">فيزا</option>
<option value="bank_transfer">تحويل بنكي</option>
<option value="check">شيك</option>
</select>
</div>
<div style="background:#F9FAFB;border-radius:8px;padding:15px;margin-bottom:20px;text-align:center;">
<div style="font-size:13px;color:#6B7280;margin-bottom:5px;">المبلغ المطلوب تحصيله</div>
<div style="font-size:32px;font-weight:700;color:#059669;direction:ltr;"><?= money($pr['amount']) ?></div>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;padding:15px;font-size:18px;background:#059669;border-color:#059669;"
onclick="return confirm('تأكيد تحصيل <?= money($pr['amount']) ?> بطريقة ' + document.querySelector('[name=payment_method]').selectedOptions[0].text + '؟')">
تأكيد التحصيل
</button>
</form>
<hr style="margin:20px 0;border:0;border-top:1px solid #E5E7EB;">
<form method="POST" action="/cashier/<?= (int)$pr['id'] ?>/cancel" style="text-align:center;">
<?= csrf_field() ?>
<input type="hidden" name="reason" id="cancelReason" value="">
<button type="submit" class="btn btn-outline" style="color:#DC2626;border-color:#DC2626;font-size:13px;"
onclick="var r = prompt('سبب الإلغاء:'); if(!r) return false; document.getElementById('cancelReason').value = r; return confirm('تأكيد إلغاء الطلب؟');">
إلغاء الطلب
</button>
</form>
</div>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>طابور الخزينة<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<form method="GET" action="/cashier" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" name="search" class="form-input" value="<?= e($filters['search'] ?? '') ?>" placeholder="اسم العضو، رقم الاستمارة، رقم الطلب...">
</div>
<div>
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-select">
<option value="">معلق + قيد التنفيذ</option>
<option value="pending" <?= ($filters['status'] ?? '') === 'pending' ? 'selected' : '' ?>>معلق</option>
<option value="processing" <?= ($filters['status'] ?? '') === 'processing' ? 'selected' : '' ?>>قيد التنفيذ</option>
<option value="completed" <?= ($filters['status'] ?? '') === 'completed' ? 'selected' : '' ?>>مكتمل</option>
<option value="cancelled" <?= ($filters['status'] ?? '') === 'cancelled' ? 'selected' : '' ?>>ملغى</option>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">نوع الدفعة</label>
<select name="payment_type" class="form-select">
<option value="">الكل</option>
<option value="form_fee" <?= ($filters['payment_type'] ?? '') === 'form_fee' ? 'selected' : '' ?>>رسوم استمارة</option>
<option value="membership_fee" <?= ($filters['payment_type'] ?? '') === 'membership_fee' ? 'selected' : '' ?>>رسوم عضوية</option>
<option value="down_payment" <?= ($filters['payment_type'] ?? '') === 'down_payment' ? 'selected' : '' ?>>مقدم أقساط</option>
<option value="divorce_fee" <?= ($filters['payment_type'] ?? '') === 'divorce_fee' ? 'selected' : '' ?>>رسوم طلاق</option>
<option value="death_fee" <?= ($filters['payment_type'] ?? '') === 'death_fee' ? 'selected' : '' ?>>رسوم وفاة</option>
<option value="waiver_fee" <?= ($filters['payment_type'] ?? '') === 'waiver_fee' ? 'selected' : '' ?>>رسوم تنازل</option>
<option value="separation_fee" <?= ($filters['payment_type'] ?? '') === 'separation_fee' ? 'selected' : '' ?>>رسوم فصل</option>
<option value="addition_fee" <?= ($filters['payment_type'] ?? '') === 'addition_fee' ? 'selected' : '' ?>>رسوم إضافة</option>
<option value="annual_subscription" <?= ($filters['payment_type'] ?? '') === 'annual_subscription' ? 'selected' : '' ?>>اشتراك سنوي</option>
<option value="fine" <?= ($filters['payment_type'] ?? '') === 'fine' ? 'selected' : '' ?>>غرامة</option>
</select>
</div>
<button type="submit" class="btn btn-outline">بحث</button>
<?php if (!empty($filters['search']) || !empty($filters['status']) || !empty($filters['payment_type'])): ?>
<a href="/cashier" class="btn btn-outline" style="color:#DC2626;border-color:#DC2626;">مسح</a>
<?php endif; ?>
</form>
</div>
</div>
<!-- Queue -->
<div class="card">
<?php
$pendingCount = 0;
foreach ($requests as $r) {
if ($r['status'] === 'pending') $pendingCount++;
}
?>
<?php if ($pendingCount > 0): ?>
<div style="padding:12px 20px;background:#FEF2F2;border-bottom:1px solid #FECACA;font-size:14px;color:#DC2626;font-weight:600;">
<?= $pendingCount ?> طلب في الانتظار
</div>
<?php endif; ?>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>رقم الطلب</th>
<th>العضو</th>
<th>رقم الاستمارة</th>
<th>نوع الدفعة</th>
<th>المبلغ</th>
<th>الحالة</th>
<th>وقت الانتظار</th>
<th>بواسطة</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($requests as $r):
$statusColor = match($r['status']) {
'pending' => '#F59E0B',
'processing' => '#3B82F6',
'completed' => '#059669',
'cancelled' => '#DC2626',
default => '#6B7280',
};
$statusLabel = match($r['status']) {
'pending' => 'معلق',
'processing' => 'قيد التنفيذ',
'completed' => 'مكتمل',
'cancelled' => 'ملغى',
default => $r['status'],
};
$typeLabel = \App\Modules\Cashier\Services\PaymentRequestService::getPaymentTypeLabel($r['payment_type']);
$waitMinutes = (int) ((time() - strtotime($r['created_at'])) / 60);
if ($waitMinutes < 60) {
$waitText = $waitMinutes . ' د';
} elseif ($waitMinutes < 1440) {
$waitText = intdiv($waitMinutes, 60) . ' س ' . ($waitMinutes % 60) . ' د';
} else {
$waitText = intdiv($waitMinutes, 1440) . ' يوم';
}
$urgent = ($r['status'] === 'pending' && $waitMinutes > 30);
?>
<tr<?= $r['status'] === 'cancelled' ? ' style="opacity:0.5;"' : '' ?><?= $urgent ? ' style="background:#FEF2F2;"' : '' ?>>
<td style="direction:ltr;text-align:right;font-weight:600;font-size:12px;"><?= e($r['request_number']) ?></td>
<td>
<a href="/members/<?= (int)$r['member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($r['member_name'] ?? '—') ?></a>
</td>
<td style="font-weight:600;color:#D97706;"><?= e($r['form_number'] ?? '—') ?></td>
<td><?= $typeLabel ?></td>
<td style="direction:ltr;text-align:right;font-weight:700;font-size:15px;"><?= money($r['amount']) ?></td>
<td><span style="color:<?= $statusColor ?>;font-weight:600;font-size:13px;"><?= $statusLabel ?></span></td>
<td style="font-size:12px;<?= $urgent ? 'color:#DC2626;font-weight:700;' : 'color:#6B7280;' ?>"><?= $waitText ?></td>
<td style="font-size:12px;color:#6B7280;"><?= e($r['requested_by_name'] ?? '—') ?></td>
<td>
<?php if ($r['status'] === 'pending' || $r['status'] === 'processing'): ?>
<a href="/cashier/<?= (int)$r['id'] ?>" class="btn btn-sm btn-primary">تحصيل</a>
<?php elseif ($r['status'] === 'completed'): ?>
<span style="color:#059669;font-size:12px;">تم</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($requests)): ?>
<tr><td colspan="9" style="text-align:center;color:#6B7280;padding:40px;">
<div style="font-size:48px;margin-bottom:10px;">&#x1f4ad;</div>
لا توجد طلبات دفع
</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<script>
setTimeout(function(){ location.reload(); }, 30000);
</script>
<?php $__template->endSection(); ?>
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
use App\Core\EventBus;
MenuRegistry::register('cashier', [
'label_ar' => 'الخزينة',
'label_en' => 'Cashier',
'icon' => 'credit-card',
'route' => '/cashier',
'permission' => 'cashier.view_queue',
'parent' => null,
'order' => 250,
'children' => [
['label_ar' => 'طابور الدفع', 'label_en' => 'Payment Queue', 'route' => '/cashier', 'permission' => 'cashier.view_queue', 'order' => 1],
],
]);
PermissionRegistry::register('cashier', [
'cashier.view_queue' => ['ar' => 'عرض طابور الخزينة', 'en' => 'View Cashier Queue'],
'cashier.process_payment' => ['ar' => 'معالجة طلب دفع', 'en' => 'Process Payment Request'],
'cashier.cancel_request' => ['ar' => 'إلغاء طلب دفع', 'en' => 'Cancel Payment Request'],
]);
EventBus::listen('payment_request.completed', function (array $data) {
try {
$db = \App\Core\App::getInstance()->db();
$paymentType = $data['payment_type'] ?? '';
$memberId = (int) ($data['member_id'] ?? 0);
$requestId = (int) ($data['request_id'] ?? 0);
if ($memberId <= 0) return;
if (in_array($paymentType, ['membership_fee', 'down_payment'], true)) {
$member = \App\Modules\Members\Models\Member::find($memberId);
if (!$member) return;
$membershipNumber = \App\Modules\Members\Services\MemberNumberGenerator::assign($memberId);
$member->update(['status' => 'active']);
if ($paymentType === 'down_payment') {
$request = $db->selectOne("SELECT * FROM payment_requests WHERE id = ?", [$requestId]);
$notesData = $request && $request['notes'] ? json_decode($request['notes'], true) : [];
$months = min(30, max(1, (int) ($notesData['installment_months'] ?? 30)));
$amount = (string) ($data['amount'] ?? '0.00');
$membershipValue = $member->membership_value ?? '0.00';
$remaining = bcsub($membershipValue, $amount, 2);
if (bccomp($remaining, '0', 2) > 0) {
$interestRateData = \App\Modules\Rules\Services\RuleEngine::get('INSTALLMENT_INTEREST_RATE');
$interestRate = $interestRateData['percentage'] ?? '22.00';
$totalInterest = bcdiv(bcmul($remaining, $interestRate, 4), '100', 2);
$totalWithInterest = bcadd($remaining, $totalInterest, 2);
$monthlyPayment = bcdiv($totalWithInterest, (string) $months, 2);
$planId = $db->insert('installment_plans', [
'member_id' => $memberId, 'related_entity_type' => 'members', 'related_entity_id' => $memberId,
'total_amount' => $membershipValue, 'down_payment' => $amount,
'remaining_balance' => $remaining, 'interest_rate' => $interestRate,
'total_interest' => $totalInterest, 'total_with_interest' => $totalWithInterest,
'number_of_months' => $months, 'monthly_payment' => $monthlyPayment,
'start_date' => date('Y-m-d'), 'status' => 'active',
'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
]);
$rem = $totalWithInterest;
for ($i = 1; $i <= $months; $i++) {
$principal = bcdiv($remaining, (string) $months, 2);
$interest = bcdiv($totalInterest, (string) $months, 2);
$instAmount = bcadd($principal, $interest, 2);
$rem = bcsub($rem, $instAmount, 2);
if (bccomp($rem, '0', 2) < 0) $rem = '0.00';
$db->insert('installment_schedule', [
'installment_plan_id' => $planId, 'installment_number' => $i,
'due_date' => date('Y-m-d', strtotime("+{$i} months")),
'amount' => $instAmount, 'principal' => $principal, 'interest' => $interest,
'remaining_after' => $rem, 'paid_amount' => '0.00', 'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
]);
}
EventBus::dispatch('installment.plan_created', [
'plan_id' => $planId, 'member_id' => $memberId, 'total_amount' => $totalWithInterest,
]);
}
}
EventBus::dispatch('member.activated', ['member_id' => $memberId, 'membership_number' => $membershipNumber]);
\App\Core\Logger::info("Member activated via cashier", ['member_id' => $memberId, 'membership_number' => $membershipNumber]);
}
$entityType = $data['related_entity_type'] ?? null;
$entityId = (int) ($data['related_entity_id'] ?? 0);
$paymentId = (int) ($data['payment_id'] ?? 0);
if ($entityType && $entityId > 0) {
$tableMap = [
'divorce_fee' => ['table' => 'divorce_cases', 'event' => 'divorce.fee_paid', 'key' => 'case_id'],
'death_fee' => ['table' => 'death_cases', 'event' => 'death.fee_paid', 'key' => 'case_id'],
'waiver_fee' => ['table' => 'waiver_requests', 'event' => 'waiver.fee_paid', 'key' => 'waiver_id'],
'separation_fee' => ['table' => 'transfer_requests','event' => 'transfer.fee_paid', 'key' => 'transfer_id'],
];
if (isset($tableMap[$paymentType])) {
$cfg = $tableMap[$paymentType];
$db->update($cfg['table'], [
'status' => 'fee_paid',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$entityId]);
EventBus::dispatch($cfg['event'], [$cfg['key'] => $entityId, 'payment_id' => $paymentId]);
\App\Core\Logger::info("Life-event fee paid via cashier", ['type' => $paymentType, 'entity_id' => $entityId]);
}
}
} catch (\Throwable $e) {
\App\Core\Logger::error("payment_request.completed listener failed: " . $e->getMessage(), ['data' => $data]);
}
});
......@@ -12,6 +12,7 @@ use App\Modules\Death\Models\DeathCase;
use App\Modules\Archive\Services\ArchiveService;
use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Payments\Services\PaymentService;
use App\Modules\Cashier\Services\PaymentRequestService;
use App\Modules\ServiceCatalog\Models\ServicePrice;
class DeathController extends Controller
......@@ -87,33 +88,25 @@ class DeathController extends Controller
EventBus::dispatch('death.recorded', ['case_id' => (int) $case->id, 'member_id' => (int) $memberId, 'type' => $deceasedType]);
// Collect payment inline
// Send payment to cashier queue
if (bccomp($totalFee, '0', 2) > 0) {
$result = PaymentService::processPayment([
$result = PaymentRequestService::createRequest([
'member_id' => (int) $memberId,
'amount' => $totalFee,
'payment_type' => 'death_fee',
'payment_method' => $paymentMethod,
'related_entity_type' => 'death_cases',
'related_entity_id' => (int) $case->id,
'description' => 'رسوم وفاة (استمارة + اشتراك) — حالة #' . $case->id,
'description_ar' => 'رسوم وفاة (استمارة + اشتراك) — حالة #' . $case->id,
]);
if ($result['success']) {
$db->update('death_cases', [
'status' => 'fee_paid',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $case->id]);
EventBus::dispatch('death.fee_paid', ['case_id' => (int) $case->id, 'payment_id' => $result['payment_id']]);
return $this->redirect("/death/{$case->id}")->withSuccess(
'تم تسجيل حالة الوفاة وتحصيل الرسوم — ' . money($totalFee) . ' — إيصال: ' . $result['receipt_number']
'تم تسجيل حالة الوفاة وإرسال طلب الدفع للخزينة — ' . money($totalFee) . ' — رقم الطلب: ' . $result['request_number']
);
}
return $this->redirect("/death/{$case->id}")->withError(
'تم تسجيل الحالة لكن فشل تحصيل الرسوم: ' . ($result['error'] ?? 'خطأ غير معروف')
'تم تسجيل الحالة لكن فشل إنشاء طلب الدفع: ' . ($result['error'] ?? 'خطأ غير معروف')
);
}
......
......@@ -13,6 +13,7 @@ use App\Modules\Divorce\Services\DivorceFeeCalculator;
use App\Modules\Archive\Services\ArchiveService;
use App\Modules\Members\Services\MemberNumberGenerator;
use App\Modules\Payments\Services\PaymentService;
use App\Modules\Cashier\Services\PaymentRequestService;
class DivorceController extends Controller
{
......@@ -71,36 +72,27 @@ class DivorceController extends Controller
EventBus::dispatch('divorce.submitted', ['case_id' => (int) $case->id, 'member_id' => (int) $memberId]);
// Collect payment inline
// Send payment to cashier queue
$amount = $feeCalc['total_fee'] ?? '0.00';
if (bccomp((string) $amount, '0', 2) > 0) {
$result = PaymentService::processPayment([
$result = PaymentRequestService::createRequest([
'member_id' => (int) $memberId,
'amount' => $amount,
'payment_type' => 'divorce_fee',
'payment_method' => $paymentMethod,
'related_entity_type' => 'divorce_cases',
'related_entity_id' => (int) $case->id,
'description' => 'رسوم طلاق — حالة #' . $case->id,
'description_ar' => 'رسوم طلاق — حالة #' . $case->id,
]);
if ($result['success']) {
$db->update('divorce_cases', [
'status' => 'fee_paid',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $case->id]);
EventBus::dispatch('divorce.fee_paid', ['case_id' => (int) $case->id, 'payment_id' => $result['payment_id']]);
return $this->redirect("/divorce/{$case->id}")->withSuccess(
'تم تسجيل حالة الطلاق وتحصيل الرسوم — النوع: ' . DivorceCase::getCaseTypeLabel($feeCalc['case_type'])
. ' — الرسوم: ' . money($amount) . ' — إيصال: ' . $result['receipt_number']
'تم تسجيل حالة الطلاق وإرسال طلب الدفع للخزينة — النوع: ' . DivorceCase::getCaseTypeLabel($feeCalc['case_type'])
. ' — الرسوم: ' . money($amount) . ' — رقم الطلب: ' . $result['request_number']
);
}
// Payment failed — case created but not paid
return $this->redirect("/divorce/{$case->id}")->withError(
'تم تسجيل الحالة لكن فشل تحصيل الرسوم: ' . ($result['error'] ?? 'خطأ غير معروف')
'تم تسجيل الحالة لكن فشل إنشاء طلب الدفع: ' . ($result['error'] ?? 'خطأ غير معروف')
);
}
......
......@@ -8,6 +8,7 @@ use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Registries\PermissionRegistry;
use App\Modules\Forms\Models\FormSchema;
use App\Modules\Forms\Models\FormSubmission;
use App\Modules\Forms\Services\FormRenderer;
......@@ -56,6 +57,11 @@ class FormController extends Controller
return $this->redirect('/forms')->withError('نموذج غير موجود');
}
$formPermKey = 'forms.access.' . $code;
if (PermissionRegistry::has($formPermKey)) {
$this->authorize($formPermKey);
}
return $this->view('Forms.Views.render', [
'schema' => $schema,
'formHtml' => FormRenderer::render($schema),
......@@ -71,6 +77,11 @@ class FormController extends Controller
return $this->redirect('/forms')->withError('نموذج غير موجود');
}
$formPermKey = 'forms.access.' . $code;
if (PermissionRegistry::has($formPermKey)) {
$this->authorize($formPermKey);
}
$submittedData = $request->all();
unset($submittedData['_csrf_token']);
......
......@@ -21,4 +21,20 @@ PermissionRegistry::register('forms', [
'forms.view' => ['ar' => 'عرض النماذج', 'en' => 'View Forms'],
'forms.edit_schema' => ['ar' => 'تعديل هيكل النماذج', 'en' => 'Edit Form Schemas'],
'forms.create_schema' => ['ar' => 'إنشاء نموذج', 'en' => 'Create Form Schema'],
]);
\ No newline at end of file
]);
try {
$db = \App\Core\App::getInstance()->db();
$schemas = $db->select("SELECT form_code, name_ar FROM form_schemas WHERE is_active = 1");
$formPerms = [];
foreach ($schemas as $s) {
$formPerms['forms.access.' . $s['form_code']] = [
'ar' => 'الوصول لنموذج: ' . $s['name_ar'],
'en' => 'Access: ' . $s['form_code'],
];
}
if (!empty($formPerms)) {
PermissionRegistry::register('forms_access', $formPerms);
}
} catch (\Throwable $e) {
}
\ No newline at end of file
......@@ -14,6 +14,7 @@ use App\Modules\Members\Services\MemberNumberGenerator;
use App\Modules\Members\Services\MemberSearchService;
use App\Modules\Members\Services\BillingService;
use App\Modules\Payments\Services\PaymentService;
use App\Modules\Cashier\Services\PaymentRequestService;
use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Pricing\Models\SpecialDiscount;
......@@ -130,6 +131,16 @@ class MemberController extends Controller
$specialDiscount = $db->selectOne("SELECT * FROM special_discounts WHERE id = ?", [(int) $member->special_discount_id]);
}
$pendingRequests = PaymentRequestService::getForMember((int) $id);
$pendingFormFee = null;
$pendingMembership = null;
foreach ($pendingRequests as $pr) {
if ($pr['status'] === 'pending' || $pr['status'] === 'processing') {
if ($pr['payment_type'] === 'form_fee') $pendingFormFee = $pr;
if (in_array($pr['payment_type'], ['membership_fee', 'down_payment'], true)) $pendingMembership = $pr;
}
}
return $this->view('Members.Views.show', [
'member' => $member,
'branchName' => $branch['name_ar'] ?? '—',
......@@ -140,6 +151,8 @@ class MemberController extends Controller
'formFee' => MemberNumberGenerator::getFormFee(),
'formFilled' => $formFilled,
'specialDiscount' => $specialDiscount,
'pendingFormFee' => $pendingFormFee,
'pendingMembership' => $pendingMembership,
]);
}
......@@ -148,89 +161,43 @@ class MemberController extends Controller
$member = Member::find((int) $id);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
$result = PaymentService::processPayment([
'member_id' => (int) $id, 'amount' => MemberNumberGenerator::getFormFee(),
'payment_type' => 'form_fee', 'payment_method' => $request->post('payment_method', 'cash'),
'related_entity_type' => 'members', 'related_entity_id' => (int) $id,
'description' => 'رسوم استمارة عضوية رقم ' . ($member->form_number ?? ''),
$result = PaymentRequestService::createRequest([
'member_id' => (int) $id,
'amount' => MemberNumberGenerator::getFormFee(),
'payment_type' => 'form_fee',
'related_entity_type' => 'members',
'related_entity_id' => (int) $id,
'description_ar' => 'رسوم استمارة عضوية رقم ' . ($member->form_number ?? ''),
]);
if (!$result['success']) return $this->redirect('/members/' . $id)->withError($result['error']);
return $this->redirect('/members/' . $id)->withSuccess('تم دفع رسوم الاستمارة — إيصال: ' . $result['receipt_number']);
return $this->redirect('/members/' . $id)->withSuccess('تم إرسال طلب الدفع للخزينة — رقم الطلب: ' . $result['request_number']);
}
public function payMembership(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$member = Member::find((int) $id);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
$paymentType = trim((string) $request->post('payment_type', ''));
$amount = trim((string) $request->post('amount', '0'));
$paymentMethod = trim((string) $request->post('payment_method', 'cash'));
if (bccomp($amount, '0.01', 2) < 0) return $this->redirect('/members/' . $id)->withError('المبلغ غير صالح');
$result = PaymentService::processPayment([
'member_id' => (int) $id, 'amount' => $amount,
'payment_type' => $paymentType, 'payment_method' => $paymentMethod,
'related_entity_type' => 'members', 'related_entity_id' => (int) $id,
'description' => ($paymentType === 'down_payment' ? 'مقدم تقسيط' : 'قيمة العضوية') . ' — استمارة ' . ($member->form_number ?? ''),
$months = ($paymentType === 'down_payment') ? min(30, max(1, (int) $request->post('installment_months', 30))) : null;
$notes = $months ? json_encode(['installment_months' => $months], JSON_UNESCAPED_UNICODE) : null;
$result = PaymentRequestService::createRequest([
'member_id' => (int) $id,
'amount' => $amount,
'payment_type' => $paymentType,
'related_entity_type' => 'members',
'related_entity_id' => (int) $id,
'description_ar' => ($paymentType === 'down_payment' ? 'مقدم تقسيط' : 'قيمة العضوية') . ' — استمارة ' . ($member->form_number ?? ''),
'notes' => $notes,
]);
if (!$result['success']) return $this->redirect('/members/' . $id)->withError($result['error']);
$membershipNumber = MemberNumberGenerator::assign((int) $id);
$member->update(['status' => 'active']);
if ($paymentType === 'down_payment') {
$membershipValue = $member->membership_value ?? '0.00';
$remaining = bcsub($membershipValue, $amount, 2);
if (bccomp($remaining, '0', 2) > 0) {
$months = min(30, max(1, (int) $request->post('installment_months', 30)));
$interestRateData = RuleEngine::get('INSTALLMENT_INTEREST_RATE');
$interestRate = $interestRateData['percentage'] ?? '22.00';
$totalInterest = bcdiv(bcmul($remaining, $interestRate, 4), '100', 2);
$totalWithInterest = bcadd($remaining, $totalInterest, 2);
$monthlyPayment = bcdiv($totalWithInterest, (string) $months, 2);
$planId = $db->insert('installment_plans', [
'member_id' => (int) $id, 'related_entity_type' => 'members', 'related_entity_id' => (int) $id,
'total_amount' => $membershipValue, 'down_payment' => $amount,
'remaining_balance' => $remaining, 'interest_rate' => $interestRate,
'total_interest' => $totalInterest, 'total_with_interest' => $totalWithInterest,
'number_of_months' => $months, 'monthly_payment' => $monthlyPayment,
'start_date' => date('Y-m-d'), 'status' => 'active',
'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
]);
$rem = $totalWithInterest;
for ($i = 1; $i <= $months; $i++) {
$principal = bcdiv($remaining, (string) $months, 2);
$interest = bcdiv($totalInterest, (string) $months, 2);
$instAmount = bcadd($principal, $interest, 2);
$rem = bcsub($rem, $instAmount, 2);
if (bccomp($rem, '0', 2) < 0) $rem = '0.00';
$db->insert('installment_schedule', [
'installment_plan_id' => $planId, 'installment_number' => $i,
'due_date' => date('Y-m-d', strtotime("+{$i} months")),
'amount' => $instAmount, 'principal' => $principal, 'interest' => $interest,
'remaining_after' => $rem, 'paid_amount' => '0.00', 'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
]);
}
EventBus::dispatch('installment.plan_created', [
'plan_id' => $planId,
'member_id' => (int) $id,
'total_amount' => $totalWithInterest,
]);
}
}
EventBus::dispatch('member.activated', ['member_id' => (int) $id, 'membership_number' => $membershipNumber]);
$msg = '✅ تم السداد — رقم العضوية: ' . $membershipNumber . ' — إيصال: ' . $result['receipt_number'];
if ($paymentType === 'down_payment') $msg .= ' — تم إنشاء خطة تقسيط';
return $this->redirect('/members/' . $id)->withSuccess($msg);
return $this->redirect('/members/' . $id)->withSuccess('تم إرسال طلب الدفع للخزينة — رقم الطلب: ' . $result['request_number']);
}
public function fillForm(Request $request, string $id): Response
......
......@@ -10,10 +10,10 @@ return [
['GET', '/members/{id}/edit', 'Members\Controllers\MemberController@edit', ['auth'], 'member.edit'],
['POST', '/members/{id}', 'Members\Controllers\MemberController@update', ['auth'], 'member.edit'],
['POST', '/members/{id}/status', 'Members\Controllers\MemberController@changeStatus', ['auth'], 'member.change_status'],
['POST', '/members/{id}/pay-form-fee', 'Members\Controllers\MemberController@payFormFee', ['auth'], 'member.edit'],
['POST', '/members/{id}/pay-membership', 'Members\Controllers\MemberController@payMembership',['auth'], 'member.edit'],
['GET', '/members/{id}/fill-form', 'Members\Controllers\MemberController@fillForm', ['auth'], 'member.edit'],
['POST', '/members/{id}/fill-form', 'Members\Controllers\MemberController@saveFillForm', ['auth'], 'member.edit'],
['POST', '/members/{id}/pay-form-fee', 'Members\Controllers\MemberController@payFormFee', ['auth'], 'member.pay_form_fee'],
['POST', '/members/{id}/pay-membership', 'Members\Controllers\MemberController@payMembership',['auth'], 'member.pay_membership'],
['GET', '/members/{id}/fill-form', 'Members\Controllers\MemberController@fillForm', ['auth'], 'member.fill_form'],
['POST', '/members/{id}/fill-form', 'Members\Controllers\MemberController@saveFillForm', ['auth'], 'member.fill_form'],
['POST', '/api/members/parse-nid', 'Members\Controllers\MemberApiController@parseNid', ['auth'], 'member.create'],
['POST', '/api/members/search', 'Members\Controllers\MemberApiController@search', ['auth'], 'member.view'],
];
\ No newline at end of file
......@@ -20,9 +20,23 @@ $isActive = ($member->status === 'active');
<!-- STEP 1: Form Fee Not Paid -->
<!-- ═══════════════════════════════════════ -->
<?php if (!$bill['form_fee_paid'] && $member->status === 'potential'): ?>
<?php if (!empty($pendingFormFee)): ?>
<div class="card" style="margin-bottom:20px;padding:30px;background:linear-gradient(135deg, #FFF7ED, #FEF3C7);border:2px solid #F59E0B;">
<div style="text-align:center;">
<div style="font-size:48px;">&#x23F3;</div>
<h2 style="color:#D97706;margin:10px 0 5px;">في انتظار الخزينة</h2>
<p style="color:#6B7280;margin:0 0 15px;">تم إرسال طلب دفع رسوم الاستمارة للخزينة</p>
<div style="background:#fff;display:inline-block;padding:15px 30px;border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<div style="font-size:12px;color:#6B7280;">رقم الطلب</div>
<div style="font-size:20px;font-weight:700;color:#D97706;direction:ltr;"><?= e($pendingFormFee['request_number']) ?></div>
<div style="font-size:14px;color:#6B7280;margin-top:5px;">المبلغ: <strong style="color:#059669;"><?= money($pendingFormFee['amount']) ?></strong></div>
</div>
</div>
</div>
<?php else: ?>
<div class="card" style="margin-bottom:20px;padding:30px;background:linear-gradient(135deg, #FEF2F2, #FEE2E2);border:2px solid #DC2626;">
<div style="text-align:center;margin-bottom:20px;">
<div style="font-size:48px;">🔒</div>
<div style="font-size:48px;">&#x1f512;</div>
<h2 style="color:#DC2626;margin:10px 0 5px;">يجب دفع رسوم الاستمارة أولاً</h2>
</div>
<div style="max-width:500px;margin:0 auto;background:#fff;border-radius:12px;padding:25px;box-shadow:0 4px 12px rgba(0,0,0,0.1);">
......@@ -32,14 +46,11 @@ $isActive = ($member->status === 'active');
</table>
<form method="POST" action="/members/<?= (int) $member->id ?>/pay-form-fee">
<?= csrf_field() ?>
<div class="form-group" style="margin-bottom:15px;">
<label class="form-label">طريقة الدفع</label>
<select name="payment_method" class="form-select"><option value="cash">نقدي 💵</option><option value="visa">فيزا 💳</option><option value="bank_transfer">تحويل بنكي 🏦</option></select>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;padding:15px;font-size:18px;background:#059669;border-color:#059669;" onclick="return confirm('تأكيد دفع <?= money($formFee) ?>؟')">💰 دفع رسوم الاستمارة</button>
<button type="submit" class="btn btn-primary" style="width:100%;padding:15px;font-size:18px;background:#D97706;border-color:#D97706;" onclick="return confirm('إرسال طلب دفع <?= money($formFee) ?> للخزينة؟')">&#x1f4b0; إرسال للخزينة</button>
</form>
</div>
</div>
<?php endif; ?>
<!-- ═══════════════════════════════════════ -->
<!-- STEP 2: Fee Paid, Form Not Filled -->
......@@ -154,27 +165,37 @@ $isActive = ($member->status === 'active');
<!-- Payment Section -->
<?php if (bccomp($bill['total_pending'], '0', 2) > 0 && in_array($member->status, ['accepted', 'payment_pending'])): ?>
<?php if (!empty($pendingMembership)): ?>
<div style="padding:20px;background:#FFF7ED;border-top:2px solid #F59E0B;text-align:center;">
<div style="font-size:36px;">&#x23F3;</div>
<h4 style="margin:10px 0 5px;color:#D97706;">في انتظار الخزينة</h4>
<p style="color:#6B7280;margin:0 0 15px;">تم إرسال طلب دفع العضوية للخزينة</p>
<div style="background:#fff;display:inline-block;padding:15px 30px;border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<div style="font-size:12px;color:#6B7280;">رقم الطلب</div>
<div style="font-size:20px;font-weight:700;color:#D97706;direction:ltr;"><?= e($pendingMembership['request_number']) ?></div>
<div style="font-size:14px;color:#6B7280;margin-top:5px;">المبلغ: <strong style="color:#059669;"><?= money($pendingMembership['amount']) ?></strong></div>
<div style="font-size:12px;color:#6B7280;margin-top:5px;">النوع: <?= \App\Modules\Cashier\Services\PaymentRequestService::getPaymentTypeLabel($pendingMembership['payment_type']) ?></div>
</div>
</div>
<?php else: ?>
<div style="padding:20px;background:#FFF7ED;border-top:2px solid #F59E0B;">
<h4 style="margin:0 0 15px;color:#D97706;">💰 اختر طريقة السداد</h4>
<h4 style="margin:0 0 15px;color:#D97706;">&#x1f4b0; اختر طريقة السداد وأرسل للخزينة</h4>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<!-- Cash Full -->
<div style="background:#fff;border:2px solid #059669;border-radius:12px;padding:20px;">
<h5 style="margin:0 0 10px;color:#059669;">💵 كاش كامل</h5>
<h5 style="margin:0 0 10px;color:#059669;">&#x1f4b5; كاش كامل</h5>
<p style="font-size:13px;color:#6B7280;margin:0 0 10px;">ادفع المبلغ كاملاً — بدون فوائد</p>
<div style="font-size:24px;font-weight:700;color:#059669;margin-bottom:15px;"><?= money($bill['total_pending']) ?></div>
<form method="POST" action="/members/<?= (int) $member->id ?>/pay-membership">
<?= csrf_field() ?>
<input type="hidden" name="payment_type" value="membership_fee">
<input type="hidden" name="amount" value="<?= e($bill['total_pending']) ?>">
<select name="payment_method" class="form-select" style="margin-bottom:10px;">
<option value="cash">نقدي</option><option value="visa">فيزا</option><option value="bank_transfer">تحويل بنكي</option>
</select>
<button type="submit" class="btn btn-primary" style="width:100%;background:#059669;border-color:#059669;" onclick="return confirm('تأكيد دفع <?= money($bill['total_pending']) ?>؟')">ادفع الآن</button>
<button type="submit" class="btn btn-primary" style="width:100%;background:#D97706;border-color:#D97706;" onclick="return confirm('إرسال طلب دفع <?= money($bill['total_pending']) ?> للخزينة؟')">&#x1f4e4; إرسال للخزينة</button>
</form>
</div>
<!-- Installment -->
<div style="background:#fff;border:2px solid #0284C7;border-radius:12px;padding:20px;">
<h5 style="margin:0 0 10px;color:#0284C7;">📅 تقسيط</h5>
<h5 style="margin:0 0 10px;color:#0284C7;">&#x1f4c5; تقسيط</h5>
<p style="font-size:13px;color:#6B7280;margin:0 0 5px;">مقدم 25% على الأقل + باقي على أقساط</p>
<p style="font-size:12px;color:#D97706;margin:0 0 10px;">فائدة 22% سنوياً — حتى 30 شهر</p>
<?php
......@@ -193,24 +214,22 @@ $isActive = ($member->status === 'active');
<?= csrf_field() ?>
<input type="hidden" name="payment_type" value="down_payment">
<div class="form-group" style="margin-bottom:10px;">
<label class="form-label" style="font-size:12px;">المقدم ( <?= money($minDown) ?>)</label>
<label class="form-label" style="font-size:12px;">المقدم (&#x2265; <?= money($minDown) ?>)</label>
<input type="number" name="amount" value="<?= e($minDown) ?>" min="<?= e($minDown) ?>" step="0.01" class="form-input" style="direction:ltr;text-align:left;" required>
</div>
<div class="form-group" style="margin-bottom:10px;">
<label class="form-label" style="font-size:12px;">عدد الأشهر (حتى 30)</label>
<input type="number" name="installment_months" value="30" min="1" max="30" class="form-input" style="direction:ltr;text-align:left;">
</div>
<select name="payment_method" class="form-select" style="margin-bottom:10px;">
<option value="cash">نقدي</option><option value="visa">فيزا</option><option value="bank_transfer">تحويل</option>
</select>
<button type="submit" class="btn btn-primary" style="width:100%;background:#0284C7;border-color:#0284C7;" onclick="return confirm('تأكيد دفع المقدم؟')">ادفع المقدم</button>
<button type="submit" class="btn btn-primary" style="width:100%;background:#D97706;border-color:#D97706;" onclick="return confirm('إرسال طلب تقسيط للخزينة؟')">&#x1f4e4; إرسال للخزينة</button>
</form>
</div>
</div>
<div style="margin-top:15px;padding:10px;background:#FEF2F2;border-radius:8px;font-size:12px;color:#DC2626;">
⚠️ مهلة السداد: 15 يوم من تاريخ القبول — بعدها تنتهي الاستمارة
&#x26a0;&#xfe0f; مهلة السداد: 15 يوم من تاريخ القبول — بعدها تنتهي الاستمارة
</div>
</div>
<?php endif; ?>
<?php elseif ($formFilled && in_array($member->status, ['under_review', 'interview_scheduled'])): ?>
<div style="padding:20px;background:#EFF6FF;border-top:2px solid #0284C7;">
<p style="margin:0;color:#0284C7;font-size:14px;">📋 الفاتورة جاهزة — في انتظار قرار مجلس الأمناء قبل السداد</p>
......
......@@ -48,4 +48,7 @@ PermissionRegistry::register('members', [
'member.search' => ['ar' => 'بحث الأعضاء', 'en' => 'Search Members'],
'member.view_financial' => ['ar' => 'عرض البيانات المالية', 'en' => 'View Financial Data'],
'member.change_status' => ['ar' => 'تغيير حالة العضوية', 'en' => 'Change Member Status'],
'member.pay_form_fee' => ['ar' => 'دفع رسوم النموذج', 'en' => 'Pay Form Fee'],
'member.pay_membership' => ['ar' => 'دفع رسوم العضوية', 'en' => 'Pay Membership Fee'],
'member.fill_form' => ['ar' => 'تعبئة نموذج العضو', 'en' => 'Fill Member Form'],
]);
\ No newline at end of file
......@@ -2,9 +2,9 @@
declare(strict_types=1);
return [
['GET', '/roles', 'Roles\Controllers\RoleController@index', ['auth'], 'user.assign_role'],
['GET', '/roles/create', 'Roles\Controllers\RoleController@create', ['auth'], 'user.assign_role'],
['POST', '/roles', 'Roles\Controllers\RoleController@store', ['auth', 'csrf'], 'user.assign_role'],
['GET', '/roles/{id}/edit', 'Roles\Controllers\RoleController@edit', ['auth'], 'user.assign_role'],
['POST', '/roles/{id}', 'Roles\Controllers\RoleController@update', ['auth', 'csrf'], 'user.assign_role'],
];
\ No newline at end of file
['GET', '/roles', 'Roles\Controllers\RoleController@index', ['auth'], 'role.view'],
['GET', '/roles/create', 'Roles\Controllers\RoleController@create', ['auth'], 'role.create'],
['POST', '/roles', 'Roles\Controllers\RoleController@store', ['auth', 'csrf'], 'role.create'],
['GET', '/roles/{id}/edit', 'Roles\Controllers\RoleController@edit', ['auth'], 'role.edit'],
['POST', '/roles/{id}', 'Roles\Controllers\RoleController@update', ['auth', 'csrf'], 'role.edit'],
];
......@@ -29,18 +29,38 @@ $ap = $allPermissions ?? [];
</div>
<div class="card">
<div class="card-body" style="padding:20px;">
<h3 style="margin-bottom:15px;color:#1A1A2E;">الصلاحيات</h3>
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:15px;">
<h3 style="margin:0;color:#1A1A2E;">الصلاحيات</h3>
<input type="text" id="role-perm-search" placeholder="بحث في الصلاحيات..." class="form-input" style="width:280px;font-size:13px;">
</div>
<?php if (empty($ap)): ?>
<p style="color:#6B7280;">لا توجد صلاحيات مسجلة بعد. ستظهر تلقائياً عند إضافة الوحدات.</p>
<?php else: ?>
<?php foreach ($ap as $group => $perms): ?>
<div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:8px;padding:15px;">
<h4 style="margin-bottom:10px;color:#0D7377;font-size:16px;"><?= e($group) ?></h4>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(250px, 1fr));gap:8px;">
<?php
$groupTotal = count($perms);
$groupChecked = 0;
foreach ($perms as $key => $_) {
if (in_array($key, $rp)) $groupChecked++;
}
?>
<div class="role-perm-group" style="margin-bottom:15px;border:1px solid #E5E7EB;border-radius:8px;overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 15px;background:#F9FAFB;">
<div>
<strong style="color:#0D7377;font-size:15px;"><?= e($group) ?></strong>
<span class="group-count" style="font-size:12px;color:#6B7280;margin-right:8px;">(<?= $groupChecked ?>/<?= $groupTotal ?>)</span>
</div>
<div style="display:flex;gap:8px;">
<button type="button" onclick="toggleGroupAll(this.closest('.role-perm-group'), true)" style="font-size:11px;padding:2px 8px;border:1px solid #059669;color:#059669;background:#fff;border-radius:4px;cursor:pointer;">تحديد الكل</button>
<button type="button" onclick="toggleGroupAll(this.closest('.role-perm-group'), false)" style="font-size:11px;padding:2px 8px;border:1px solid #9CA3AF;color:#6B7280;background:#fff;border-radius:4px;cursor:pointer;">إلغاء الكل</button>
</div>
</div>
<div style="padding:10px 15px;display:grid;grid-template-columns:repeat(auto-fill, minmax(280px, 1fr));gap:8px;">
<?php foreach ($perms as $key => $labels): ?>
<label style="display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;">
<label class="role-perm-item" data-key="<?= e($key) ?>" data-label="<?= e($labels['ar'] ?? '') ?>" style="display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;padding:4px 0;">
<input type="checkbox" name="permissions[]" value="<?= e($key) ?>"
<?= in_array($key, $rp) ? 'checked' : '' ?>>
<?= in_array($key, $rp) ? 'checked' : '' ?>
onchange="updateGroupCount(this.closest('.role-perm-group'))">
<span><?= e($labels['ar'] ?? $key) ?></span>
<small style="color:#9CA3AF;">(<?= e($key) ?>)</small>
</label>
......@@ -50,4 +70,34 @@ $ap = $allPermissions ?? [];
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
\ No newline at end of file
</div>
<script>
function toggleGroupAll(groupEl, check) {
groupEl.querySelectorAll('input[type="checkbox"]').forEach(function(cb) { cb.checked = check; });
updateGroupCount(groupEl);
}
function updateGroupCount(groupEl) {
var total = groupEl.querySelectorAll('input[type="checkbox"]').length;
var checked = groupEl.querySelectorAll('input[type="checkbox"]:checked').length;
var span = groupEl.querySelector('.group-count');
if (span) span.textContent = '(' + checked + '/' + total + ')';
}
document.getElementById('role-perm-search').addEventListener('input', function() {
var q = this.value.toLowerCase();
document.querySelectorAll('.role-perm-item').forEach(function(item) {
var key = (item.getAttribute('data-key') || '').toLowerCase();
var label = (item.getAttribute('data-label') || '').toLowerCase();
item.style.display = (!q || key.indexOf(q) >= 0 || label.indexOf(q) >= 0) ? '' : 'none';
});
document.querySelectorAll('.role-perm-group').forEach(function(group) {
var hasVisible = false;
group.querySelectorAll('.role-perm-item').forEach(function(item) {
if (item.style.display !== 'none') hasVisible = true;
});
group.style.display = hasVisible ? '' : 'none';
});
});
</script>
......@@ -14,14 +14,18 @@ MenuRegistry::register('users_roles', [
'children' => [
['label_ar' => 'الموظفون', 'label_en' => 'Employees', 'route' => '/users', 'permission' => 'user.view', 'order' => 1],
['label_ar' => 'إضافة موظف', 'label_en' => 'Add Employee', 'route' => '/users/create', 'permission' => 'user.create', 'order' => 2],
['label_ar' => 'الأدوار', 'label_en' => 'Roles', 'route' => '/roles', 'permission' => 'user.assign_role', 'order' => 3],
['label_ar' => 'الأدوار', 'label_en' => 'Roles', 'route' => '/roles', 'permission' => 'role.view', 'order' => 3],
],
]);
PermissionRegistry::register('users', [
'user.view' => ['ar' => 'عرض الموظفين', 'en' => 'View Employees'],
'user.create' => ['ar' => 'إنشاء موظف', 'en' => 'Create Employee'],
'user.edit' => ['ar' => 'تعديل موظف', 'en' => 'Edit Employee'],
'user.deactivate' => ['ar' => 'تعطيل موظف', 'en' => 'Deactivate Employee'],
'user.assign_role' => ['ar' => 'تعيين الأدوار', 'en' => 'Assign Roles'],
'user.view' => ['ar' => 'عرض الموظفين', 'en' => 'View Employees'],
'user.create' => ['ar' => 'إنشاء موظف', 'en' => 'Create Employee'],
'user.edit' => ['ar' => 'تعديل موظف', 'en' => 'Edit Employee'],
'user.deactivate' => ['ar' => 'تعطيل موظف', 'en' => 'Deactivate Employee'],
'user.assign_role' => ['ar' => 'تعيين الأدوار', 'en' => 'Assign Roles'],
'user.manage_permissions' => ['ar' => 'إدارة صلاحيات مباشرة', 'en' => 'Manage Direct Permissions'],
'role.view' => ['ar' => 'عرض الأدوار', 'en' => 'View Roles'],
'role.create' => ['ar' => 'إنشاء دور', 'en' => 'Create Role'],
'role.edit' => ['ar' => 'تعديل دور', 'en' => 'Edit Role'],
]);
\ No newline at end of file
......@@ -12,6 +12,7 @@ use App\Modules\Transfers\Models\TransferRequest;
use App\Modules\Transfers\Services\SeparationFeeCalculator;
use App\Modules\Transfers\Services\TransferProcessor;
use App\Modules\Payments\Services\PaymentService;
use App\Modules\Cashier\Services\PaymentRequestService;
class TransferController extends Controller
{
......@@ -122,34 +123,26 @@ class TransferController extends Controller
'type' => $transferType,
]);
// Collect payment inline
// Send payment to cashier queue
$amount = $feeCalc['total_fee'] ?? '0.00';
if (bccomp((string) $amount, '0', 2) > 0) {
$result = PaymentService::processPayment([
$result = PaymentRequestService::createRequest([
'member_id' => (int) $memberId,
'amount' => $amount,
'payment_type' => 'separation_fee',
'payment_method' => $paymentMethod,
'related_entity_type' => 'transfer_requests',
'related_entity_id' => (int) $transferReq->id,
'description' => 'رسوم تحويل/فصل — طلب #' . $transferReq->id,
'description_ar' => 'رسوم تحويل/فصل — طلب #' . $transferReq->id,
]);
if ($result['success']) {
$db->update('transfer_requests', [
'status' => 'fee_paid',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $transferReq->id]);
EventBus::dispatch('transfer.fee_paid', ['transfer_id' => (int) $transferReq->id, 'payment_id' => $result['payment_id']]);
return $this->redirect("/transfers/{$transferReq->id}")->withSuccess(
'تم تقديم طلب التحويل/الفصل وتحصيل الرسوم — الإجمالي: ' . money($amount) . ' — إيصال: ' . $result['receipt_number']
'تم تقديم طلب التحويل/الفصل وإرسال طلب الدفع للخزينة — الإجمالي: ' . money($amount) . ' — رقم الطلب: ' . $result['request_number']
);
}
return $this->redirect("/transfers/{$transferReq->id}")->withError(
'تم تقديم الطلب لكن فشل تحصيل الرسوم: ' . ($result['error'] ?? 'خطأ غير معروف')
'تم تقديم الطلب لكن فشل إنشاء طلب الدفع: ' . ($result['error'] ?? 'خطأ غير معروف')
);
}
......
......@@ -104,15 +104,18 @@ class UserController extends Controller
]);
$roleIds = $request->post('roles', []);
$roleExpires = $request->post('role_expires', []);
if (is_array($roleIds)) {
$db = App::getInstance()->db();
$currentEmp = App::getInstance()->currentEmployee();
foreach ($roleIds as $roleId) {
$expiresAt = $roleExpires[$roleId] ?? null;
$db->insert('employee_roles', [
'employee_id' => (int) $employee->id,
'role_id' => (int) $roleId,
'assigned_at' => date('Y-m-d H:i:s'),
'assigned_by' => $currentEmp ? (int) $currentEmp->id : null,
'expires_at' => ($expiresAt && $expiresAt !== '') ? $expiresAt : null,
'is_active' => 1,
]);
}
......@@ -153,14 +156,20 @@ class UserController extends Controller
}
$allRoles = Role::allActive();
$currentRoleIds = array_column($employee->getRolesWithNames(), 'id');
$employeeRoles = $employee->getRolesWithNames();
$currentRoleIds = array_column($employeeRoles, 'id');
$roleExpirations = [];
foreach ($employeeRoles as $er) {
$roleExpirations[(int) $er['id']] = $er['expires_at'] ?? '';
}
$branches = App::getInstance()->db()->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar");
return $this->view('Users.Views.edit', [
'employee' => $employee,
'allRoles' => $allRoles,
'currentRoleIds' => $currentRoleIds,
'branches' => $branches,
'employee' => $employee,
'allRoles' => $allRoles,
'currentRoleIds' => $currentRoleIds,
'roleExpirations' => $roleExpirations,
'branches' => $branches,
]);
}
......@@ -204,14 +213,17 @@ class UserController extends Controller
$db = App::getInstance()->db();
$db->delete('employee_roles', '`employee_id` = ?', [$employee->id]);
$roleIds = $request->post('roles', []);
$roleExpires = $request->post('role_expires', []);
if (is_array($roleIds)) {
$currentEmp = App::getInstance()->currentEmployee();
foreach ($roleIds as $roleId) {
$expiresAt = $roleExpires[$roleId] ?? null;
$db->insert('employee_roles', [
'employee_id' => (int) $employee->id,
'role_id' => (int) $roleId,
'assigned_at' => date('Y-m-d H:i:s'),
'assigned_by' => $currentEmp ? (int) $currentEmp->id : null,
'expires_at' => ($expiresAt && $expiresAt !== '') ? $expiresAt : null,
'is_active' => 1,
]);
}
......
<?php
declare(strict_types=1);
namespace App\Modules\Users\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\Registries\PermissionRegistry;
use App\Modules\Users\Models\Employee;
use App\Modules\Roles\Models\Role;
class UserPermissionController extends Controller
{
public function edit(Request $request, string $id): Response
{
$employee = Employee::find((int) $id);
if (!$employee) {
return $this->redirect('/users')->withError('الموظف غير موجود');
}
$allPermissions = PermissionRegistry::getAllGrouped();
$allRoles = Role::allActive();
$employeeRoles = $employee->getRolesWithNames();
$currentRoleIds = array_column($employeeRoles, 'id');
$roleExpirations = [];
foreach ($employeeRoles as $er) {
$roleExpirations[(int) $er['id']] = $er['expires_at'] ?? '';
}
$db = App::getInstance()->db();
$rolePermMap = [];
foreach ($employeeRoles as $er) {
$perms = $db->select(
"SELECT permission_key FROM role_permissions WHERE role_id = ?",
[(int) $er['id']]
);
foreach ($perms as $p) {
$rolePermMap[$p['permission_key']][] = $er['name_ar'];
}
}
$directPerms = $employee->getDirectPermissions();
$directMap = [];
foreach ($directPerms as $dp) {
$directMap[$dp['permission_key']] = (int) $dp['is_denied'] === 1 ? 'deny' : 'grant';
}
return $this->view('Users.Views.permissions', [
'employee' => $employee,
'allPermissions' => $allPermissions,
'allRoles' => $allRoles,
'currentRoleIds' => $currentRoleIds,
'roleExpirations' => $roleExpirations,
'rolePermMap' => $rolePermMap,
'directMap' => $directMap,
]);
}
public function update(Request $request, string $id): Response
{
$employee = Employee::find((int) $id);
if (!$employee) {
return $this->redirect('/users')->withError('الموظف غير موجود');
}
$db = App::getInstance()->db();
$currentEmp = App::getInstance()->currentEmployee();
$grantedBy = $currentEmp ? (int) $currentEmp->id : 0;
$db->delete('employee_roles', '`employee_id` = ?', [$employee->id]);
$roleIds = $request->post('roles', []);
$roleExpires = $request->post('role_expires', []);
if (is_array($roleIds)) {
foreach ($roleIds as $roleId) {
$expiresAt = $roleExpires[$roleId] ?? null;
$db->insert('employee_roles', [
'employee_id' => (int) $employee->id,
'role_id' => (int) $roleId,
'assigned_at' => date('Y-m-d H:i:s'),
'assigned_by' => $grantedBy,
'expires_at' => ($expiresAt && $expiresAt !== '') ? $expiresAt : null,
'is_active' => 1,
]);
}
}
$permStates = $request->post('perm_state', []);
$grants = [];
$denials = [];
if (is_array($permStates)) {
foreach ($permStates as $key => $state) {
if ($state === 'grant') {
$grants[] = $key;
} elseif ($state === 'deny') {
$denials[] = $key;
}
}
}
$employee->syncDirectPermissions($grants, $denials, $grantedBy);
return $this->redirect("/users/{$id}/permissions")->withSuccess('تم تحديث الصلاحيات بنجاح');
}
}
......@@ -22,6 +22,7 @@ class Employee extends Model
];
private ?array $cachedPermissions = null;
private ?array $cachedDenials = null;
private ?array $cachedRoles = null;
public static function findByUsername(string $username): ?static
......@@ -58,6 +59,29 @@ class Employee extends Model
$parentPerms = $this->getParentRolePermissions($roleIds);
$permissions = array_unique(array_merge($permissions, $parentPerms));
try {
$directRows = $db->select(
"SELECT permission_key, is_denied FROM employee_permissions WHERE employee_id = ?",
[$this->id]
);
$directGrants = [];
$directDenials = [];
foreach ($directRows as $row) {
if ((int) $row['is_denied'] === 1) {
$directDenials[] = $row['permission_key'];
} else {
$directGrants[] = $row['permission_key'];
}
}
$permissions = array_unique(array_merge($permissions, $directGrants));
$permissions = array_values(array_diff($permissions, $directDenials));
$this->cachedDenials = $directDenials;
} catch (\Throwable $e) {
$this->cachedDenials = [];
}
$this->cachedPermissions = $permissions;
return $this->cachedPermissions;
}
......@@ -116,6 +140,9 @@ class Employee extends Model
if (in_array('*', $permissions, true)) {
return true;
}
if (in_array($key, $this->cachedDenials ?? [], true)) {
return false;
}
return in_array($key, $permissions, true);
}
......@@ -224,4 +251,93 @@ class Employee extends Model
$branch = $db->selectOne("SELECT name_ar FROM branches WHERE id = ?", [$this->branch_id]);
return $branch['name_ar'] ?? '—';
}
public function getDirectPermissions(): array
{
$db = App::getInstance()->db();
try {
return $db->select(
"SELECT permission_key, is_denied, granted_at, granted_by, notes
FROM employee_permissions WHERE employee_id = ? ORDER BY permission_key",
[$this->id]
);
} catch (\Throwable $e) {
return [];
}
}
public function getEffectivePermissions(): array
{
$db = App::getInstance()->db();
$rolePerms = $db->select("
SELECT rp.permission_key, r.name_ar AS source
FROM employee_roles er
JOIN role_permissions rp ON rp.role_id = er.role_id
JOIN roles r ON r.id = er.role_id AND r.is_active = 1
WHERE er.employee_id = ? AND er.is_active = 1
AND (er.expires_at IS NULL OR er.expires_at > NOW())
", [$this->id]);
$result = [];
foreach ($rolePerms as $rp) {
$key = $rp['permission_key'];
if (!isset($result[$key])) {
$result[$key] = ['key' => $key, 'sources' => [], 'direct' => null];
}
$result[$key]['sources'][] = $rp['source'];
}
try {
$directPerms = $db->select(
"SELECT permission_key, is_denied, notes FROM employee_permissions WHERE employee_id = ?",
[$this->id]
);
foreach ($directPerms as $dp) {
$key = $dp['permission_key'];
if (!isset($result[$key])) {
$result[$key] = ['key' => $key, 'sources' => [], 'direct' => null];
}
$result[$key]['direct'] = (int) $dp['is_denied'] === 1 ? 'deny' : 'grant';
}
} catch (\Throwable $e) {
}
ksort($result);
return $result;
}
public function syncDirectPermissions(array $grants, array $denials, int $grantedBy): void
{
$db = App::getInstance()->db();
$db->delete('employee_permissions', '`employee_id` = ?', [$this->id]);
$now = date('Y-m-d H:i:s');
foreach ($grants as $key) {
$key = trim($key);
if ($key === '') continue;
$db->insert('employee_permissions', [
'employee_id' => (int) $this->id,
'permission_key' => $key,
'is_denied' => 0,
'granted_at' => $now,
'granted_by' => $grantedBy,
]);
}
foreach ($denials as $key) {
$key = trim($key);
if ($key === '') continue;
$db->insert('employee_permissions', [
'employee_id' => (int) $this->id,
'permission_key' => $key,
'is_denied' => 1,
'granted_at' => $now,
'granted_by' => $grantedBy,
]);
}
$this->cachedPermissions = null;
$this->cachedDenials = null;
}
}
\ No newline at end of file
......@@ -8,5 +8,7 @@ return [
['GET', '/users/{id:\d+}', 'Users\Controllers\UserController@show', ['auth'], 'user.view'],
['GET', '/users/{id:\d+}/edit', 'Users\Controllers\UserController@edit', ['auth'], 'user.edit'],
['POST', '/users/{id:\d+}', 'Users\Controllers\UserController@update', ['auth', 'csrf'], 'user.edit'],
['GET', '/users/{id:\d+}/activity', 'Users\Controllers\UserController@activity', ['auth'], 'user.view'],
['GET', '/users/{id:\d+}/activity', 'Users\Controllers\UserController@activity', ['auth'], 'user.view'],
['GET', '/users/{id:\d+}/permissions', 'Users\Controllers\UserPermissionController@edit', ['auth'], 'user.manage_permissions'],
['POST', '/users/{id:\d+}/permissions', 'Users\Controllers\UserPermissionController@update', ['auth', 'csrf'], 'user.manage_permissions'],
];
\ No newline at end of file
......@@ -48,14 +48,22 @@
</div>
</div>
<div class="card" style="padding:20px;margin-bottom:20px;">
<h3 style="margin-bottom:15px;">الأدوار</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(200px, 1fr));gap:10px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:15px;">
<h3 style="margin:0;">الأدوار</h3>
<a href="/users/<?= (int) $employee->id ?>/permissions" class="btn btn-outline" style="font-size:13px;">إدارة الصلاحيات التفصيلية &larr;</a>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(320px, 1fr));gap:10px;">
<?php foreach ($allRoles as $role): ?>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
<?php $checked = in_array($role['id'], $currentRoleIds); ?>
<div style="display:flex;align-items:center;gap:10px;padding:6px 10px;border:1px solid <?= $checked ? '#059669' : '#E5E7EB' ?>;border-radius:6px;">
<input type="checkbox" name="roles[]" value="<?= (int) $role['id'] ?>"
<?= in_array($role['id'], $currentRoleIds) ? 'checked' : '' ?>>
<span><?= e($role['name_ar']) ?></span>
</label>
<?= $checked ? 'checked' : '' ?> id="erole_<?= (int) $role['id'] ?>">
<label for="erole_<?= (int) $role['id'] ?>" style="cursor:pointer;flex:1;font-size:14px;"><?= e($role['name_ar']) ?></label>
<input type="date" name="role_expires[<?= (int) $role['id'] ?>]"
value="<?= e(isset($roleExpirations[(int) $role['id']]) ? substr($roleExpirations[(int) $role['id']], 0, 10) : '') ?>"
style="width:130px;font-size:12px;padding:3px 5px;border:1px solid #D1D5DB;border-radius:4px;"
title="انتهاء (اختياري)">
</div>
<?php endforeach; ?>
</div>
</div>
......
This diff is collapsed.
......@@ -2,6 +2,7 @@
<?php $__template->section('title'); ?>الموظف: <?= e($employee->full_name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/users/<?= (int) $employee->id ?>/edit" class="btn btn-outline">تعديل</a>
<a href="/users/<?= (int) $employee->id ?>/permissions" class="btn btn-outline">الصلاحيات</a>
<a href="/users/<?= (int) $employee->id ?>/activity" class="btn btn-outline">سجل النشاط</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
......@@ -35,6 +36,39 @@
<?php endforeach; ?>
<?php endif; ?>
</div>
<div class="card" style="padding:20px;margin-bottom:20px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:15px;">
<h3 style="margin:0;color:#0D7377;">الصلاحيات الفعّالة</h3>
<a href="/users/<?= (int) $employee->id ?>/permissions" style="font-size:12px;color:#0D7377;">إدارة &larr;</a>
</div>
<?php
$allPerms = $employee->getAllPermissions();
$hasStar = in_array('*', $allPerms, true);
$permsByGroup = [];
$allGrouped = \App\Core\Registries\PermissionRegistry::getAllGrouped();
foreach ($allGrouped as $gName => $gPerms) {
$cnt = 0;
foreach ($gPerms as $pk => $_) {
if ($hasStar || in_array($pk, $allPerms, true)) $cnt++;
}
if ($cnt > 0) $permsByGroup[$gName] = $cnt;
}
?>
<div style="text-align:center;padding:10px 0;margin-bottom:10px;">
<span style="font-size:28px;font-weight:700;color:#059669;"><?= $hasStar ? '∞' : count($allPerms) ?></span>
<span style="color:#6B7280;font-size:13px;display:block;">إجمالي الصلاحيات</span>
</div>
<?php if (!empty($permsByGroup)): ?>
<div style="font-size:13px;">
<?php foreach ($permsByGroup as $gn => $gc): ?>
<div style="display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px solid #F3F4F6;">
<span style="color:#6B7280;"><?= e($gn) ?></span>
<span style="font-weight:600;"><?= $gc ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<div class="card" style="padding:20px;">
<h3 style="margin-bottom:15px;color:#0D7377;">الجلسات النشطة</h3>
<?php if (empty($sessions)): ?>
......
......@@ -12,6 +12,7 @@ use App\Modules\Waiver\Models\WaiverRequest;
use App\Modules\Waiver\Services\WaiverProcessor;
use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Payments\Services\PaymentService;
use App\Modules\Cashier\Services\PaymentRequestService;
class WaiverController extends Controller
{
......@@ -79,33 +80,25 @@ class WaiverController extends Controller
EventBus::dispatch('waiver.requested', ['waiver_id' => (int) $waiver->id, 'member_id' => (int) $memberId]);
// Collect payment inline
// Send payment to cashier queue
if (bccomp($waiverFee, '0', 2) > 0) {
$result = PaymentService::processPayment([
$result = PaymentRequestService::createRequest([
'member_id' => (int) $memberId,
'amount' => $waiverFee,
'payment_type' => 'waiver_fee',
'payment_method' => $paymentMethod,
'related_entity_type' => 'waiver_requests',
'related_entity_id' => (int) $waiver->id,
'description' => 'رسوم تنازل — طلب #' . $waiver->id,
'description_ar' => 'رسوم تنازل — طلب #' . $waiver->id,
]);
if ($result['success']) {
$db->update('waiver_requests', [
'status' => 'fee_paid',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $waiver->id]);
EventBus::dispatch('waiver.fee_paid', ['waiver_id' => (int) $waiver->id, 'payment_id' => $result['payment_id']]);
return $this->redirect("/waivers/{$waiver->id}")->withSuccess(
'تم تقديم طلب التنازل وتحصيل الرسوم — ' . money($waiverFee) . ' (' . $waiverPct . '%) — إيصال: ' . $result['receipt_number']
'تم تقديم طلب التنازل وإرسال طلب الدفع للخزينة — ' . money($waiverFee) . ' (' . $waiverPct . '%) — رقم الطلب: ' . $result['request_number']
);
}
return $this->redirect("/waivers/{$waiver->id}")->withError(
'تم تقديم الطلب لكن فشل تحصيل الرسوم: ' . ($result['error'] ?? 'خطأ غير معروف')
'تم تقديم الطلب لكن فشل إنشاء طلب الدفع: ' . ($result['error'] ?? 'خطأ غير معروف')
);
}
......
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `employee_permissions` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`employee_id` BIGINT UNSIGNED NOT NULL,
`permission_key` VARCHAR(100) NOT NULL,
`is_denied` TINYINT(1) NOT NULL DEFAULT 0,
`granted_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`granted_by` BIGINT UNSIGNED NULL,
`notes` VARCHAR(500) NULL,
UNIQUE KEY `uq_emp_perm` (`employee_id`, `permission_key`),
INDEX `idx_emp_perms_key` (`permission_key`),
CONSTRAINT `fk_emp_perms_emp` FOREIGN KEY (`employee_id`)
REFERENCES `employees`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `employee_permissions`",
];
<?php
declare(strict_types=1);
return [
'up' => "
INSERT IGNORE INTO role_permissions (role_id, permission_key, granted_at)
SELECT role_id, 'role.view', NOW() FROM role_permissions WHERE permission_key = 'user.assign_role';
INSERT IGNORE INTO role_permissions (role_id, permission_key, granted_at)
SELECT role_id, 'role.create', NOW() FROM role_permissions WHERE permission_key = 'user.assign_role';
INSERT IGNORE INTO role_permissions (role_id, permission_key, granted_at)
SELECT role_id, 'role.edit', NOW() FROM role_permissions WHERE permission_key = 'user.assign_role';
INSERT IGNORE INTO role_permissions (role_id, permission_key, granted_at)
SELECT role_id, 'user.manage_permissions', NOW() FROM role_permissions WHERE permission_key = 'user.assign_role';
INSERT IGNORE INTO role_permissions (role_id, permission_key, granted_at)
SELECT role_id, 'member.pay_form_fee', NOW() FROM role_permissions WHERE permission_key = 'member.edit';
INSERT IGNORE INTO role_permissions (role_id, permission_key, granted_at)
SELECT role_id, 'member.pay_membership', NOW() FROM role_permissions WHERE permission_key = 'member.edit';
INSERT IGNORE INTO role_permissions (role_id, permission_key, granted_at)
SELECT role_id, 'member.fill_form', NOW() FROM role_permissions WHERE permission_key = 'member.edit';
",
'down' => "
DELETE FROM role_permissions WHERE permission_key IN (
'role.view', 'role.create', 'role.edit', 'user.manage_permissions',
'member.pay_form_fee', 'member.pay_membership', 'member.fill_form'
)
",
];
<?php
declare(strict_types=1);
return [
'up' => "
INSERT IGNORE INTO role_permissions (role_id, permission_key, granted_at)
SELECT rp.role_id, CONCAT('forms.access.', fs.form_code), NOW()
FROM role_permissions rp
CROSS JOIN form_schemas fs
WHERE rp.permission_key = 'forms.view'
AND fs.is_active = 1
",
'down' => "
DELETE FROM role_permissions WHERE permission_key LIKE 'forms.access.%'
",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `payment_requests` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`request_number` VARCHAR(50) NOT NULL UNIQUE,
`member_id` BIGINT UNSIGNED NOT NULL,
`payment_type` VARCHAR(50) NOT NULL,
`amount` DECIMAL(15,2) NOT NULL,
`currency` VARCHAR(3) NOT NULL DEFAULT 'EGP',
`description_ar` TEXT NULL,
`related_entity_type` VARCHAR(100) NULL,
`related_entity_id` BIGINT UNSIGNED NULL,
`status` VARCHAR(30) NOT NULL DEFAULT 'pending',
`payment_id` BIGINT UNSIGNED NULL,
`receipt_number` VARCHAR(50) NULL,
`requested_by` BIGINT UNSIGNED NOT NULL,
`processed_by` BIGINT UNSIGNED NULL,
`processed_at` TIMESTAMP NULL DEFAULT NULL,
`branch_id` BIGINT UNSIGNED NULL,
`notes` TEXT NULL,
`is_voided` TINYINT(1) NOT NULL DEFAULT 0,
`voided_at` TIMESTAMP NULL DEFAULT NULL,
`voided_by` BIGINT UNSIGNED NULL,
`void_reason` TEXT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_pr_status` (`status`),
INDEX `idx_pr_member` (`member_id`),
INDEX `idx_pr_branch` (`branch_id`),
INDEX `idx_pr_requested_by` (`requested_by`),
INDEX `idx_pr_payment_type` (`payment_type`),
INDEX `idx_pr_related` (`related_entity_type`, `related_entity_id`),
CONSTRAINT `fk_pr_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `payment_requests`",
];
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$permissions = [
['cashier.view_queue', 'عرض طابور الخزينة', 'View Cashier Queue'],
['cashier.process_payment', 'معالجة طلب دفع', 'Process Payment Request'],
['cashier.cancel_request', 'إلغاء طلب دفع', 'Cancel Payment Request'],
];
foreach ($permissions as [$key, $ar, $en]) {
$exists = $db->selectOne("SELECT 1 FROM role_permissions WHERE permission_key = ? LIMIT 1", [$key]);
if ($exists) continue;
$superAdmin = $db->selectOne("SELECT id FROM roles WHERE role_code = 'super_admin' AND is_active = 1 LIMIT 1");
if ($superAdmin) {
$db->insert('role_permissions', [
'role_id' => (int) $superAdmin['id'],
'permission_key' => $key,
'granted_at' => date('Y-m-d H:i:s'),
]);
}
}
echo " Cashier permissions seeded.\n";
};
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