Commit d3f2e87e authored by DevPilot's avatar DevPilot

fix(facility-grids, members): cross-grid delete auth gap, blank names, wrong...

fix(facility-grids, members): cross-grid delete auth gap, blank names, wrong permissions, dead crons, FY string mismatch

FacilityGrids:
- ZoneTraineeController::remove()/clearZone()/move() ما كانوش بيتأكدوا إن
  المتدرب/المنطقة فعلاً تابعين لنفس الشبكة في الـ URL — أي حد عنده
  facility_grid.manage على أي شبكة كان يقدر يمسح متدرب أو يفرغ منطقة تابعة
  لشبكة تانية تمامًا لو خمّن الـ ID. دلوقتي بيتأكدوا من الملكية الأول.
- FacilityAttendanceService::sendPaymentReminders() والـ view بتاع شاشة
  الحضور كانوا لسه بيقروا name_ar بعد ما الكويري اتصلح لـ full_name_ar —
  فكل تذكير دفع كان بيتبعت باسم فاضي، وشاشة تسجيل الحضور كانت بتطلع كل
  أسماء المدربين/المتدربين فاضية وبتحفظ entity_name_cache فاضي في القاعدة.
- MonthlyPlanController::index() كان بيتحقق من صلاحية مختلفة عن اللي مسجلة
  على الـ route بتاعه (manage بدل view) — يعني حتى لو عندك صلاحية العرض
  بس، تاخد 403 فور ما تدخل شاشة الخطط الشهرية.
- زرار "إنشاء شبكة" و"خطة جديدة" كانوا بيتحكموا بصلاحية facility.manage
  (بتاعة موديول Facilities المختلف) بدل facility_grid.manage بتاع
  الموديول نفسه.
- زرار "تصدير PDF" في لوحة المالية كان بيسمي نفسه غلط — الملف اللي بينزل
  فعليًا HTML مش PDF.

Members:
- AutoFreezeService::freezeTemporaryAtAgeLimit() كانت شرطه بيدور على أكواد
  فئات (sisters_under_25 إلخ) مش موجودة في القاعدة أصلًا — الفئات الحقيقية
  المخزنة هي sister/stepchild/orphan، فالكرون كان ميت فعليًا ومفيش شقيقة
  ولا ابن زوج ولا يتيم بيتقفل تلقائيًا بعد سن 25.
- حالة permanently_dropped (اللي بيكتبها كرون إسقاط الأعضاء) ما كانتش في
  قائمة تسميات الحالات — فكانت بتظهر بالإنجليزي الخام في أي شاشة تعرض حالة
  العضو. اتضافت هي وterminated اللي كانت ناقصة في نفس القائمة.
- MemberController::show() كان بيبني السنة المالية الحالية بصيغة فيها شرطة
  (2025-2026) بينما كل مكان تاني في الكود بيكتبها بشرطة مايلة (2025/2026)
  زي ما فعليًا متخزن في subscriptions — فكانت شاشة بروفايل أي عضو دايمًا
  بتقول إن مفيش اشتراك للسنة الحالية حتى لو مدفوع فعلًا.

