Commit d760eb10 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(settlements): make the worklist teach the case it is showing

The screen listed what was wrong with an account and stopped there. The person
reading it is a receptionist mid-shift, not an accountant with time to work out
what "مستلزم محاسَب بأكثر من سعره" means or which of nine tools fixes it — and a
case nobody understands is a case nobody touches, which is money.

So the catalogue now carries the explanation with the label: what the case
means, why it happens, the numbered handling, and which tool does it. Selecting
a case in the rail shows all of that beside the accounts it filters to. Every
row opens its own evidence in place — the months nobody billed, the free-text
lines that are really product sales, where the player stands on his kit — so the
operator reads the account before deciding, without opening the wizard to find
out whether it is worth opening.

New case, highest severity: **لا يوجد له اشتراك مسجَّل**. An active paying player
with invoice history and no enrolment row at all. Renewal billing reads
`enrollments` and nothing else, so these players are not billed late — they are
not billed at all, and no other screen in the system lists them. That is what
made OC-Sport's 95 lost accounts invisible for a month after a programme delete
took their enrolments. The row reads the programme off their last invoice line,
because the enrolment that knew it is gone and that name is what an operator
needs to put them back.

Two things the rebuild fixes on the way past:

- The scan no longer passes `only` to the scanner. Filtering there dropped the
  other cases from the result, so the rail beside a selected case read zero
  everywhere — which an operator reads as "those are fixed", not "those are
  hidden".
- `expanded` is `#[Locked]`. It names a row whose detail is that participant's
  own money; settable from the browser it is an id to aim somewhere else.