Plus extreme-detail reference docs for both modules.
parent 00d84740
...@@ -14,7 +14,7 @@ class MonthlyPlanController extends Controller ...@@ -14,7 +14,7 @@ class MonthlyPlanController extends Controller
{ {
public function index(Request $request, string $gridId): Response public function index(Request $request, string $gridId): Response
{ {
$this->authorize('facility_grid.manage'); $this->authorize('facility_grid.view');
$grid = FacilityGrid::find((int) $gridId); $grid = FacilityGrid::find((int) $gridId);
if (!$grid) return $this->redirect('/facility-grids')->withError('الشبكة غير موجودة'); if (!$grid) return $this->redirect('/facility-grids')->withError('الشبكة غير موجودة');
......
...@@ -67,9 +67,20 @@ class ZoneTraineeController extends Controller ...@@ -67,9 +67,20 @@ class ZoneTraineeController extends Controller
public function remove(Request $request, string $gridId, string $traineeId): Response public function remove(Request $request, string $gridId, string $traineeId): Response
{ {
$this->authorize('facility_grid.manage'); $this->authorize('facility_grid.manage');
$gridId = (int) $gridId;
$traineeId = (int) $traineeId; $traineeId = (int) $traineeId;
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$trainee = $db->selectOne(
"SELECT fzt.id FROM facility_zone_trainees fzt
INNER JOIN facility_grid_zones fgz ON fgz.id = fzt.zone_id
WHERE fzt.id = ? AND fgz.grid_id = ?",
[$traineeId, $gridId]
);
if (!$trainee) {
return $this->json(['success' => false, 'message' => 'المتدرب غير موجود في هذه الشبكة']);
}
$db->delete('facility_zone_trainees', 'id = ?', [$traineeId]); $db->delete('facility_zone_trainees', 'id = ?', [$traineeId]);
return $this->json(['success' => true, 'message' => 'تم إزالة المتدرب']); return $this->json(['success' => true, 'message' => 'تم إزالة المتدرب']);
} }
...@@ -86,6 +97,16 @@ class ZoneTraineeController extends Controller ...@@ -86,6 +97,16 @@ class ZoneTraineeController extends Controller
return $this->json(['success' => false, 'message' => 'لم يتم تحديد المنطقة']); return $this->json(['success' => false, 'message' => 'لم يتم تحديد المنطقة']);
} }
$currentTrainee = $db->selectOne(
"SELECT fzt.id FROM facility_zone_trainees fzt
INNER JOIN facility_grid_zones fgz ON fgz.id = fzt.zone_id
WHERE fzt.id = ? AND fgz.grid_id = ?",
[$traineeId, $gridId]
);
if (!$currentTrainee) {
return $this->json(['success' => false, 'message' => 'المتدرب غير موجود في هذه الشبكة']);
}
$targetZone = $db->selectOne( $targetZone = $db->selectOne(
"SELECT * FROM facility_grid_zones WHERE id = ? AND grid_id = ?", "SELECT * FROM facility_grid_zones WHERE id = ? AND grid_id = ?",
[$targetZoneId, $gridId] [$targetZoneId, $gridId]
...@@ -109,9 +130,15 @@ class ZoneTraineeController extends Controller ...@@ -109,9 +130,15 @@ class ZoneTraineeController extends Controller
public function clearZone(Request $request, string $gridId, string $zoneId): Response public function clearZone(Request $request, string $gridId, string $zoneId): Response
{ {
$this->authorize('facility_grid.manage'); $this->authorize('facility_grid.manage');
$gridId = (int) $gridId;
$zoneId = (int) $zoneId; $zoneId = (int) $zoneId;
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$zone = $db->selectOne("SELECT id FROM facility_grid_zones WHERE id = ? AND grid_id = ?", [$zoneId, $gridId]);
if (!$zone) {
return $this->json(['success' => false, 'message' => 'المنطقة غير موجودة']);
}
$db->delete('facility_zone_trainees', 'zone_id = ?', [$zoneId]); $db->delete('facility_zone_trainees', 'zone_id = ?', [$zoneId]);
return $this->json(['success' => true, 'message' => 'تم إفراغ المنطقة']); return $this->json(['success' => true, 'message' => 'تم إفراغ المنطقة']);
} }
......
...@@ -111,7 +111,7 @@ final class FacilityAttendanceService ...@@ -111,7 +111,7 @@ final class FacilityAttendanceService
foreach ($trainees as $trainee) { foreach ($trainees as $trainee) {
EventBus::dispatch('facility.payment_reminder', [ EventBus::dispatch('facility.payment_reminder', [
'player_id' => (int) $trainee['player_id'], 'player_id' => (int) $trainee['player_id'],
'player_name' => $trainee['name_ar'], 'player_name' => $trainee['full_name_ar'],
'phone' => $trainee['phone'], 'phone' => $trainee['phone'],
'grid_id' => $gridId, 'grid_id' => $gridId,
]); ]);
......
...@@ -45,11 +45,11 @@ ...@@ -45,11 +45,11 @@
<tbody> <tbody>
<?php foreach ($coaches as $i => $coach): ?> <?php foreach ($coaches as $i => $coach): ?>
<tr> <tr>
<td><?= e($coach['name_ar']) ?></td> <td><?= e($coach['full_name_ar']) ?></td>
<td> <td>
<input type="hidden" name="attendance[<?= $i ?>][entity_type]" value="coach"> <input type="hidden" name="attendance[<?= $i ?>][entity_type]" value="coach">
<input type="hidden" name="attendance[<?= $i ?>][entity_id]" value="<?= $coach['coach_id'] ?>"> <input type="hidden" name="attendance[<?= $i ?>][entity_id]" value="<?= $coach['coach_id'] ?>">
<input type="hidden" name="attendance[<?= $i ?>][entity_name]" value="<?= e($coach['name_ar']) ?>"> <input type="hidden" name="attendance[<?= $i ?>][entity_name]" value="<?= e($coach['full_name_ar']) ?>">
<select name="attendance[<?= $i ?>][status]" class="form-select form-select-sm"> <select name="attendance[<?= $i ?>][status]" class="form-select form-select-sm">
<option value="present">حاضر</option> <option value="present">حاضر</option>
<option value="absent">غائب</option> <option value="absent">غائب</option>
...@@ -75,11 +75,11 @@ ...@@ -75,11 +75,11 @@
<tbody> <tbody>
<?php $offset = count($coaches); foreach ($trainees as $j => $trainee): ?> <?php $offset = count($coaches); foreach ($trainees as $j => $trainee): ?>
<tr> <tr>
<td><?= e($trainee['name_ar']) ?></td> <td><?= e($trainee['full_name_ar']) ?></td>
<td> <td>
<input type="hidden" name="attendance[<?= $offset + $j ?>][entity_type]" value="trainee"> <input type="hidden" name="attendance[<?= $offset + $j ?>][entity_type]" value="trainee">
<input type="hidden" name="attendance[<?= $offset + $j ?>][entity_id]" value="<?= $trainee['player_id'] ?>"> <input type="hidden" name="attendance[<?= $offset + $j ?>][entity_id]" value="<?= $trainee['player_id'] ?>">
<input type="hidden" name="attendance[<?= $offset + $j ?>][entity_name]" value="<?= e($trainee['name_ar']) ?>"> <input type="hidden" name="attendance[<?= $offset + $j ?>][entity_name]" value="<?= e($trainee['full_name_ar']) ?>">
<select name="attendance[<?= $offset + $j ?>][status]" class="form-select form-select-sm"> <select name="attendance[<?= $offset + $j ?>][status]" class="form-select form-select-sm">
<option value="present">حاضر</option> <option value="present">حاضر</option>
<option value="absent">غائب</option> <option value="absent">غائب</option>
......
...@@ -4,7 +4,7 @@ $__template->layout('Layout.main'); ...@@ -4,7 +4,7 @@ $__template->layout('Layout.main');
<?php $__template->section('title'); ?>شبكات المرافق<?php $__template->endSection(); ?> <?php $__template->section('title'); ?>شبكات المرافق<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?> <?php $__template->section('page_actions'); ?>
<?php if (can('facility.manage')): ?> <?php if (can('facility_grid.manage')): ?>
<a href="/facility-grids/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إنشاء شبكة</a> <a href="/facility-grids/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إنشاء شبكة</a>
<?php endif; ?> <?php endif; ?>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
...@@ -15,7 +15,7 @@ $__template->layout('Layout.main'); ...@@ -15,7 +15,7 @@ $__template->layout('Layout.main');
<div class="card" style="padding:60px;text-align:center;"> <div class="card" style="padding:60px;text-align:center;">
<i data-lucide="grid-3x3" style="width:48px;height:48px;color:#9CA3AF;margin-bottom:16px;"></i> <i data-lucide="grid-3x3" style="width:48px;height:48px;color:#9CA3AF;margin-bottom:16px;"></i>
<p style="font-size:16px;color:#6B7280;margin-bottom:16px;">لا توجد شبكات مرافق بعد</p> <p style="font-size:16px;color:#6B7280;margin-bottom:16px;">لا توجد شبكات مرافق بعد</p>
<?php if (can('facility.manage')): ?> <?php if (can('facility_grid.manage')): ?>
<a href="/facility-grids/create" class="btn btn-primary">إنشاء أول شبكة</a> <a href="/facility-grids/create" class="btn btn-primary">إنشاء أول شبكة</a>
<?php endif; ?> <?php endif; ?>
</div> </div>
......
<?php $__template->layout('Layout.main'); ?> <?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الخطط الشهرية — <?= e($grid->name_ar) ?><?php $__template->endSection(); ?> <?php $__template->section('title'); ?>الخطط الشهرية — <?= e($grid->name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?> <?php $__template->section('page_actions'); ?>
<?php if (can('facility.manage')): ?> <?php if (can('facility_grid.manage')): ?>
<a href="/facility-grids/<?= (int) $grid->id ?>/plans/create" class="btn btn-primary">+ خطة جديدة</a> <a href="/facility-grids/<?= (int) $grid->id ?>/plans/create" class="btn btn-primary">+ خطة جديدة</a>
<?php endif; ?> <?php endif; ?>
<a href="/facility-grids/<?= (int) $grid->id ?>" class="btn btn-outline">العودة للشبكة</a> <a href="/facility-grids/<?= (int) $grid->id ?>" class="btn btn-outline">العودة للشبكة</a>
......
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h4>لوحة التحكم المالية - حمام السباحة</h4> <h4>لوحة التحكم المالية - حمام السباحة</h4>
<a href="/facility-grids/<?= $grid_id ?>/financial-dashboard/export?filter=<?= e($filter) ?>&from=<?= e($data['period']['from'] ?? '') ?>&to=<?= e($data['period']['to'] ?? '') ?>" <a href="/facility-grids/<?= $grid_id ?>/financial-dashboard/export?filter=<?= e($filter) ?>&from=<?= e($data['period']['from'] ?? '') ?>&to=<?= e($data['period']['to'] ?? '') ?>"
class="btn btn-outline-success" target="_blank">تصدير PDF</a> class="btn btn-outline-success" target="_blank">تصدير التقرير</a>
</div> </div>
<!-- Filter --> <!-- Filter -->
......
...@@ -317,7 +317,7 @@ class MemberController extends Controller ...@@ -317,7 +317,7 @@ class MemberController extends Controller
// Subscription status for current FY // Subscription status for current FY
$currentMonth = (int) date('n'); $currentMonth = (int) date('n');
$currentYear = (int) date('Y'); $currentYear = (int) date('Y');
$currentFY = $currentMonth >= 7 ? "$currentYear-" . ($currentYear + 1) : ($currentYear - 1) . "-$currentYear"; $currentFY = $currentMonth >= 7 ? "$currentYear/" . ($currentYear + 1) : ($currentYear - 1) . "/$currentYear";
$subscriptionStatus = $db->selectOne( $subscriptionStatus = $db->selectOne(
"SELECT id, financial_year, status, total_amount, paid_amount, fine_amount "SELECT id, financial_year, status, total_amount, paid_amount, fine_amount
FROM subscriptions WHERE member_id = ? AND financial_year = ? LIMIT 1", FROM subscriptions WHERE member_id = ? AND financial_year = ? LIMIT 1",
......
...@@ -65,6 +65,10 @@ class Member extends Model ...@@ -65,6 +65,10 @@ class Member extends Model
'dropped' => 'مسقط', 'dropped' => 'مسقط',
'expired' => 'منتهي', 'expired' => 'منتهي',
'terminated' => 'منتهي بقرار', 'terminated' => 'منتهي بقرار',
'deceased' => 'متوفى',
'transferred' => 'منقولة',
'waived' => 'متنازل عنها',
'permanently_dropped'=> 'مسقط نهائياً',
]; ];
return $labels[$this->status] ?? $this->status; return $labels[$this->status] ?? $this->status;
} }
...@@ -121,9 +125,11 @@ class Member extends Model ...@@ -121,9 +125,11 @@ class Member extends Model
public static function getAllStatusLabels(): array public static function getAllStatusLabels(): array
{ {
return self::getStatusOptions() + [ return self::getStatusOptions() + [
'deceased' => 'متوفى', 'deceased' => 'متوفى',
'transferred' => 'منقولة', 'transferred' => 'منقولة',
'waived' => 'متنازل عنها', 'waived' => 'متنازل عنها',
'terminated' => 'منتهي بقرار',
'permanently_dropped' => 'مسقط نهائياً',
]; ];
} }
......
...@@ -166,7 +166,7 @@ final class AutoFreezeService ...@@ -166,7 +166,7 @@ final class AutoFreezeService
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
$frozenCount = 0; $frozenCount = 0;
$ageLimitCategories = ['sisters_under_25', 'stepchildren_under_25', 'orphan_sponsored']; $ageLimitCategories = ['sister', 'stepchild', 'orphan'];
$temps = $db->select( $temps = $db->select(
"SELECT id, date_of_birth, category FROM temporary_members "SELECT id, date_of_birth, category FROM temporary_members
......
# FacilityGrids Module
40 routes, 2 permissions (`facility_grid.view`, `facility_grid.manage`). This module owns a legacy row×column physical grid ("**المرايا** / Mirror") for assigning coaches/academies and individual trainees onto lanes/courts by hand — it is a **separate, older system from SportsActivity's Mirror and Pool Grid**, not a shared one.
## Read this first: this is not SportsActivity's Mirror
SportsActivity has its own, newer drag-and-drop Mirror (`/sa/mirror/*`, scheduling `sa_groups` onto `sa_facilities`) and its own Pool Grid template system (`/sa/pool-grid/*`, `sa_pool_zone_templates`). Both are entirely separate modules with entirely separate tables (`facilities`/`facility_grids`/`facility_grid_zones`/`facility_zone_schedules`/`facility_zone_trainees` here vs. `sa_facilities`/`sa_groups`/`sa_pool_zone_bookings` there — unrelated ID spaces, created in different migration phases). **This module has zero references anywhere to any SportsActivity table.** Practical consequence: a club running both systems against the same physical pool has **no cross-system double-booking protection whatsoever** — this module's conflict checker only ever looks at its own `facility_zone_schedules`, and SportsActivity's never looks at this module's tables. This is an architectural gap worth knowing about, not a line-level bug.
## The Mirror grid (`/facility-grids/{id}`)
Assigning a coach/academy session is **click-to-select, not drag-and-drop**: pick a selection mode (cells/row/column/all), click cells to build a selection, then "تعيين المحدد" opens a modal for a free-text `schedule_name` (doubles as the coach/academy name), activity type, age group, gender, day of week, color. Start/end time are forced to exactly the one-hour block of whichever hour-tab is open — **the Mirror cannot create sessions shorter or longer than exactly one hour.**
The modal has **no `coach_id`/`academy_id` field at all** — only a name string. Every schedule created from the live screen therefore has `coach_id = NULL`/`academy_id = NULL`, which means the coach/academy existence-and-active-status validation written into `ZoneScheduleController::store()` is dead code from this screen's perspective — it only ever runs against nulls. A separate, fully-built alternative Mirror view (`Views/grid_interactive.php`, with real coach/academy pickers, mouse-drag rectangular selection, and a richer modal) exists in the codebase but is wired to **zero routes** — an orphaned, superseded screen.
Moving an already-assigned trainee between zones **is** real HTML5 drag-and-drop.
Conflict checking (`GridStateService::checkConflicts()`) resolves each schedule's `zone_selection_json` into concrete grid cells and does a manual overlap check in PHP — it's invoked on both create and update, and also exposed as a standalone pre-check endpoint that the shipped UI never actually calls (dead endpoint, not wired to any JS).
### Fixed this session — cross-grid authorization gap on trainee actions
`ZoneTraineeController::remove()` and `::clearZone()` never checked that the trainee/zone being deleted actually belonged to the grid ID in the URL — `remove()` ran a bare `DELETE ... WHERE id = ?`, and `clearZone()` a bare `DELETE ... WHERE zone_id = ?`, with no ownership check at all. Anyone holding `facility_grid.manage` on *any* grid could delete a trainee record or wipe an entire zone belonging to a *completely different* grid just by guessing/enumerating IDs. `move()` had a matching gap in the other direction: it validated the *target* zone belonged to the grid in the URL, but never checked that the trainee being moved currently belonged to that grid at all — a trainee from Grid A could be pulled into Grid B from Grid B's own page. All three now verify the trainee/zone actually belongs to the grid in the URL before acting.
Since `facility_zone_trainees` has no soft-delete column, these were (and remain) true SQL `DELETE`s with no audit trail of who removed a trainee or when — worth knowing if a trainee disappears with no record of why.
## Facility Attendance
Checklist-style attendance for coaches/trainees scheduled on a grid, `POST`ed as a raw `$_POST` array with no server-side validation of `entity_type`/`status` beyond what the `<select>` in the view offers — a hand-crafted POST could write an arbitrary status string. No unique DB constraint backs the upsert-by-`(grid_id, date, entity_type, entity_id, hour_slot)` logic, so a double-submit (two tabs, a slow double-click) can create duplicate rows that would double-count in the report's aggregates.
`remind()` fires a payment-reminder event for **every** trainee ever assigned to any zone on the grid — despite the name, it does not check whether they actually owe payment.
### Fixed this session — reminder/checklist showed blank names everywhere
An earlier column-name fix (`name_ar``full_name_ar`) was applied to the controller's SQL but missed two other places reading the same result rows: `FacilityAttendanceService::sendPaymentReminders()` still read `$trainee['name_ar']` (always null against a row that only has `full_name_ar`), so **every `facility.payment_reminder` event shipped with `player_name = null`**, and `Views/facility_attendance.php` (4 occurrences) still read `$coach['name_ar']`/`$trainee['name_ar']`, so the attendance checklist rendered **blank names for every coach and trainee row**, and every attendance record saved through that screen persisted `entity_name_cache = ''` (shown as "—" in the report). Both are now aligned to `full_name_ar`.
## Monthly Plans
A per-grid month container (draft → active → archived, day/night working-hour config) that can be cloned from a prior month — cloning copies the source's active schedules into the new month, forcing their `effective_from`/`effective_to` to bound exactly the new calendar month.
**Confirmed structural gap, not fixed**`facility_zone_schedules.plan_id` is not in `FacilityZoneSchedule::$fillable`, and the only reachable schedule-creation path (the Mirror modal, via `ZoneScheduleController::store()`) never sets it at all. Only `clonePlan()`'s raw SQL insert ever populates `plan_id`. Consequence: a Monthly Plan created from scratch (not cloned) will show **zero schedules forever** on its own "show"/"diff" page, even after staff fill the grid with real sessions through the Mirror — because those sessions are never tagged with that plan's ID. This needs a deliberate design decision (what should tag a schedule with the currently-active plan?) rather than a blind fix, since normally-created schedules also default to open-ended (`effective_to = NULL`), meaning the Monthly Plan layer is largely decorative metadata sitting on top of a grid whose actual displayed content is driven purely by date/day-of-week, independent of which plan is "active." Activating a plan does not touch `facility_zone_schedules` at all.
The "diff" between a cloned plan and its source is a net count delta (added/removed totals), not a real per-schedule comparison, despite the name.
### Fixed this session — two permission mismatches
`MonthlyPlanController::index()` called `$this->authorize('facility_grid.manage')` while its own route was registered under `facility_grid.view` — a staff member holding only view access would pass the route middleware and then get a hard 403 the instant the controller ran, making the "الخطط الشهرية" list unreachable for view-only users despite the route table promising otherwise. Now it authorizes against `facility_grid.view`, matching the route.
Separately, the "Create Grid" button (`Views/index.php`, 2 places) and the "New Plan" button (`Views/monthly_plans.php`) were gated on `can('facility.manage')` — a different, real permission belonging to the unrelated `Facilities` module — instead of this module's own `facility_grid.manage`. A role granted `facility_grid.manage` never saw its own create buttons; a role granted the unrelated `facility.manage` saw the buttons and then got 403'd on submit. All three now check `facility_grid.manage`.
## Pool Hour Plan
A per-hour snapshot-freeze feature (`pool_hour_plans.grid_snapshot_json`) meant to let staff freeze/print "what the grid looked like" for one hour of one date. **Confirmed dead feature, not fixed** — the day-view screen's "تعديل الشبكة" button links straight to `PoolHourPlanController::showHour()`, which is a JSON-only API endpoint with no HTML page or client-side script anywhere in the codebase to consume it. The backend (save/copy an hour's snapshot) works; there is currently no way to reach it through the shipped UI at all.
## Pool Financial Dashboard
Revenue is read from the **legacy** `reservations`/`free_time_entries` tables, filtered by `facility_id`**not** `grid_id`, and not aware of SportsActivity's `sa_pool_reservations` at all. Two consequences worth knowing: if two `facility_grids` rows happen to share one underlying `facility_id`, both grids' dashboards show identical, double-visible revenue with no way to attribute it to one or the other; and if a club's actual pool commerce runs through SportsActivity's booking wizard rather than this legacy `reservations` flow, this dashboard will under-report or show zero revenue despite real bookings existing.
The "تصدير PDF" button — **fixed this session** — actually downloaded a plain `.html` file (no PDF library is invoked anywhere in the export method); relabeled to "تصدير التقرير" so it no longer promises a format it doesn't produce.
## Legacy URL redirects
Four old routes (`/mirror`, `/mirror/{id}`, `/pool/{id}/grid`, `/pool/{id}/schedules`) 302 to their `facility-grids` equivalents, kept only so old bookmarks/links still work after this module unified what used to be separate Mirror/Pool screens. Unrelated to SportsActivity's current `/sa/mirror/*` routes.
This diff is collapsed.
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