Verified against the restored tenant: 496 tests, 387 pass / 109 skip on Postgres
and 313 / 183 on SQLite, and every one of the first 40 flagged accounts renders
its detail block. Rendered and inspected at 390px, 820px and 1360px.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent a6451e4e
...@@ -31,50 +31,147 @@ class AccountAnomalyScanner ...@@ -31,50 +31,147 @@ class AccountAnomalyScanner
* never recorded outranks money nobody ever chased. * never recorded outranks money nobody ever chased.
*/ */
public const CASES = [ public const CASES = [
'no_enrollment' => [
'icon' => 'no-symbol',
'label' => 'لا يوجد له اشتراك مسجَّل',
'hint' => 'لاعب نشط له فواتير سابقة ولا يوجد له أي سجل اشتراك — لا شيء في النظام يحاسبه.',
'severity' => 0,
'meaning' => 'اللاعب موجود ونشط وله فواتير، لكن لا يوجد في النظام أي سجل يربطه ببرنامج. سجل الاشتراك هو الشيء الوحيد الذي يبني عليه التجديد الشهري فواتيره، فبدونه لا تصدر له فاتورة أبداً، ولا يظهر في كشف المجموعة، ولا في تقرير المتأخرات. هو يتدرب، والحسابات لا تراه.',
'cause' => 'حُذف البرنامج أو المجموعة التي كان مسجَّلاً بها. كان الحذف يمسح سجلات الاشتراك معه بلا رجعة، فيختفي اللاعب من الحسابات وهو ما زال في الملعب. لم يعد هذا ممكناً: حذف برنامج أو مجموعة بها اشتراكات مرفوض الآن.',
'steps' => [
'اعرف برنامجه من آخر فاتورة اشتراك — اسم البرنامج مكتوب في بند الفاتورة نفسه، ويظهر أسفل اسمه هنا.',
'سجِّله في البرنامج المقابل من شاشة «تسجيل لاعب قائم».',
'اضبط تاريخ التجديد على أول الشهر الجاري حتى تصدر له فاتورة هذا الشهر.',
'إن كان قد فاته شهر أو أكثر، ستظهر له حالة «شهر بلا فاتورة» بعد التسجيل — عالجها من هنا.',
],
'tool' => 'تسجيل لاعب قائم، ثم معالج التسوية للشهور الفائتة',
],
'never_paid' => [ 'never_paid' => [
'icon' => 'banknotes',
'label' => 'لم يُسجَّل له أي دفع', 'label' => 'لم يُسجَّل له أي دفع',
'hint' => 'شهور انتهت ولم يُسجَّل عليها أي تحصيل — يُراجَع مع الاستقبال: هل حُصِّل نقداً؟ (الشهر الجاري لا يُحتسب)', 'hint' => 'شهور انتهت ولم يُسجَّل عليها أي تحصيل — يُراجَع مع الاستقبال: هل حُصِّل نقداً؟ (الشهر الجاري لا يُحتسب)',
'severity' => 1, 'severity' => 1,
'meaning' => 'كل فواتير هذا الحساب ما زالت بلا أي تحصيل، وبينها شهر منتهٍ على الأقل. لم يدخل على الحساب ولا قرش واحد في النظام منذ فتحه.',
'cause' => 'في الغالب ليس امتناعاً عن الدفع. الأشيع: صف كامل أُدخل في جلسة واحدة وزر «الدفع الآن» مغلق افتراضياً، أو مبلغ حُصِّل نقداً في الاستقبال ولم يُسجَّل. الشهر الجاري لا يُحتسب هنا لأنه ما زال في وقت تحصيله.',
'steps' => [
'راجع دفتر الكاش أو إيصالات الاستقبال في تاريخ تسجيله.',
'إن كان قد دفع فعلاً: سجِّل الدفعة بتاريخها الأصلي، لا بتاريخ اليوم — وإلا اختل تقرير الإيرادات الشهري.',
'إن لم يدفع: تواصل مع ولي الأمر، أو سجِّل المبلغ إعفاءً بسبب مكتوب حتى لا يظل معلَّقاً على الحساب.',
],
'tool' => 'معالج التسوية ← تسجيل دفعة، أو إعفاء',
], ],
'handtyped_product' => [ 'handtyped_product' => [
'icon' => 'document-text',
'label' => 'منتج مكتوب كبند يدوي', 'label' => 'منتج مكتوب كبند يدوي',
'hint' => 'دفع مالاً لمنتج (قيد/شنطة/زي) كُتب كنص حر ولم يُربط بالمنتج — المخزون والتقارير لا تراه.', 'hint' => 'دفع مالاً لمنتج (قيد/شنطة/زي) كُتب كنص حر ولم يُربط بالمنتج — المخزون والتقارير لا تراه.',
'severity' => 2, 'severity' => 2,
'meaning' => 'بند في فاتورته مكتوب بخط اليد باسم منتج تبيعه الأكاديمية، لكنه غير مربوط بالمنتج نفسه. المال دخل، لكن المخزون لم ينقص، وتقرير مبيعات المنتجات لا يعرف بهذه البيعة.',
'cause' => 'أُضيف البند من خانة «بند حر» بدل بيع المنتج من نقطة البيع — غالباً لأن المنتج لم يكن مضافاً وقتها، أو لأن الإدخال كان أسرع.',
'steps' => [
'تأكد أن البند فعلاً يقصد هذا المنتج وليس شيئاً آخر بنفس الاسم.',
'اربط البند بالمنتج من معالج التسوية — لا تلغِ الفاتورة ولا تعيد إصدارها.',
'الربط يصحح المخزون والتقارير بأثر رجعي، ولا يغيّر قرشاً في الفاتورة.',
],
'tool' => 'معالج التسوية ← ربط بند بمنتج',
], ],
'zero_invoice' => [ 'zero_invoice' => [
'icon' => 'receipt-percent',
'label' => 'فاتورة بقيمة صفر', 'label' => 'فاتورة بقيمة صفر',
'hint' => 'صدرت فاتورة بلا سعر — الشهر لم يُحاسَب أصلاً.', 'hint' => 'صدرت فاتورة بلا سعر — الشهر لم يُحاسَب أصلاً.',
'severity' => 3, 'severity' => 3,
'meaning' => 'فاتورة إجماليها صفر. الشهر يبدو مُحاسَباً في الكشوف بينما لم يُطلب فيه أي مبلغ، وهذا أخطر من شهر بلا فاتورة أصلاً لأن لا شيء ينبّه إليه.',
'cause' => 'صدرت الفاتورة والبرنامج بلا سعر أساسي فعّال في ذلك التاريخ، أو بسعر ينطبق على فئة عضوية غير فئة اللاعب.',
'steps' => [
'راجع سعر البرنامج: هل يوجد سعر فعّال لفئة هذا اللاعب (عضو / غير عضو)؟ صحِّحه أولاً وإلا تكرر الأمر الشهر القادم.',
'ثم صحِّح قيمة الفاتورة نفسها من معالج التسوية بالسعر الصحيح.',
'إن كان الإعفاء مقصوداً، اكتب السبب في التسوية حتى لا يعود الحساب إلى هذه القائمة.',
],
'tool' => 'شاشة الأسعار، ثم معالج التسوية ← تصحيح قيمة',
], ],
'stacked_unpaid' => [ 'stacked_unpaid' => [
'icon' => 'clock',
'label' => 'متأخرات متراكمة', 'label' => 'متأخرات متراكمة',
'hint' => 'شهران منتهيان فأكثر بلا تحصيل — يُراجَعان شهراً شهراً. اشتراك الشهر الجاري لا يُحتسب هنا.', 'hint' => 'شهران منتهيان فأكثر بلا تحصيل — يُراجَعان شهراً شهراً. اشتراك الشهر الجاري لا يُحتسب هنا.',
'severity' => 4, 'severity' => 4,
'meaning' => 'شهران منتهيان أو أكثر ما زالا بلا تحصيل. بعكس «لم يُسجَّل له أي دفع»، هذا حساب يدفع أحياناً — وهو ما يجعل المتأخر فيه أقرب إلى نسيان منه إلى انقطاع.',
'cause' => 'دفعات جزئية غير مكتملة، أو شهور مرّت وقت انشغال الاستقبال، أو ولي أمر يدفع كل شهرين ولا أحد يتابع.',
'steps' => [
'راجع الشهور شهراً شهراً — لا تحصِّل المجموع دفعة واحدة، فالتقارير الشهرية تُبنى على الشهر الذي تخصّه الدفعة.',
'ما تأكد تحصيله نقداً: سجِّله بتاريخه.',
'الباقي: اتفق على خطة أقساط بدل تركه متأخرات مفتوحة.',
],
'tool' => 'معالج التسوية ← تسجيل دفعة / خطة أقساط',
], ],
'unbilled_month' => [ 'unbilled_month' => [
'icon' => 'calendar',
'label' => 'شهر بلا فاتورة', 'label' => 'شهر بلا فاتورة',
'hint' => 'شهر بين بداية الاشتراك واليوم لم تصدر له فاتورة اشتراك.', 'hint' => 'شهر بين بداية الاشتراك واليوم لم تصدر له فاتورة اشتراك.',
'severity' => 5, 'severity' => 5,
], 'meaning' => 'شهر مرّ واللاعب مشترك فيه ولم تصدر له فاتورة أصلاً. المبلغ ليس متأخراً — هو غير مطلوب من الأساس، ولن يظهر في أي تقرير مديونية.',
'missing_bundle' => [ 'cause' => 'توقّف التجديد التلقائي في ذلك الشهر، أو سُجِّل اللاعب بأثر رجعي بعد بدء الموسم، أو حُذف اشتراكه وأُعيد.',
'label' => 'لم يُحاسَب على مستلزم البرنامج', 'steps' => [
'hint' => 'البرنامج يتطلب منتجاً (قيد اتحاد الكرة مثلاً) ولا يوجد له أي مبلغ.', 'تأكد أنه كان فعلاً متدرباً في ذلك الشهر — كشف الحضور يحسم هذا.',
'severity' => 6, 'إن كان متدرباً: أصدر فاتورة الشهر بتاريخه من معالج التسوية، وستُحتسب في إيراد شهرها لا في إيراد اليوم.',
'إن لم يكن: لا تصدر شيئاً، وسجّل السبب حتى لا يظهر الشهر مرة أخرى.',
],
'tool' => 'معالج التسوية ← فوترة شهر',
], ],
'over_billed_bundle' => [ 'over_billed_bundle' => [
'icon' => 'exclamation-triangle',
'label' => 'مستلزم محاسَب بأكثر من سعره', 'label' => 'مستلزم محاسَب بأكثر من سعره',
'hint' => 'مجموع البنود المكتوبة يدوياً لهذا المستلزم يتجاوز سعره — غالباً قسط أُدخل مرتين، أو تصحيح أضاف فاتورة بدل أن يحل محل القديمة.', 'hint' => 'مجموع البنود المكتوبة يدوياً لهذا المستلزم يتجاوز سعره — غالباً قسط أُدخل مرتين، أو تصحيح أضاف فاتورة بدل أن يحل محل القديمة.',
'severity' => 2, 'severity' => 2,
'meaning' => 'مجموع ما حُوسِب عليه لهذا المستلزم أكبر من سعره المعلن. هذه هي الحالة الوحيدة في القائمة التي تكون فيها الأكاديمية مخطئة في حق ولي الأمر لا العكس — الفارق مطلوب منه مرتين.',
'cause' => 'قسط أُدخل مرتين، أو تصحيح أُضيف كفاتورة جديدة بدل أن يحل محل القديمة، فبقيت الاثنتان قائمتين.',
'steps' => [
'افتح الحساب واقرأ البنود: أيّها الأصل وأيّها التكرار؟ التاريخ والرقم يفصلان بينهما.',
'ألغِ البند المكرر من معالج التسوية — الإلغاء يُسجَّل كقيد عكسي، ولا تُعدَّل الفاتورة الأصلية أبداً.',
'إن كان قد دفع الزيادة فعلاً: أضفها إلى رصيده بدل ردّها نقداً، فتُخصم تلقائياً من الشهر القادم.',
],
'tool' => 'معالج التسوية ← إلغاء بند مكرر / إضافة للرصيد',
],
'missing_bundle' => [
'icon' => 'cube',
'label' => 'لم يُحاسَب على مستلزم البرنامج',
'hint' => 'البرنامج يتطلب منتجاً (قيد اتحاد الكرة مثلاً) ولا يوجد له أي مبلغ.',
'severity' => 6,
'meaning' => 'برنامجه يشترط منتجاً — قيد اتحاد الكرة، شنطة، زي — ولا يوجد على حسابه أي مبلغ يخصّه، لا مدفوعاً ولا مطلوباً.',
'cause' => 'سُجِّل قبل أن يُربط المنتج بالبرنامج، أو أُدخل من شاشة لا تبيع المستلزمات، أو استُثني شفهياً ولم يُكتب.',
'steps' => [
'تأكد أولاً أنه لم يدفعه خارج النظام — راجع الاستقبال قبل أن تطالبه بمبلغ ربما سدده.',
'إن كان قد دفع: سجّل البيعة بتاريخها الأصلي حتى ينضبط المخزون.',
'إن لم يدفع: بِع له المستلزم من هنا، أو ضعه على خطة أقساط.',
'إن كان معفى: سجّل الإعفاء بسببه، ولا تتركه بلا قرار.',
],
'tool' => 'معالج التسوية ← بيع مستلزم / خطة أقساط',
], ],
'partial_bundle' => [ 'partial_bundle' => [
'icon' => 'wallet',
'label' => 'مستلزم مدفوع جزئياً', 'label' => 'مستلزم مدفوع جزئياً',
'hint' => 'سدد جزءاً من قيمة مستلزم البرنامج وتأخر عن قسط شهر منتهٍ، أو سُجِّل دفعه خارج المنتج. الأقساط المنتظمة لا تظهر هنا.', 'hint' => 'سدد جزءاً من قيمة مستلزم البرنامج وتأخر عن قسط شهر منتهٍ، أو سُجِّل دفعه خارج المنتج. الأقساط المنتظمة لا تظهر هنا.',
'severity' => 6, 'severity' => 6,
'meaning' => 'دفع جزءاً من قيمة المستلزم والباقي متوقف. اللاعب الذي يسدد أقساطه في مواعيدها لا يظهر هنا إطلاقاً — الظاهر هنا إمّا متأخر عن قسط شهر انتهى، وإمّا مالٌ سُجِّل خارج المنتج فلا يعرف النظام أنه يخصّه.',
'cause' => 'خطة أقساط توقفت، أو دفعات كُتبت كبنود حرة فلم تُحتسب على المنتج.',
'steps' => [
'اقرأ المدفوع والمتبقي في تفاصيل الصف قبل أي إجراء — الرقم هنا هو المرجع لا ذاكرة الاستقبال.',
'إن كان الفارق مدفوعاً فعلاً وسُجِّل كبند حر: اربطه بالمنتج بدل تحصيله ثانية.',
'إن كان متأخراً حقاً: حصِّل الباقي، أو أعِد جدولة الأقساط بموعد جديد.',
],
'tool' => 'معالج التسوية ← ربط بند / تحصيل الباقي',
], ],
'duplicate_person' => [ 'duplicate_person' => [
'icon' => 'users',
'label' => 'سجل مكرر', 'label' => 'سجل مكرر',
'hint' => 'يوجد مشترك آخر بنفس الاسم والهاتف — الفواتير موزعة على سجلين.', 'hint' => 'يوجد مشترك آخر بنفس الاسم والهاتف — الفواتير موزعة على سجلين.',
'severity' => 7, 'severity' => 7,
'meaning' => 'طفل واحد بسجلّين. فواتيره ومدفوعاته موزعة بينهما، فكل سجل يبدو ناقصاً، ولا واحد منهما يعرض الصورة الصحيحة لحسابه.',
'cause' => 'سُجِّل مرتين — مرة في الاستقبال ومرة عبر التسجيل الذاتي، أو باسم مكتوب بصيغتين.',
'steps' => [
'قارن السجلين: تاريخ الميلاد ورقم ولي الأمر يؤكدان أنهما شخص واحد قبل أي دمج.',
'اختر السجل الذي سيبقى — الأقدم عادةً، لأن رقم المشترك مطبوع على إيصالاته.',
'حصِّل وسوِّ كل حساب على حدة أولاً، ثم أوقف السجل الزائد بسبب مكتوب حتى لا يُستخدم ثانية.',
],
'tool' => 'معالج التسوية على كل سجل، ثم إيقاف السجل الزائد',
], ],
]; ];
...@@ -110,6 +207,7 @@ public function scan(?int $branchId = null, ?string $only = null, int $limit = 3 ...@@ -110,6 +207,7 @@ public function scan(?int $branchId = null, ?string $only = null, int $limit = 3
$duplicates = $this->duplicateGroups($participants); $duplicates = $this->duplicateGroups($participants);
$bundles = $this->bundleStatus($participants); $bundles = $this->bundleStatus($participants);
$unbilled = $this->unbilledMonths($participants); $unbilled = $this->unbilledMonths($participants);
$orphaned = $this->orphanedAccounts($participants);
$rows = []; $rows = [];
...@@ -195,6 +293,11 @@ public function scan(?int $branchId = null, ?string $only = null, int $limit = 3 ...@@ -195,6 +293,11 @@ public function scan(?int $branchId = null, ?string $only = null, int $limit = 3
$detail['duplicate_of'] = $duplicates[$participant->id]; $detail['duplicate_of'] = $duplicates[$participant->id];
} }
if (! empty($orphaned[$participant->id])) {
$cases[] = 'no_enrollment';
$detail['no_enrollment'] = $orphaned[$participant->id];
}
if ($cases === []) { if ($cases === []) {
continue; continue;
} }
...@@ -253,6 +356,7 @@ public function forParticipant(Participant $participant): array ...@@ -253,6 +356,7 @@ public function forParticipant(Participant $participant): array
'over_billed' => array_values(array_filter($bundle, fn ($b) => ($b['over_billed'] ?? 0) > 0)), 'over_billed' => array_values(array_filter($bundle, fn ($b) => ($b['over_billed'] ?? 0) > 0)),
'unbilled_months' => $this->unbilledMonths($collection)[$participant->id] ?? [], 'unbilled_months' => $this->unbilledMonths($collection)[$participant->id] ?? [],
'duplicate_of' => $this->duplicateGroups($collection)[$participant->id] ?? [], 'duplicate_of' => $this->duplicateGroups($collection)[$participant->id] ?? [],
'no_enrollment' => $this->orphanedAccounts($collection)[$participant->id] ?? null,
]; ];
} }
...@@ -666,6 +770,105 @@ private function unbilledMonths($participants): array ...@@ -666,6 +770,105 @@ private function unbilledMonths($participants): array
return $out; return $out;
} }
/**
* Paying players who have invoice history and no enrolment row at all.
*
* Every other case on this screen is an account whose numbers are wrong.
* This one is an account the system has stopped counting: renewal billing
* reads `enrollments` and nothing else, so a player with no row there is
* not billed late — he is not billed at all, and no report anywhere lists
* him, which is what makes this the one case nobody can find by looking.
*
* It is on this screen because it was invisible on every other one. Deleting
* a programme used to force-delete its enrolments; OC-Sport lost 181 of them
* on 28 August 2026 and 95 paying players went a month unbilled while the
* renewal run reported a clean success every night.
*
* The existence check drops the branch scope deliberately. A transfer can
* leave a player enrolled in another branch, and telling an operator to
* re-enrol somebody who is already enrolled is a worse failure than missing
* a case — this list is only useful while every row on it is real.
*
* @return array<int, array{invoices:int, last_program:?string, last_month:?string}>
*/
private function orphanedAccounts($participants): array
{
$candidates = $participants->filter(
fn ($p) => ! $p->is_free && in_array($p->status?->value ?? $p->status, ['active', 'registered'], true)
);
if ($candidates->isEmpty()) {
return [];
}
$ids = $candidates->pluck('id')->all();
$enrolled = Enrollment::withoutBranchScope()
->whereIn('participant_id', $ids)
->distinct()
->pluck('participant_id')
->all();
$orphanIds = array_values(array_diff($ids, $enrolled));
if ($orphanIds === []) {
return [];
}
// The subscription line on their last invoice is the only surviving
// record of which programme they were in — the enrolment that knew is
// gone. It is what the operator needs to put them back, so it is read
// here rather than left for them to dig out invoice by invoice.
$invoices = DB::table('invoices')
->leftJoin('invoice_items', function ($join) {
$join->on('invoice_items.invoice_id', '=', 'invoices.id')
->whereNull('invoice_items.itemable_type');
})
->where('invoices.billable_type', Participant::class)
->whereIn('invoices.billable_id', $orphanIds)
->whereNull('invoices.deleted_at')
->where('invoices.status', '!=', 'cancelled')
->orderBy('invoices.issue_date')
->get([
'invoices.id as invoice_id',
'invoices.billable_id',
'invoices.issue_date',
'invoice_items.description',
]);
$out = [];
$seen = [];
foreach ($invoices as $invoice) {
$pid = (int) $invoice->billable_id;
// The join fans out one row per line, so the invoice count is over
// distinct invoice ids — otherwise a three-line invoice reads as
// three invoices and the operator distrusts the whole row.
$seen[$pid][(int) $invoice->invoice_id] = true;
$out[$pid] ??= ['invoices' => 0, 'last_program' => null, 'last_month' => null];
$out[$pid]['invoices'] = count($seen[$pid]);
$out[$pid]['last_month'] = substr((string) $invoice->issue_date, 0, 7);
$description = trim((string) $invoice->description);
if ($description !== '') {
// "تجديد اشتراك: فريق 2017" and "اشتراك: فريق 2017" both name the
// programme; the prefix is the invoice's wording, not part of it.
$out[$pid]['last_program'] = trim(preg_replace(
'/^(تجديد اشتراك|اشتراك)\s*:\s*/u',
'',
$description
));
}
}
// No invoice, no evidence anything was ever billed: a half-finished
// registration is somebody else's problem, not a settlement.
return array_filter($out, fn ($row) => $row['invoices'] > 0);
}
/** /**
* Two participant records for one child: same name, same guardian phone. * Two participant records for one child: same name, same guardian phone.
* *
......
...@@ -5,18 +5,24 @@ ...@@ -5,18 +5,24 @@
use App\Domain\Financial\Services\AccountAnomalyScanner; use App\Domain\Financial\Services\AccountAnomalyScanner;
use App\Domain\Shared\Support\ArabicSearch; use App\Domain\Shared\Support\ArabicSearch;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Attributes\Url; use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
/** /**
* Every account that needs a human decision, worst first. * Every account that needs a human decision, worst first — and what to do
* about each one.
* *
* The alternative — and what actually happened on the first academy to go live * The alternative — and what actually happened on the first academy to go live
* — is that nobody knows a file is wrong until a parent argues at the desk. By * — is that nobody knows a file is wrong until a parent argues at the desk. By
* then the money is months old and the person who took it has gone home. This * then the money is months old and the person who took it has gone home. This
* screen reads the same books the roster reads and says: these are the players * screen reads the same books the roster reads and says: these are the players
* whose file does not add up, and this is why. * whose file does not add up, this is why, and this is how it is handled.
*
* The guidance is on the screen rather than in a manual because the operator
* here is a receptionist mid-shift, not an accountant with time to read. A case
* they do not understand is a case they skip, and a skipped case is money.
* *
* Read-only. Every row links into the settlement wizard, where a person decides * Read-only. Every row links into the settlement wizard, where a person decides
* what actually happened before anything is written. * what actually happened before anything is written.
...@@ -31,6 +37,16 @@ class SettlementWorklist extends Component ...@@ -31,6 +37,16 @@ class SettlementWorklist extends Component
#[Url(as: 'q')] #[Url(as: 'q')]
public string $search = ''; public string $search = '';
/**
* The row whose full detail is open.
*
* Locked: it only ever names a row this render already produced, and the
* detail it reveals is the participant's own money. Letting the browser set
* it would be handing it an id to point at.
*/
#[Locked]
public ?int $expanded = null;
public function mount(): void public function mount(): void
{ {
$this->authorize('settlements.manage'); $this->authorize('settlements.manage');
...@@ -40,6 +56,20 @@ public function clearFilter(): void ...@@ -40,6 +56,20 @@ public function clearFilter(): void
{ {
$this->caseFilter = ''; $this->caseFilter = '';
$this->search = ''; $this->search = '';
$this->expanded = null;
}
public function selectCase(string $case): void
{
// Selecting the open case again closes it. A filter you can only escape
// by finding the "all" button is a filter operators leave switched on.
$this->caseFilter = $this->caseFilter === $case ? '' : $case;
$this->expanded = null;
}
public function toggleRow(int $participantId): void
{
$this->expanded = $this->expanded === $participantId ? null : $participantId;
} }
/** /**
...@@ -47,29 +77,48 @@ public function clearFilter(): void ...@@ -47,29 +77,48 @@ public function clearFilter(): void
* a query per participant — an academy of a few hundred players is one * a query per participant — an academy of a few hundred players is one
* page-load, and the branch scope on Participant is what keeps it to this * page-load, and the branch scope on Participant is what keeps it to this
* branch. * branch.
*
* Scanned unfiltered, then narrowed here. The scanner's own `only` argument
* would drop the other cases from the result entirely, and the case rail
* would then show a count of zero beside every case except the selected one
* — which reads as "these are fixed" rather than "these are hidden".
*
* @return array{all:array<int,array>, shown:array<int,array>, counts:array<string,int>}
*/ */
private function rows(): array private function scan(): array
{ {
$rows = app(AccountAnomalyScanner::class)->scan( $all = app(AccountAnomalyScanner::class)->scan(branchId: null);
branchId: null,
only: $this->caseFilter ?: null, $counts = [];
); foreach ($all as $row) {
foreach ($row['cases'] as $case) {
$counts[$case] = ($counts[$case] ?? 0) + 1;
}
}
$term = trim($this->search); $term = trim($this->search);
$shown = $all;
if ($term === '') { if ($this->caseFilter !== '') {
return $rows; $shown = array_values(array_filter(
$shown,
fn ($row) => in_array($this->caseFilter, $row['cases'], true)
));
} }
return array_values(array_filter($rows, function ($row) use ($term) { if ($term !== '') {
$person = $row['participant']->person; $shown = array_values(array_filter($shown, function ($row) use ($term) {
$person = $row['participant']->person;
// Same folding the wizard's search uses, so a name found there
// is found here too.
return ArabicSearch::matches($person?->name_ar, $term)
|| ArabicSearch::matches($person?->name, $term)
|| str_contains((string) $person?->phone, ArabicSearch::normalise($term));
}));
}
// Same folding the wizard's search uses, so a name found there is return ['all' => $all, 'shown' => $shown, 'counts' => $counts];
// found here too.
return ArabicSearch::matches($person?->name_ar, $term)
|| ArabicSearch::matches($person?->name, $term)
|| str_contains((string) $person?->phone, ArabicSearch::normalise($term));
}));
} }
/** The worklist as a spreadsheet, for the people who work off paper. */ /** The worklist as a spreadsheet, for the people who work off paper. */
...@@ -77,7 +126,7 @@ public function export() ...@@ -77,7 +126,7 @@ public function export()
{ {
$this->authorize('settlements.manage'); $this->authorize('settlements.manage');
$rows = $this->rows(); $rows = $this->scan()['shown'];
$cases = AccountAnomalyScanner::CASES; $cases = AccountAnomalyScanner::CASES;
return response()->streamDownload(function () use ($rows, $cases) { return response()->streamDownload(function () use ($rows, $cases) {
...@@ -98,7 +147,11 @@ public function export() ...@@ -98,7 +147,11 @@ public function export()
$participant->id, $participant->id,
$participant->person?->name_ar, $participant->person?->name_ar,
$participant->person?->phone, $participant->person?->phone,
$participant->enrollments->first()?->program?->name_ar, // A player whose enrolment was destroyed has no programme to
// read off the relation; his last invoice line is the only
// surviving record of which one it was.
$participant->enrollments->first()?->program?->name_ar
?? ($row['detail']['no_enrollment']['last_program'] ?? null),
implode(' / ', array_map(fn ($c) => $cases[$c]['label'], $row['cases'])), implode(' / ', array_map(fn ($c) => $cases[$c]['label'], $row['cases'])),
$row['unpaid_invoices'], $row['unpaid_invoices'],
number_format($row['owed'] / 100, 2, '.', ''), number_format($row['owed'] / 100, 2, '.', ''),
...@@ -115,20 +168,25 @@ public function export() ...@@ -115,20 +168,25 @@ public function export()
public function render() public function render()
{ {
$rows = $this->rows(); $scan = $this->scan();
$counts = []; // Money the books are asking for twice, across every flagged account.
foreach ($rows as $row) { // It sits beside the debt in the header because it is the one headline
foreach ($row['cases'] as $case) { // figure that is owed BY the academy, and averaging it into "المستحق"
$counts[$case] = ($counts[$case] ?? 0) + 1; // is how an over-billing goes unnoticed for a season.
} $overBilled = 0;
foreach ($scan['all'] as $row) {
$overBilled += array_sum(array_column($row['detail']['over_billed_bundle'] ?? [], 'over_billed'));
} }
return view('livewire.admin.settlement-worklist', [ return view('livewire.admin.settlement-worklist', [
'rows' => $rows, 'rows' => $scan['shown'],
'counts' => $counts, 'total' => count($scan['all']),
'counts' => $scan['counts'],
'cases' => AccountAnomalyScanner::CASES, 'cases' => AccountAnomalyScanner::CASES,
'totalOwed' => array_sum(array_column($rows, 'owed')), 'guide' => AccountAnomalyScanner::CASES[$this->caseFilter] ?? null,
'totalOwed' => array_sum(array_column($scan['all'], 'owed')),
'overBilled' => $overBilled,
]); ]);
} }
} }
{{-- The accounts that need a decision. RTL: logical properties only. --}} {{--
<div class="max-w-7xl mx-auto px-3 sm:px-4 py-4 sm:py-6" dir="rtl"> The settlement console.
<div class="mb-4 sm:mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="text-xl sm:text-2xl font-bold text-gray-900">{{ __('حالات تحتاج تسوية') }}</h1>
<p class="mt-1 text-sm text-gray-500">
{{ __('حسابات لا تتفق أرقامها مع الواقع: مال حُصِّل ولم يُسجَّل، شهور بلا فواتير، منتجات بيعت خارج السيستم.') }}
<span class="block mt-0.5">{{ __('الشهر الجاري لا يُحتسب هنا — تحصيله من شاشة «تحصيل دفعة». وكذلك الأقساط المنتظمة حتى الشهر الجاري.') }}</span>
</p>
</div>
<button type="button" wire:click="export"
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50">
{{ __('تصدير Excel') }}
</button>
</div>
{{-- Headline --}} Two jobs on one screen: say which accounts need a decision, and teach the
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5"> operator how each kind of case is handled. The second job is why the guide
<div class="p-3 bg-white border border-gray-200 rounded-xl"> panel is here rather than in a manual the person reading this is a
<p class="text-[11px] text-gray-500">{{ __('حسابات مرصودة') }}</p> receptionist mid-shift, and a case they do not understand is a case they
<p class="text-xl font-bold text-gray-900">{{ count($rows) }}</p> skip.
</div>
<div class="p-3 bg-white border border-gray-200 rounded-xl"> RTL: logical properties only (ms/me/ps/pe, text-start/end, border-s/e).
<p class="text-[11px] text-gray-500">{{ __('إجمالي المستحق عليها') }}</p> --}}
<p class="text-xl font-bold text-red-700 tabular-nums" dir="ltr">{{ format_money($totalOwed) }}</p> @php
// Severity bands. The scanner's numbers are an ordering, not a vocabulary;
// this is where they become one, in one place, so the rail dot, the row
// stripe and the guide heading cannot disagree about how bad a case is.
$band = fn (int $severity) => match (true) {
$severity <= 2 => ['dot' => 'bg-rose-500', 'chip' => 'bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-950/50 dark:text-rose-300 dark:ring-rose-900'],
$severity <= 5 => ['dot' => 'bg-amber-500', 'chip' => 'bg-amber-50 text-amber-800 ring-amber-200 dark:bg-amber-950/50 dark:text-amber-300 dark:ring-amber-900'],
default => ['dot' => 'bg-slate-400', 'chip' => 'bg-slate-100 text-slate-700 ring-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-700'],
};
@endphp
<div dir="rtl" class="settlement-console min-h-screen bg-gray-50 dark:bg-gray-950">
<style>
/* The surfaces we did not draw still belong to the design: selection,
focus and the rail's scrollbar ship with browser defaults otherwise. */
.settlement-console ::selection { background: #fde68a; color: #451a03; }
.dark .settlement-console ::selection { background: #78350f; color: #fef3c7; }
.settlement-console :focus-visible {
outline: 2px solid #f59e0b;
outline-offset: 2px;
border-radius: 0.5rem;
}
.settlement-console .rail { scrollbar-width: thin; scrollbar-color: #cbd5e1 transparent; }
.dark .settlement-console .rail { scrollbar-color: #334155 transparent; }
/* The one authored moment on the page: a row opening its evidence.
Exponential ease-out from an already-visible default. */
@keyframes settlement-reveal {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: none; }
}
.settlement-console .reveal { animation: settlement-reveal 180ms cubic-bezier(0.16, 1, 0.3, 1) both; }
@media (prefers-reduced-motion: reduce) {
.settlement-console .reveal { animation: none; }
}
</style>
<div class="max-w-[92rem] mx-auto px-3 sm:px-5 py-5 sm:py-7">
{{-- ---- what this screen is ------------------------------------ --}}
<div class="flex flex-wrap items-end justify-between gap-4 mb-5">
<div class="max-w-[62ch]">
<h1 class="text-2xl sm:text-[26px] font-bold tracking-tight text-gray-900 dark:text-gray-50">
{{ __('حالات تحتاج تسوية') }}
</h1>
<p class="mt-1.5 text-sm leading-relaxed text-gray-600 dark:text-gray-400">
{{ __('حسابات لا تتفق أرقامها مع الواقع: مال حُصِّل ولم يُسجَّل، شهور بلا فواتير، لاعبون لا يحاسبهم شيء. اختر حالة من القائمة لتقرأ معناها وطريقة معالجتها خطوة بخطوة.') }}
</p>
<p class="mt-1 text-[12px] text-gray-500 dark:text-gray-500">
{{ __('الشهر الجاري لا يُحتسب هنا — تحصيله من شاشة «تحصيل دفعة»، وكذلك الأقساط المنتظمة.') }}
</p>
</div>
<button type="button" wire:click="export" wire:loading.attr="disabled"
class="shrink-0 inline-flex items-center gap-2 px-3.5 py-2 rounded-lg text-sm font-medium
border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-200
bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800
disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<x-ui.icon name="document-text" class="w-4 h-4" />
<span wire:loading.remove wire:target="export">{{ __('تصدير Excel') }}</span>
<span wire:loading wire:target="export">{{ __('جارٍ التصدير…') }}</span>
</button>
</div> </div>
<div class="p-3 bg-white border border-gray-200 rounded-xl col-span-2">
<p class="text-[11px] text-gray-500 mb-1">{{ __('بحث') }}</p> {{-- ---- the state of the books --------------------------------- --}}
<input type="text" wire:model.live.debounce.400ms="search" placeholder="{{ __('اسم أو هاتف...') }}" <div class="mb-6 rounded-xl bg-slate-900 dark:bg-slate-900 ring-1 ring-slate-800 shadow-lg shadow-slate-900/10">
class="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm"> <div class="flex flex-wrap">
<div class="flex-1 min-w-[10rem] px-5 py-4">
<p class="text-[11px] font-medium text-slate-400">{{ __('حسابات تحتاج قراراً') }}</p>
<p class="mt-1 text-2xl font-bold text-white tabular-nums" dir="ltr">{{ $total }}</p>
</div>
<div class="flex-1 min-w-[12rem] px-5 py-4 border-t sm:border-t-0 sm:border-s border-slate-800">
<p class="text-[11px] font-medium text-slate-400">{{ __('موقوف على هذه الحسابات') }}</p>
<p class="mt-1 text-2xl font-bold text-white tabular-nums whitespace-nowrap" dir="ltr">{{ format_money($totalOwed) }}</p>
</div>
@if($overBilled > 0)
<div class="flex-1 min-w-[12rem] px-5 py-4 border-t sm:border-t-0 sm:border-s border-slate-800">
{{-- Not averaged into the debt above: this is money the
academy is asking for twice, and it is owed the other
way round. --}}
<p class="text-[11px] font-medium text-rose-300">{{ __('محاسَب زيادة — مستحق للأهالي') }}</p>
<p class="mt-1 text-2xl font-bold text-rose-300 tabular-nums whitespace-nowrap" dir="ltr">{{ format_money($overBilled) }}</p>
</div>
@endif
<div class="flex-1 min-w-[14rem] px-5 py-3.5 border-t sm:border-t-0 sm:border-s border-slate-800">
<label for="settlement-search" class="text-[11px] font-medium text-slate-400">{{ __('بحث بالاسم أو الهاتف') }}</label>
<div class="mt-1.5 relative">
<x-ui.icon name="magnifying-glass" class="w-4 h-4 absolute top-1/2 -translate-y-1/2 start-2.5 text-slate-500 pointer-events-none" />
<input id="settlement-search" type="text" wire:model.live.debounce.400ms="search"
placeholder="{{ __('اسم أو هاتف…') }}"
class="w-full ps-8 pe-3 py-1.5 rounded-lg text-sm bg-slate-800 border border-slate-700
text-white placeholder-slate-500 focus:border-amber-500 focus:outline-none">
</div>
</div>
</div>
</div> </div>
</div>
{{-- Case filters --}} <div class="grid grid-cols-1 lg:grid-cols-[19rem_minmax(0,1fr)] gap-5 items-start">
<div class="flex flex-wrap gap-2 mb-4">
<button type="button" wire:click="clearFilter"
class="px-3 py-1.5 text-xs rounded-full border {{ $caseFilter === '' ? 'bg-gray-900 text-white border-gray-900' : 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50' }}">
{{ __('الكل') }} ({{ count($rows) }})
</button>
@foreach($cases as $code => $case)
@if(!empty($counts[$code]) || $caseFilter === $code)
<button type="button" wire:click="$set('caseFilter', '{{ $code }}')"
class="px-3 py-1.5 text-xs rounded-full border {{ $caseFilter === $code ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-600 border-gray-300 hover:bg-amber-50' }}">
{{ $case['label'] }} ({{ $counts[$code] ?? 0 }})
</button>
@endif
@endforeach
</div>
@if($caseFilter && isset($cases[$caseFilter])) {{-- ---- the case rail -------------------------------------- --}}
<div class="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-900"> <aside class="lg:sticky lg:top-4">
{{ $cases[$caseFilter]['hint'] }} <div class="rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 overflow-hidden">
</div> <div class="px-4 pt-3.5 pb-2 flex items-baseline justify-between">
@endif <h2 class="text-sm font-bold text-gray-900 dark:text-gray-100">{{ __('أنواع الحالات') }}</h2>
@if($caseFilter !== '' || $search !== '')
{{-- The list --}} <button type="button" wire:click="clearFilter"
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden"> class="text-[11px] font-medium text-amber-700 dark:text-amber-400 hover:underline">
<div class="overflow-x-auto"> {{ __('عرض الكل') }}
<table class="w-full text-sm"> </button>
<thead class="bg-gray-50 text-xs text-gray-600"> @endif
<tr> </div>
<th class="px-3 py-2.5 text-start font-medium">{{ __('المشترك') }}</th>
<th class="px-3 py-2.5 text-start font-medium">{{ __('البرنامج') }}</th> @php
<th class="px-3 py-2.5 text-start font-medium">{{ __('ما الذي رُصد') }}</th> // Filtered here rather than with @continue inside the loop:
<th class="px-3 py-2.5 text-center font-medium">{{ __('غير مسددة') }}</th> // a forelse whose every iteration continues still counts as
<th class="px-3 py-2.5 text-center font-medium">{{ __('المستحق') }}</th> // non-empty, so a branch with clean books would have shown
<th class="px-3 py-2.5 text-center font-medium">{{ __('المحصَّل') }}</th> // an unexplained blank panel instead of saying so.
<th class="px-3 py-2.5 text-center font-medium"></th> $visibleCases = array_filter(
</tr> $cases,
</thead> fn ($case, $code) => ! empty($counts[$code]) || $caseFilter === $code,
<tbody class="divide-y divide-gray-100"> ARRAY_FILTER_USE_BOTH
@forelse($rows as $row) );
@php $participant = $row['participant']; @endphp @endphp
<tr class="hover:bg-gray-50 transition"> <div class="rail max-h-[26rem] lg:max-h-[calc(100vh-16rem)] overflow-y-auto pb-2">
<td class="px-3 py-2.5"> @forelse($visibleCases as $code => $case)
<span class="block font-medium text-gray-900">{{ $participant->person?->name_ar }}</span> @php $b = $band($case['severity']); @endphp
@if($participant->person?->phone) <button type="button" wire:click="selectCase('{{ $code }}')"
<span class="block text-xs text-gray-500" dir="ltr">{{ $participant->person->phone }}</span> aria-pressed="{{ $caseFilter === $code ? 'true' : 'false' }}"
@endif class="w-full text-start px-4 py-2.5 flex items-center gap-2.5 border-t border-gray-100 dark:border-gray-800 transition-colors
</td> {{ $caseFilter === $code
<td class="px-3 py-2.5 text-xs text-gray-600">{{ $participant->enrollments->first()?->program?->name_ar ?? '—' }}</td> ? 'bg-amber-50 dark:bg-amber-950/40'
<td class="px-3 py-2.5"> : 'hover:bg-gray-50 dark:hover:bg-gray-800/60' }}">
<div class="flex flex-wrap gap-1"> <span class="w-1.5 h-1.5 rounded-full shrink-0 {{ $b['dot'] }}"></span>
@foreach($row['cases'] as $case) <x-ui.icon :name="$case['icon']" class="w-4 h-4 shrink-0 {{ $caseFilter === $code ? 'text-amber-700 dark:text-amber-400' : 'text-gray-400 dark:text-gray-500' }}" />
{{-- Severity is the point of the list; a duplicate billing and a <span class="flex-1 text-[13px] leading-snug {{ $caseFilter === $code ? 'font-semibold text-amber-900 dark:text-amber-200' : 'text-gray-700 dark:text-gray-300' }}">
missing month reading identically is what made the operator {{ $case['label'] }}
work top-to-bottom instead of worst-first. --}} </span>
<span class="px-1.5 py-0.5 text-[10px] rounded-full <span class="shrink-0 text-[12px] tabular-nums {{ $caseFilter === $code ? 'font-bold text-amber-800 dark:text-amber-300' : 'text-gray-400 dark:text-gray-500' }}" dir="ltr">
{{ ($cases[$case]['severity'] ?? 9) <= 2 ? 'bg-rose-100 text-rose-800' : 'bg-amber-100 text-amber-800' }}"> {{ $counts[$code] ?? 0 }}
{{ $cases[$case]['label'] }} </span>
</span> </button>
@endforeach @empty
<p class="px-4 py-6 text-[13px] text-gray-500 dark:text-gray-400">
{{ __('لا توجد حالات مرصودة في هذا الفرع.') }}
</p>
@endforelse
</div>
</div>
@unless($guide)
<div class="mt-3 rounded-xl border border-dashed border-gray-300 dark:border-gray-700 px-4 py-3.5">
<p class="text-[13px] font-semibold text-gray-700 dark:text-gray-300">{{ __('كيف تُستخدم هذه الشاشة') }}</p>
<p class="mt-1.5 text-[12px] leading-relaxed text-gray-500 dark:text-gray-400">
{{ __('اختر نوع الحالة من القائمة أعلاه: تظهر لك شرحها وسببها وخطوات معالجتها، وتُفلتَر القائمة عليها. اضغط أي صف لتقرأ تفاصيل الحساب كاملة قبل أن تفتح المعالج.') }}
</p>
<p class="mt-2 text-[12px] leading-relaxed text-gray-500 dark:text-gray-400">
{{ __('القائمة مرتّبة بالأخطر أولاً: مال دخل ولم يُسجَّل، ثم مال طُلب مرتين، ثم ما لم يُطلب أصلاً.') }}
</p>
</div>
@endunless
</aside>
{{-- ---- guide + worklist ----------------------------------- --}}
<div class="min-w-0">
@if($guide)
@php $gb = $band($guide['severity']); @endphp
<section class="reveal mb-5 rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 overflow-hidden">
<div class="px-5 pt-4 pb-4 border-b border-gray-100 dark:border-gray-800">
<div class="flex items-start gap-3">
<span class="mt-0.5 shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg ring-1 {{ $gb['chip'] }}">
<x-ui.icon :name="$guide['icon']" class="w-5 h-5" />
</span>
<div class="min-w-0">
<h2 class="text-lg font-bold text-gray-900 dark:text-gray-50 leading-tight">{{ $guide['label'] }}</h2>
<p class="mt-2 text-[13px] leading-relaxed text-gray-700 dark:text-gray-300 max-w-[70ch]">
{{ $guide['meaning'] }}
</p>
</div> </div>
@if(!empty($row['detail']['over_billed_bundle'])) </div>
{{-- The figure is the reason this row is here; making the operator </div>
open the wizard to find out how much is a wasted click. --}}
@foreach($row['detail']['over_billed_bundle'] as $b) <div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_20rem]">
<p class="mt-1 text-[11px] text-rose-700"> <div class="px-5 py-4">
{{ \Illuminate\Support\Str::limit($b['product_name'], 26) }}: <h3 class="text-[12px] font-bold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('كيف تُعالَج') }}</h3>
{{ __('محاسَب') }} <span dir="ltr">{{ format_money($b['billed']) }}</span> <ol class="mt-3 space-y-3">
{{ __('وسعره') }} <span dir="ltr">{{ format_money($b['price']) }}</span> — @foreach($guide['steps'] as $i => $step)
<span class="font-bold">{{ __('زيادة') }} <span dir="ltr">{{ format_money($b['over_billed']) }}</span></span> <li class="flex gap-3">
</p> <span class="shrink-0 mt-px inline-flex items-center justify-center w-5 h-5 rounded-full
bg-amber-100 dark:bg-amber-900/60 text-amber-800 dark:text-amber-300
text-[11px] font-bold tabular-nums" dir="ltr">{{ $i + 1 }}</span>
<span class="text-[13px] leading-relaxed text-gray-700 dark:text-gray-300 max-w-[68ch]">{{ $step }}</span>
</li>
@endforeach @endforeach
</ol>
</div>
<div class="px-5 py-4 border-t lg:border-t-0 lg:border-s border-gray-100 dark:border-gray-800 bg-gray-50/70 dark:bg-gray-950/40">
<h3 class="text-[12px] font-bold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('لماذا تحدث') }}</h3>
<p class="mt-2 text-[12px] leading-relaxed text-gray-600 dark:text-gray-400">{{ $guide['cause'] }}</p>
<h3 class="mt-5 text-[12px] font-bold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('الأداة') }}</h3>
<p class="mt-2 text-[12px] leading-relaxed text-gray-600 dark:text-gray-400">{{ $guide['tool'] }}</p>
@if($caseFilter === 'no_enrollment')
@can('enrollments.create')
<a href="{{ route('receptionist.enroll-existing') }}" wire:navigate
class="mt-3 inline-flex items-center gap-1.5 text-[12px] font-semibold text-amber-700 dark:text-amber-400 hover:underline">
{{ __('افتح «تسجيل لاعب قائم»') }}
<x-ui.icon name="chevron-down" class="w-3.5 h-3.5 rotate-90" />
</a>
@endcan
@endif @endif
</td> </div>
<td class="px-3 py-2.5 text-center tabular-nums">{{ $row['unpaid_invoices'] }}</td> </div>
<td class="px-3 py-2.5 text-center tabular-nums font-bold {{ $row['owed'] > 0 ? 'text-red-700' : 'text-gray-400' }}" dir="ltr"> </section>
{{ format_money($row['owed']) }} @endif
</td>
<td class="px-3 py-2.5 text-center tabular-nums text-emerald-700" dir="ltr">{{ format_money($row['paid']) }}</td> {{-- ---- the accounts ----------------------------------- --}}
<td class="px-3 py-2.5 text-center"> <div class="rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 overflow-hidden">
<a href="{{ route('admin.account-settlement', ['participant' => $participant->id]) }}" wire:navigate <div class="px-4 py-3 flex items-baseline justify-between border-b border-gray-100 dark:border-gray-800">
class="inline-flex px-3 py-1.5 bg-amber-500 text-white text-xs font-medium rounded-lg hover:bg-amber-600"> <h2 class="text-sm font-bold text-gray-900 dark:text-gray-100">
{{ __('تسوية') }} {{ $guide ? $guide['label'] : __('كل الحسابات المرصودة') }}
</a> </h2>
</td> <span class="text-[12px] text-gray-500 dark:text-gray-400 tabular-nums">
</tr> {{ count($rows) }} {{ __('من') }} {{ $total }}
@empty </span>
<tr> </div>
<td colspan="7" class="px-3 py-10 text-center">
<p class="text-sm text-gray-500">{{ __('لا توجد حالات مرصودة') }}</p> <div class="overflow-x-auto">
<p class="text-xs text-gray-400 mt-1">{{ __('كل الحسابات في هذا الفرع متسقة — أو غيّر الفلتر.') }}</p> <table class="w-full min-w-[46rem] text-sm">
</td> <thead class="bg-gray-50 dark:bg-gray-950/60 text-[11px] text-gray-500 dark:text-gray-400">
</tr> <tr>
@endforelse <th scope="col" class="ps-3 pe-2 py-2.5 text-start font-semibold">{{ __('المشترك') }}</th>
</tbody> <th scope="col" class="px-3 py-2.5 text-start font-semibold hidden md:table-cell">{{ __('البرنامج') }}</th>
</table> <th scope="col" class="px-3 py-2.5 text-start font-semibold">{{ __('ما الذي رُصد') }}</th>
<th scope="col" class="px-3 py-2.5 text-center font-semibold whitespace-nowrap hidden sm:table-cell">{{ __('غير مسددة') }}</th>
<th scope="col" class="px-3 py-2.5 text-center font-semibold whitespace-nowrap">{{ __('المستحق') }}</th>
<th scope="col" class="px-3 py-2.5 text-center font-semibold whitespace-nowrap hidden sm:table-cell">{{ __('المحصَّل') }}</th>
<th scope="col" class="px-3 py-2.5 text-end font-semibold"><span class="sr-only">{{ __('إجراء') }}</span></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
@forelse($rows as $row)
@php
$participant = $row['participant'];
$detail = $row['detail'];
$open = $expanded === $participant->id;
$rb = $band($row['severity']);
$program = $participant->enrollments->first()?->program?->name_ar
?? ($detail['no_enrollment']['last_program'] ?? null);
@endphp
<tr class="{{ $open ? 'bg-amber-50/60 dark:bg-amber-950/20' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50' }} transition-colors">
<td class="ps-3 pe-2 py-2.5 align-top">
<button type="button" wire:click="toggleRow({{ $participant->id }})"
aria-expanded="{{ $open ? 'true' : 'false' }}"
class="text-start group">
<span class="flex items-center gap-1.5">
<x-ui.icon :name="$open ? 'chevron-up' : 'chevron-down'"
class="w-3.5 h-3.5 shrink-0 text-gray-400 group-hover:text-amber-600" />
<span class="font-medium text-gray-900 dark:text-gray-100 group-hover:text-amber-700 dark:group-hover:text-amber-400">
{{ $participant->person?->name_ar ?? '—' }}
</span>
</span>
@if($participant->person?->phone)
<span class="block ms-5 text-[11px] text-gray-500 dark:text-gray-400" dir="ltr">{{ $participant->person->phone }}</span>
@endif
</button>
</td>
<td class="px-3 py-2.5 align-top text-[12px] text-gray-600 dark:text-gray-400 hidden md:table-cell">
{{ $program ?? '—' }}
@if(! $participant->enrollments->first() && $program)
<span class="block mt-0.5 text-[10px] whitespace-nowrap text-rose-600 dark:text-rose-400">{{ __('من فاتورة سابقة — لا اشتراك') }}</span>
@endif
</td>
<td class="px-3 py-2.5 align-top">
<div class="flex flex-wrap gap-1">
@foreach($row['cases'] as $case)
{{-- Severity is the point of the list; a duplicate billing and a
missing month reading identically is what made the operator
work top-to-bottom instead of worst-first. --}}
<span class="px-1.5 py-0.5 text-[10px] rounded-full ring-1 whitespace-nowrap {{ $band($cases[$case]['severity'] ?? 9)['chip'] }}">
{{ $cases[$case]['label'] }}
</span>
@endforeach
</div>
@if(!empty($detail['over_billed_bundle']))
{{-- The figure is the reason this row is here; making the operator
open the wizard to find out how much is a wasted click. --}}
@foreach($detail['over_billed_bundle'] as $b)
<p class="mt-1 text-[11px] text-rose-700 dark:text-rose-400">
{{ \Illuminate\Support\Str::limit($b['product_name'], 26) }}:
{{ __('محاسَب') }} <span dir="ltr">{{ format_money($b['billed']) }}</span>
{{ __('وسعره') }} <span dir="ltr">{{ format_money($b['price']) }}</span> —
<span class="font-bold">{{ __('زيادة') }} <span dir="ltr">{{ format_money($b['over_billed']) }}</span></span>
</p>
@endforeach
@endif
</td>
<td class="px-3 py-2.5 align-top text-center tabular-nums whitespace-nowrap text-gray-700 dark:text-gray-300 hidden sm:table-cell">{{ $row['unpaid_invoices'] }}</td>
<td class="px-3 py-2.5 align-top text-center tabular-nums whitespace-nowrap font-bold {{ $row['owed'] > 0 ? 'text-rose-700 dark:text-rose-400' : 'text-gray-400 dark:text-gray-600' }}" dir="ltr">
{{ format_money($row['owed']) }}
</td>
<td class="px-3 py-2.5 align-top text-center tabular-nums whitespace-nowrap text-emerald-700 dark:text-emerald-400 hidden sm:table-cell" dir="ltr">{{ format_money($row['paid']) }}</td>
<td class="px-3 py-2.5 align-top text-end whitespace-nowrap">
<a href="{{ route('admin.account-settlement', ['participant' => $participant->id]) }}" wire:navigate
class="inline-flex px-3 py-1.5 bg-amber-500 hover:bg-amber-600 active:bg-amber-700 text-white text-xs font-semibold rounded-lg transition-colors">
{{ __('تسوية') }}
</a>
</td>
</tr>
@if($open)
<tr class="bg-gray-50 dark:bg-gray-950/50">
<td colspan="7" class="px-3 sm:px-5 py-4">
<div class="reveal grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-x-6 gap-y-4">
{{-- The account, in one line, before any case detail. --}}
<div class="md:col-span-2 xl:col-span-3 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-[12px] pb-3 border-b border-gray-200 dark:border-gray-800">
<span class="text-gray-500 dark:text-gray-400">{{ __('رقم المشترك') }}
<span class="font-semibold text-gray-800 dark:text-gray-200 tabular-nums" dir="ltr">{{ $participant->participant_number ?: $participant->id }}</span>
</span>
<span class="text-gray-500 dark:text-gray-400">{{ __('المحصَّل') }}
<span class="font-semibold text-emerald-700 dark:text-emerald-400 tabular-nums" dir="ltr">{{ format_money($row['paid']) }}</span>
</span>
<span class="text-gray-500 dark:text-gray-400">{{ __('المستحق') }}
<span class="font-semibold text-rose-700 dark:text-rose-400 tabular-nums" dir="ltr">{{ format_money($row['owed']) }}</span>
</span>
<span class="text-gray-500 dark:text-gray-400">{{ __('فواتير غير مسددة') }}
<span class="font-semibold text-gray-800 dark:text-gray-200 tabular-nums" dir="ltr">{{ $row['unpaid_invoices'] }}</span>
</span>
</div>
@if(!empty($detail['no_enrollment']))
@php $orphan = $detail['no_enrollment']; @endphp
<div class="md:col-span-2 xl:col-span-3">
<h4 class="text-[11px] font-bold uppercase tracking-wide text-rose-700 dark:text-rose-400">{{ __('لا يوجد له اشتراك مسجَّل') }}</h4>
<p class="mt-1.5 text-[12px] leading-relaxed text-gray-700 dark:text-gray-300 max-w-[70ch]">
{{ __('لا شيء في النظام يحاسب هذا اللاعب. آخر فاتورة له في') }}
<span class="font-semibold tabular-nums" dir="ltr">{{ $orphan['last_month'] ?? '—' }}</span>،
{{ __('وله') }} <span class="font-semibold tabular-nums" dir="ltr">{{ $orphan['invoices'] }}</span> {{ __('فاتورة سابقة.') }}
@if($orphan['last_program'])
{{ __('البرنامج المكتوب على آخر فاتورة:') }}
<span class="font-semibold text-gray-900 dark:text-gray-100">«{{ $orphan['last_program'] }}»</span> —
{{ __('سجِّله في البرنامج المقابل له اليوم.') }}
@else
{{ __('فواتيره لا تسمّي برنامجاً — راجع كشف الحضور لتعرف مجموعته.') }}
@endif
</p>
</div>
@endif
@if(!empty($detail['unbilled_months']))
<div>
<h4 class="text-[11px] font-bold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('شهور بلا فاتورة') }}</h4>
<div class="mt-2 flex flex-wrap gap-1.5">
@foreach($detail['unbilled_months'] as $month)
<span class="px-2 py-0.5 rounded-md text-[11px] bg-white dark:bg-gray-900 ring-1 ring-gray-200 dark:ring-gray-700 text-gray-700 dark:text-gray-300">
{{ $month['label'] }}
</span>
@endforeach
</div>
</div>
@endif
@if(!empty($detail['handtyped']))
<div>
<h4 class="text-[11px] font-bold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('بنود يدوية تخص منتجاً') }}</h4>
<ul class="mt-2 space-y-1.5">
@foreach($detail['handtyped'] as $line)
<li class="text-[12px] text-gray-700 dark:text-gray-300">
<span class="text-gray-500 dark:text-gray-500 tabular-nums" dir="ltr">{{ $line['invoice_number'] }}</span>
— {{ \Illuminate\Support\Str::limit($line['description'], 40) }}
<span class="font-semibold tabular-nums" dir="ltr">{{ format_money($line['amount']) }}</span>
<span class="text-gray-500 dark:text-gray-500">← {{ $line['product_name'] }}</span>
</li>
@endforeach
</ul>
</div>
@endif
@if(!empty($detail['bundle_status']))
<div>
<h4 class="text-[11px] font-bold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('مستلزمات البرنامج') }}</h4>
<ul class="mt-2 space-y-1.5">
@foreach($detail['bundle_status'] as $bundle)
<li class="text-[12px] text-gray-700 dark:text-gray-300">
<span class="font-medium">{{ \Illuminate\Support\Str::limit($bundle['product_name'], 28) }}</span>:
{{ __('مطلوب') }} <span class="tabular-nums" dir="ltr">{{ format_money($bundle['expected']) }}</span>،
{{ __('مدفوع') }} <span class="tabular-nums font-semibold text-emerald-700 dark:text-emerald-400" dir="ltr">{{ format_money($bundle['paid']) }}</span>
@if($bundle['status'] === 'paid')
<span class="text-emerald-700 dark:text-emerald-400">— {{ __('مكتمل') }}</span>
@elseif($bundle['status'] === 'missing')
<span class="text-rose-700 dark:text-rose-400">— {{ __('لم يُحاسَب عليه') }}</span>
@else
<span class="text-amber-700 dark:text-amber-400">— {{ __('متبقٍ') }}
<span class="tabular-nums" dir="ltr">{{ format_money(max(0, $bundle['expected'] - $bundle['paid'])) }}</span>
</span>
@endif
</li>
@endforeach
</ul>
</div>
@endif
@if(!empty($detail['zero_invoices']))
<div>
<h4 class="text-[11px] font-bold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('فواتير بقيمة صفر') }}</h4>
<p class="mt-2 text-[12px] text-gray-700 dark:text-gray-300">
<span class="font-semibold tabular-nums" dir="ltr">{{ $detail['zero_invoices'] }}</span>
{{ __('فاتورة صدرت بلا مبلغ — راجع سعر البرنامج قبل تصحيحها.') }}
</p>
</div>
@endif
@if(!empty($detail['duplicate_of']))
<div>
<h4 class="text-[11px] font-bold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('سجلات أخرى بنفس الاسم') }}</h4>
<ul class="mt-2 space-y-1">
@foreach($detail['duplicate_of'] as $duplicate)
<li>
<a href="{{ route('admin.account-settlement', ['participant' => $duplicate['id']]) }}" wire:navigate
class="text-[12px] text-amber-700 dark:text-amber-400 hover:underline">
{{ $duplicate['name'] }}
<span class="text-gray-500 dark:text-gray-500 tabular-nums" dir="ltr">#{{ $duplicate['id'] }}</span>
</a>
</li>
@endforeach
</ul>
</div>
@endif
{{-- What to do about the worst thing on this row, without
making the operator go and select the case first. --}}
@php $worst = collect($row['cases'])->sortBy(fn ($c) => $cases[$c]['severity'] ?? 9)->first(); @endphp
@if($worst && $caseFilter === '')
<div class="md:col-span-2 xl:col-span-3 pt-3 border-t border-gray-200 dark:border-gray-800">
<p class="text-[12px] leading-relaxed text-gray-600 dark:text-gray-400 max-w-[74ch]">
<span class="font-semibold text-gray-800 dark:text-gray-200">{{ __('الخطوة التالية:') }}</span>
{{ $cases[$worst]['steps'][0] }}
<button type="button" wire:click="selectCase('{{ $worst }}')"
class="ms-1 font-semibold text-amber-700 dark:text-amber-400 hover:underline">
{{ __('اقرأ المعالجة كاملة') }}
</button>
</p>
</div>
@endif
</div>
</td>
</tr>
@endif
@empty
<tr>
<td colspan="7" class="px-4 py-12 text-center">
@if($search !== '' || $caseFilter !== '')
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ __('لا نتائج ضمن هذا الفلتر') }}</p>
<p class="mt-1 text-[12px] text-gray-500 dark:text-gray-400">{{ __('جرّب اسماً آخر، أو اعرض كل الحالات.') }}</p>
<button type="button" wire:click="clearFilter"
class="mt-3 inline-flex px-3 py-1.5 rounded-lg text-xs font-semibold border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800">
{{ __('عرض الكل') }}
</button>
@else
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ __('لا توجد حالات تحتاج تسوية') }}</p>
<p class="mt-1 text-[12px] text-gray-500 dark:text-gray-400 max-w-[52ch] mx-auto leading-relaxed">
{{ __('كل حساب في هذا الفرع متسق: لا مال حُصِّل بلا تسجيل، ولا شهر بلا فاتورة، ولا لاعب بلا اشتراك. تُعاد القراءة مع كل فتح للصفحة.') }}
</p>
@endif
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -3,8 +3,12 @@ ...@@ -3,8 +3,12 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Domain\Financial\Models\Invoice; use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Services\AccountAnomalyScanner;
use App\Domain\Participant\Models\Participant; use App\Domain\Participant\Models\Participant;
use App\Livewire\Admin\SettlementWorklist;
use App\Models\User; use App\Models\User;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
use Tests\TestCase; use Tests\TestCase;
/** /**
...@@ -55,6 +59,100 @@ public function test_the_worklist_can_be_filtered_to_one_case(): void ...@@ -55,6 +59,100 @@ public function test_the_worklist_can_be_filtered_to_one_case(): void
$response->assertSee('لم يُسجَّل له أي دفع', escape: false); $response->assertSee('لم يُسجَّل له أي دفع', escape: false);
} }
public function test_selecting_a_case_shows_how_it_is_handled(): void
{
// The guidance is the point of the console. An operator who cannot see
// what a case means skips it, and a skipped case is money — so the
// meaning, the cause and the numbered handling all have to be on the
// page, not one click further in.
$response = $this->actingAs($this->anAdmin())
->get(route('admin.settlement-worklist', ['case' => 'never_paid']));
$response->assertOk();
$response->assertSee('كيف تُعالَج', escape: false);
$response->assertSee('لماذا تحدث', escape: false);
$response->assertSee('راجع دفتر الكاش', escape: false);
}
public function test_the_case_rail_keeps_every_count_while_one_case_is_selected(): void
{
// The scanner's own `only` filter would have dropped the other cases
// from the result entirely, so the rail beside a selected case would
// read zero everywhere — which an operator reads as "those are fixed",
// not "those are hidden".
$response = $this->actingAs($this->anAdmin())
->get(route('admin.settlement-worklist', ['case' => 'never_paid']));
$response->assertOk();
$response->assertSee('متأخرات متراكمة', escape: false);
$response->assertSee('لا يوجد له اشتراك مسجَّل', escape: false);
}
public function test_the_worklist_surfaces_players_with_no_enrolment(): void
{
// The case nobody could find by looking: renewal billing reads
// `enrollments` and nothing else, so a player with no row there is not
// billed late — he is not billed at all, and no other screen lists him.
$orphans = collect(app(AccountAnomalyScanner::class)->scan())
->filter(fn ($row) => in_array('no_enrollment', $row['cases'], true));
if ($orphans->isEmpty()) {
$this->markTestSkipped('This tenant snapshot has no orphaned accounts.');
}
// The programme is read off the last invoice line, because the enrolment
// that knew it is gone — and it is the one fact the operator needs to
// put the player back.
$this->assertNotNull(
$orphans->first()['detail']['no_enrollment']['last_program'] ?? null,
'An orphan whose invoices name a programme must carry it into the row.'
);
$response = $this->actingAs($this->anAdmin())
->get(route('admin.settlement-worklist', ['case' => 'no_enrollment']));
$response->assertOk();
$response->assertSee('سجل الاشتراك هو الشيء الوحيد', escape: false);
// The programme column falls back to the last invoice's wording and says
// so, rather than printing an em-dash and leaving the operator to guess.
$response->assertSee('من فاتورة سابقة — لا اشتراك', escape: false);
$response->assertDontSee('لا توجد حالات تحتاج تسوية', escape: false);
}
public function test_a_row_opens_its_evidence_without_leaving_the_list(): void
{
// The detail block reads eight different shapes out of the scanner's
// `detail` array. A renamed key there is a 500 the moment an operator
// clicks a row, and nothing but rendering it catches that.
$rows = app(AccountAnomalyScanner::class)->scan();
if ($rows === []) {
$this->markTestSkipped('This tenant snapshot has no flagged accounts.');
}
$component = Livewire::actingAs($this->anAdmin())
->test(SettlementWorklist::class);
// Every flagged account, not just the first: each case contributes its
// own detail block, and one broken branch is one operator's dead end.
foreach (array_slice($rows, 0, 40) as $row) {
$component->call('toggleRow', $row['participant']->id)
->assertOk()
->assertSee('رقم المشترك', escape: false);
}
}
public function test_the_open_row_cannot_be_pointed_at_another_account(): void
{
// `expanded` reveals a participant's own money, so it is #[Locked]:
// settable from the browser it would be an id to aim somewhere else.
$this->expectException(CannotUpdateLockedPropertyException::class);
Livewire::actingAs($this->anAdmin())
->test(SettlementWorklist::class)
->set('expanded', 1);
}
public function test_the_wizard_opens_on_a_participant_and_shows_the_account(): void public function test_the_wizard_opens_on_a_participant_and_shows_the_account(): void
{ {
// A live participant: an invoice whose player was later deleted must // A live participant: an invoice whose player was later deleted must
......
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