Commit e5ce6f7f authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(portal): consent, deletion, requests that take effect, and the seeder bug...

feat(portal): consent, deletion, requests that take effect, and the seeder bug that would have erased it all

The additions the addendum marked P0 and the programme had not built, plus
two ordering bugs found by testing a from-scratch install rather than only the
incremental one.

The seeder bug (would have broken the portal on the second deploy)
------------------------------------------------------------------
PermissionSeeder deletes every permission_role row for a role and reinserts
its own list, and db:seed runs on EVERY container start when
RUN_SEED_ON_FIRST_DEPLOY is true. So the portal.* grants added by migration
would have worked exactly until the next deploy and then vanished — the
portal 403'ing for every member, with nothing in the logs and no migration to
blame.

And on a brand-new client the migration runs before any academy exists, so it
created no player role and granted nothing at all.

Both are fixed where they belong: the permissions and the `player` role are in
PermissionSeeder and RolesAndPermissionsSeeder now, so a fresh install gets
them and the seeder stops erasing them. The migration stays for existing
tenants. Verified by migrating an empty database from zero, seeding it, then
booting it a second time and re-checking every grant.

attendance.scan reaches trainers, head trainers and reception — the people who
actually stand at a gate. payments.approve_proof reaches accountants.

B2 — consent and deletion (a store-submission blocker)
------------------------------------------------------
Apple 5.1.1(v) and Google both refuse an app that creates accounts and cannot
delete them, so this is what makes a submission possible rather than a
refinement to add later.

There was no consent record anywhere in the schema — not a column — while this
product publishes children's photographs on a public website and sends
marketing over WhatsApp. Consents are versioned and append-only, enforced by a
database trigger: a consent is a statement about a particular text at a
particular moment, so editing one destroys the only thing that makes it
evidence. Withdrawal is a new row. Bumping the document version invalidates
previous answers, because a boolean would silently claim a member agreed to
text they have never seen.

Deletion is redaction, not erasure. This is also an accounting system:
invoices, payments and ledger rows are the academy's books, and a member must
not be able to delete them by tapping a button. The person's identifying data
is destroyed, the login is destroyed, the financial record survives without
their name. Three gates before that: re-authentication, a cooling-off window,
and a blocked state with the reason shown when money is owed or an enrolment
is live — shown up front, because a refusal at the last step is not respectful.

Data export is the other half of the same obligation and is streamed, never
stored: a file of somebody's whole record sitting on disk waiting to be
collected is a second copy of the data they asked to control.

B4 + E7 — requests that actually do something
---------------------------------------------
`grep -rln ServiceRequest` found a model, an event, a listener and a provider,
and no admin screen. Approving a freeze never called ParticipantService::freeze()
— the column changed and the subscription kept running. A member was told
their subscription was frozen when it was not.

Approval is now defined by its effect, and the effect runs in the same
transaction: if it fails the approval fails with it and the request stays
pending. "Approved, and nothing happened" is worse than "still pending".

E7 decided: an excuse is a service_request, never a direct attendance write.
Two contradictory implementations existed and neither worked — ParentExcuseForm
validated, stored its medical attachment to the PUBLIC disk, then discarded the
record behind a `// TODO` while telling the parent it had succeeded; and the
deleted API wrote status='excused' with no marker and no check that the session
belonged to the participant, so a player could excuse himself and corrupt every
attendance figure the product reports. Approval goes through
AttendanceMarkingService with the approving staff member as marker, and a
coach's existing observation is never overwritten. ParentExcuseForm is deleted:
it never worked, so there was nothing to preserve.

B1, B3, B5, B7, B11, B12, B13
------------------------------
- Document upload and renewal. The admin half has been complete for a long
  time — DocumentApprovalList, a nightly documents:expire, a
  MedicalCertificateAlert — and the member half did not exist, so a certificate
  expired at 06:00 and the member had no way inside the product to fix it.
- Payable instalments. reminders:installments and push:installment-due fire
  daily and the only payment path ever built charged the whole due_amount: the
  push said pay and the app could not. Settled from the wallet, which is the
  one payment the portal can complete immediately — money the academy already
  holds.
- Waitlist accept/decline. The offer, the expiry and the push all existed with
  no accept surface anywhere, so the offer expired and the place went to nobody.
- Renewal surface, for RenewalPolicy::ManualRenew, which explicitly means a
  human decides.
- can_authorize_payment gates instalment payment as well as proof submission.
- Every member upload is streamed from the private disk with attachment,
  nosniff and no-store. A member upload is never a URL.
- /health asserts the schema this code needs and names what is missing.
  Verified against a deliberately half-migrated database: 503, and
  ["payment_proofs","invoices.branch_id"].

/parent retired
---------------
Permanent redirects to the equivalent portal screen, parameters preserved so a
bookmarked invoice still lands on that invoice. Two member portals must not
coexist: they diverge, and the one nobody updates is the one a member has
bookmarked.

E2 recorded in config/compliance.php: 18, hardcoded rather than per-academy,
because a settings row nobody tunes is a false choice and a twelve year old
must never open the app and see the household's arrears.

Verification
------------
- 144 migrations from zero on an empty database, then db:seed, then a second
  boot — every grant intact.
- The same on the restored oc_sport tenant: nothing to migrate, seed clean,
  713 invoices / 356 participants / 649 payments untouched.
- 11 portal screens render 200 for a real member; 4 staff screens for an owner;
  members refused on both staff screens.
- Suite: 76 pass on SQLite; on the tenant, PortalSmoke 3/3, AdminScreens 2/2,
  PaymentProof 11/11, CheckInScan 10/10, ServiceRequestEffect 14/14.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent fb2519c6
<?php
namespace App\Domain\Compliance\Enums;
/**
* What a member is being asked to agree to.
*
* Each is a separate decision on purpose. Bundling "we may publish your child's
* photograph" into "you accept the terms of service" is how consent becomes
* meaningless, and this product does publish children's photographs on a public
* website.
*/
enum ConsentType: string
{
case Terms = 'terms';
case Privacy = 'privacy';
case PhotoMedia = 'photo_media';
case Marketing = 'marketing';
case MedicalTreatment = 'medical_treatment';
case DataProcessing = 'data_processing';
public function label(): string
{
return match ($this) {
self::Terms => 'شروط الاستخدام',
self::Privacy => 'سياسة الخصوصية',
self::PhotoMedia => 'نشر الصور ومقاطع الفيديو',
self::Marketing => 'الرسائل التسويقية',
self::MedicalTreatment => 'الإسعاف والتدخل الطبي',
self::DataProcessing => 'معالجة البيانات الشخصية',
};
}
public function description(): string
{
return match ($this) {
self::Terms => 'قواعد الاشتراك والحضور والدفع.',
self::Privacy => 'ما نجمعه من بيانات وكيف نستخدمه.',
self::PhotoMedia => 'استخدام صور العضو ومقاطع الفيديو على موقع الأكاديمية وحساباتها. يمكن سحب الموافقة في أي وقت.',
self::Marketing => 'العروض والأخبار عبر واتساب. لا يشمل إشعارات الحصص والمستحقات.',
self::MedicalTreatment => 'التصرف الطبي العاجل عند وقوع إصابة أثناء التدريب.',
self::DataProcessing => 'حفظ بيانات العضو ومعالجتها لإدارة الاشتراك.',
};
}
/**
* Without these the academy cannot lawfully run the membership at all, so
* they gate the portal. Everything else is genuinely optional, and an
* optional consent that blocks the app is not optional.
*/
public function isRequired(): bool
{
return match ($this) {
self::Terms, self::Privacy, self::DataProcessing => true,
default => false,
};
}
/** @return array<int, self> */
public static function required(): array
{
return array_values(array_filter(self::cases(), fn (self $case) => $case->isRequired()));
}
}
<?php
namespace App\Domain\Compliance\Models;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AccountDeletionRequest extends Model
{
use HasUuid, BelongsToAcademy, Auditable;
protected $fillable = [
'academy_id', 'user_id', 'status', 'reason',
'requested_at', 'eligible_at', 'completed_at', 'cancelled_at',
'handled_by', 'admin_notes', 'blockers', 'request_ip',
];
protected function casts(): array
{
return [
'requested_at' => 'datetime',
'eligible_at' => 'datetime',
'completed_at' => 'datetime',
'cancelled_at' => 'datetime',
'blockers' => 'array',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isOpen(): bool
{
return in_array($this->status, ['pending', 'blocked'], true);
}
public function statusLabel(): string
{
return match ($this->status) {
'pending' => 'قيد المعالجة',
'blocked' => 'موقوف — يوجد ما يمنع الحذف',
'completed' => 'تم الحذف',
'cancelled' => 'أُلغي الطلب',
default => $this->status,
};
}
}
<?php
namespace App\Domain\Compliance\Models;
use App\Domain\Compliance\Enums\ConsentType;
use App\Domain\Identity\Models\Person;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One statement, about one document version, at one moment.
*
* Append-only, enforced by a database trigger. Withdrawal is a new row with
* `granted = false`; editing the old one would destroy the only thing that
* makes it evidence.
*/
class Consent extends Model
{
use HasUuid, BelongsToAcademy;
public const UPDATED_AT = null;
protected $fillable = [
'academy_id', 'participant_id', 'given_by', 'person_id',
'type', 'granted', 'document_version', 'document_hash',
'granted_at', 'ip_address', 'user_agent', 'metadata',
];
protected function casts(): array
{
return [
'type' => ConsentType::class,
'granted' => 'boolean',
'granted_at' => 'datetime',
'metadata' => 'array',
];
}
public function participant(): BelongsTo
{
return $this->belongsTo(Participant::class);
}
public function giver(): BelongsTo
{
return $this->belongsTo(User::class, 'given_by');
}
public function person(): BelongsTo
{
return $this->belongsTo(Person::class);
}
}
This diff is collapsed.
<?php
namespace App\Domain\Compliance\Services;
use App\Domain\Compliance\Enums\ConsentType;
use App\Domain\Compliance\Models\Consent;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Recording, reading and withdrawing consent.
*
* The current state of a consent is "the most recent row for this participant
* and this type", never a boolean column somewhere. That is what makes the
* history readable: a photo consent granted in March and withdrawn in July is
* two rows, and an academy that published a photograph in May can show it was
* entitled to.
*/
class ConsentService
{
/**
* The version of the consent text currently in force.
*
* A consent is a statement about a *particular* document. When the text
* changes the version changes, previously recorded consents stop counting
* as current, and members are asked again — which is the only honest
* behaviour, and is why this is not a boolean.
*/
public function currentVersion(): string
{
return (string) config('compliance.consent_version', '1.0');
}
public function record(
ConsentType $type,
bool $granted,
User $givenBy,
?Participant $participant = null,
?string $ip = null,
?string $userAgent = null,
): Consent {
// A guardian may only speak for members they actually speak for. The
// portal checks this too, but a consent is exactly the kind of record
// that must not depend on the caller having remembered to.
if ($participant) {
$allowed = app(\App\Domain\Identity\Services\GuardianResolver::class)
->participantIdsFor($givenBy);
if (! in_array((int) $participant->id, $allowed, true)) {
throw new DomainException('لا تملك صلاحية تسجيل موافقة عن هذا العضو');
}
}
return Consent::create([
'academy_id' => $givenBy->academy_id,
'participant_id' => $participant?->id,
'given_by' => $givenBy->id,
'person_id' => $givenBy->person_id,
'type' => $type,
'granted' => $granted,
'document_version' => $this->currentVersion(),
'document_hash' => $this->documentHash($type),
'granted_at' => now(),
'ip_address' => $ip,
'user_agent' => $userAgent ? substr($userAgent, 0, 255) : null,
]);
}
/**
* The current answer for each type: the latest row, for the version in
* force. A consent recorded against an older text is history, not an answer.
*
* @return array<string, bool>
*/
public function currentFor(User $user, ?Participant $participant = null): array
{
$rows = Consent::withoutGlobalScopes()
->where('academy_id', $user->academy_id)
->where('document_version', $this->currentVersion())
->when(
$participant,
fn ($q) => $q->where('participant_id', $participant->id),
fn ($q) => $q->whereNull('participant_id')->where('given_by', $user->id),
)
->orderByDesc('granted_at')
->orderByDesc('id')
->get(['type', 'granted']);
$current = [];
foreach ($rows as $row) {
$key = $row->type instanceof ConsentType ? $row->type->value : (string) $row->type;
// First row wins: the query is newest-first.
$current[$key] ??= (bool) $row->granted;
}
return $current;
}
/**
* Which required consents are still outstanding.
*
* @return Collection<int, ConsentType>
*/
public function outstandingFor(User $user, ?Participant $participant = null): Collection
{
$current = $this->currentFor($user, $participant);
return collect(ConsentType::required())
->reject(fn (ConsentType $type) => ($current[$type->value] ?? false) === true)
->values();
}
public function hasGranted(User $user, ConsentType $type, ?Participant $participant = null): bool
{
return ($this->currentFor($user, $participant)[$type->value] ?? false) === true;
}
/**
* Record several answers at once — the shape of an onboarding screen.
*
* @param array<string, bool> $answers type value => granted
*/
public function recordMany(array $answers, User $givenBy, ?Participant $participant = null, ?string $ip = null, ?string $userAgent = null): void
{
DB::transaction(function () use ($answers, $givenBy, $participant, $ip, $userAgent) {
foreach ($answers as $value => $granted) {
$type = ConsentType::tryFrom((string) $value);
if (! $type) {
continue;
}
if ($type->isRequired() && ! $granted) {
throw new DomainException(
'لا يمكن متابعة الاشتراك دون الموافقة على: ' . $type->label()
);
}
$this->record($type, (bool) $granted, $givenBy, $participant, $ip, $userAgent);
}
});
}
/**
* A fingerprint of the text that was actually shown.
*
* Without it, "version 1.2" is just a label somebody typed; with it, the
* exact wording a member agreed to can be proven years later.
*/
private function documentHash(ConsentType $type): string
{
return substr(hash('sha256', $this->currentVersion() . '|' . $type->value . '|' . $type->description()), 0, 32);
}
}
......@@ -17,6 +17,8 @@ class ServiceRequest extends Model
'academy_id',
'user_id',
'participant_id',
'training_session_id',
'attendance_record_id',
'type',
'status',
'reason',
......@@ -24,13 +26,61 @@ class ServiceRequest extends Model
'admin_notes',
'handled_by',
'handled_at',
'attachment_path',
'attachment_mime',
'attachment_size',
'effect_applied_at',
'effect_error',
];
protected $casts = [
'metadata' => 'array',
'handled_at' => 'datetime',
'effect_applied_at' => 'datetime',
'attachment_size' => 'integer',
];
public const TYPES = ['freeze', 'unfreeze', 'transfer', 'cancellation', 'excuse', 'renewal', 'other'];
public function typeLabel(): string
{
return match ($this->type) {
'freeze' => 'تجميد الاشتراك',
'unfreeze' => 'إعادة تفعيل الاشتراك',
'transfer' => 'نقل إلى فرع آخر',
'cancellation' => 'إلغاء الاشتراك',
'excuse' => 'عذر عن حصة',
'renewal' => 'تجديد الاشتراك',
default => 'طلب آخر',
};
}
public function statusLabel(): string
{
return match ($this->status) {
'pending' => 'قيد المراجعة',
'approved' => 'تمت الموافقة',
'rejected' => 'مرفوض',
'cancelled' => 'ملغى',
default => $this->status,
};
}
public function isOpen(): bool
{
return $this->status === 'pending';
}
public function trainingSession(): BelongsTo
{
return $this->belongsTo(\App\Domain\Training\Models\TrainingSession::class);
}
public function attendanceRecord(): BelongsTo
{
return $this->belongsTo(\App\Domain\Attendance\Models\AttendanceRecord::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
......
This diff is collapsed.
......@@ -5,9 +5,48 @@
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class HealthController extends Controller
{
/**
* Schema facts this deploy cannot run without.
*
* The entrypoint now fails the boot on a migration error, which is the
* primary defence. This is the second one, and it answers a different
* question: not "did migrate exit non-zero" but "is the schema this code
* was written against actually here". A container that booted from a
* half-applied migration, or a database restored from an older dump, looks
* perfectly healthy until a member hits the screen that needs the column.
*
* Each entry is a column some code path dereferences without checking. The
* difference between this and no check is finding out in minutes rather
* than hearing it from a client in a month.
*
* @var array<string, array<int, string>>
*/
private const REQUIRED_SCHEMA = [
'academies' => ['branding_version', 'address'],
'invoices' => ['branch_id'],
'invoice_items' => ['academy_id'],
'transactions' => ['branch_id'],
'installments' => ['academy_id'],
'participants' => ['checkin_key_version'],
'users' => ['email_is_synthetic'],
'device_tokens' => ['user_agent'],
'service_requests' => ['training_session_id', 'attachment_path'],
'website_news' => ['channel'],
];
/** @var array<int, string> */
private const REQUIRED_TABLES = [
'portal_invitations',
'payment_proofs',
'checkin_consumptions',
'invoice_number_counters',
'consents',
'account_deletion_requests',
];
public function __invoke(): JsonResponse
{
$checks = [];
......@@ -26,15 +65,56 @@ public function __invoke(): JsonResponse
$checks['cache'] = 'fail';
}
$checks['schema'] = $this->schemaCheck($missing);
if ($missing !== []) {
// Named, because "schema: fail" sends someone reading migrations
// for an hour and "missing invoices.branch_id" does not.
$checks['schema_missing'] = $missing;
}
$checks['php_version'] = PHP_VERSION;
$checks['laravel_version'] = app()->version();
$checks['timestamp'] = now()->toIso8601String();
$allOk = !in_array('fail', $checks);
$allOk = ! in_array('fail', $checks, true);
return response()->json([
'status' => $allOk ? 'healthy' : 'degraded',
'checks' => $checks,
], $allOk ? 200 : 503);
}
/**
* @param array<int, string> $missing filled with what is absent
*/
private function schemaCheck(?array &$missing = null): string
{
$missing = [];
try {
foreach (self::REQUIRED_TABLES as $table) {
if (! Schema::hasTable($table)) {
$missing[] = $table;
}
}
foreach (self::REQUIRED_SCHEMA as $table => $columns) {
if (! Schema::hasTable($table)) {
$missing[] = $table;
continue;
}
foreach ($columns as $column) {
if (! Schema::hasColumn($table, $column)) {
$missing[] = "{$table}.{$column}";
}
}
}
} catch (\Throwable) {
return 'fail';
}
return $missing === [] ? 'ok' : 'fail';
}
}
<?php
namespace App\Http\Controllers;
use App\Domain\Document\Models\Document;
use App\Domain\Identity\Services\GuardianResolver;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Models\ServiceRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Streams the files members upload — request attachments and documents — from
* the private disk, to the people entitled to see them.
*
* There is one rule and everything else follows from it: **a member upload is
* never a URL**. The deleted mobile API returned
* `asset('storage/'.$file_path)` for documents stored on the private disk,
* which either 404s or — if anyone "fixed" it by moving the files to the public
* disk — serves national IDs and medical records to whoever guesses a path. The
* parent excuse form did move its medical attachments to the public disk, which
* is why S0 had to sweep them.
*
* Every response is an attachment with `nosniff` and `no-store`: a file the
* browser decides is HTML must not execute in the academy's own origin, and a
* medical certificate must not sit in a shared proxy's cache.
*/
class MemberFileController extends Controller
{
public function requestAttachment(Request $request, string $uuid): StreamedResponse
{
$serviceRequest = ServiceRequest::where('uuid', $uuid)->firstOrFail();
abort_unless($this->maySee($request, $serviceRequest->participant_id, $serviceRequest->user_id), 403);
abort_unless($serviceRequest->attachment_path, 404);
return $this->stream($serviceRequest->attachment_path, 'request-' . $serviceRequest->uuid);
}
public function document(Request $request, string $uuid): StreamedResponse
{
$document = Document::where('uuid', $uuid)->firstOrFail();
$participantId = $document->documentable_type === Participant::class
? (int) $document->documentable_id
: null;
abort_unless($this->maySee($request, $participantId, $document->uploaded_by), 403);
return $this->stream($document->file_path, 'document-' . $document->uuid);
}
/**
* The uploader, anyone who speaks for the member the file is about, and
* staff holding the matching view permission.
*/
private function maySee(Request $request, ?int $participantId, ?int $uploaderId): bool
{
$user = $request->user();
if (! $user) {
return false;
}
if ($uploaderId && (int) $uploaderId === (int) $user->id) {
return true;
}
if ($user->can('documents.view') || $user->can('participants.update')) {
return true;
}
return $participantId
&& in_array((int) $participantId, app(GuardianResolver::class)->participantIdsFor($user), true);
}
private function stream(string $path, string $name): StreamedResponse
{
$disk = Storage::disk('local');
abort_unless($disk->exists($path), 404);
return $disk->response($path, $name . '.' . pathinfo($path, PATHINFO_EXTENSION), [
'Content-Disposition' => 'attachment',
'X-Content-Type-Options' => 'nosniff',
'Cache-Control' => 'private, no-store',
]);
}
}
<?php
namespace App\Livewire\Parent;
use App\Domain\Identity\Models\Guardian;
use App\Domain\Identity\Services\GuardianResolver;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Models\TrainingSession;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithFileUploads;
#[Layout('layouts.parent')]
#[Title('تقديم عذر غياب - بوابة ولي الأمر')]
class ParentExcuseForm extends Component
{
use WithFileUploads;
public ?int $participantId = null;
public ?int $sessionId = null;
public string $excuseType = '';
public string $description = '';
public $attachment = null;
public function mount(?int $participantId = null, ?int $sessionId = null): void
{
$childrenIds = $this->getChildrenIds();
if ($participantId) {
if (! in_array($participantId, $childrenIds)) {
abort(403, __('ليس لديك صلاحية تقديم عذر لهذا المشترك'));
}
$this->participantId = $participantId;
} else {
$this->participantId = session('active_child_id', $childrenIds[0] ?? null);
}
if ($sessionId) {
$this->sessionId = $sessionId;
}
}
public function rules(): array
{
return [
'participantId' => 'required|integer|exists:participants,id',
'sessionId' => 'nullable|integer|exists:training_sessions,id',
'excuseType' => 'required|in:medical,family,travel,academic,other',
'description' => 'required|string|min:10|max:500',
'attachment' => 'nullable|file|max:5120|mimes:pdf,jpg,jpeg,png',
];
}
public function messages(): array
{
return [
'participantId.required' => 'يجب اختيار المشترك',
'participantId.exists' => 'المشترك غير موجود',
'sessionId.exists' => 'الحصة غير موجودة',
'excuseType.required' => 'يجب اختيار نوع العذر',
'excuseType.in' => 'نوع العذر غير صالح',
'description.required' => 'يجب كتابة وصف العذر',
'description.min' => 'الوصف يجب أن يكون 10 أحرف على الأقل',
'description.max' => 'الوصف يجب ألا يتجاوز 500 حرف',
'attachment.max' => 'حجم المرفق يجب ألا يتجاوز 5 ميجابايت',
'attachment.mimes' => 'المرفق يجب أن يكون PDF أو صورة',
];
}
public function submit(): void
{
$this->validate();
// Verify participant belongs to guardian
$childrenIds = $this->getChildrenIds();
if (! in_array($this->participantId, $childrenIds)) {
session()->flash('error', __('ليس لديك صلاحية تقديم عذر لهذا المشترك'));
return;
}
// Verify session belongs to participant's group (if specified)
if ($this->sessionId) {
$participant = Participant::find($this->participantId);
$groupIds = $participant->activeEnrollments()->pluck('training_group_id')->toArray();
$session = TrainingSession::find($this->sessionId);
if (! $session || ! in_array($session->training_group_id, $groupIds)) {
session()->flash('error', __('الحصة غير مرتبطة بمجموعات هذا المشترك'));
return;
}
}
// There is no Excuse model or table yet, so there is nowhere to record this.
//
// What used to happen here: the attachment — typically a child's medical
// note — was written to the PUBLIC disk, giving it a guessable
// unauthenticated URL, and then the form flashed success and discarded
// everything else. The parent believed the absence was excused, the
// academy never saw it, and the attendance record stayed 'absent' and fed
// the consecutive-absence threshold that auto-suspends a participant.
//
// Storing nothing and saying so is the only honest behaviour until the
// request is modelled properly (planned as a service_requests type, so the
// approval path reuses AttendanceMarkingService with a staff marker rather
// than writing attendance directly).
session()->flash('error', __('تقديم الأعذار من التطبيق غير متاح حاليًا. برجاء التواصل مع الأكاديمية لتسجيل العذر.'));
}
public function render()
{
$childrenIds = $this->getChildrenIds();
$children = Participant::whereIn('id', $childrenIds)
->with('person')
->get();
// Get recent sessions for the selected child (for session selector)
$recentSessions = collect();
if ($this->participantId) {
$participant = Participant::find($this->participantId);
if ($participant) {
$groupIds = $participant->activeEnrollments()->pluck('training_group_id')->toArray();
$recentSessions = TrainingSession::whereIn('training_group_id', $groupIds)
->where('session_date', '>=', now()->subDays(14)->toDateString())
->where('session_date', '<=', now()->toDateString())
->with('group')
->orderByDesc('session_date')
->limit(20)
->get();
}
}
$excuseTypes = [
'medical' => __('طبي'),
'family' => __('عائلي'),
'travel' => __('سفر'),
'academic' => __('دراسي'),
'other' => __('أخرى'),
];
return view('livewire.parent.parent-excuse-form', [
'children' => $children,
'recentSessions' => $recentSessions,
'excuseTypes' => $excuseTypes,
]);
}
private function getGuardian(): Guardian
{
$user = auth()->user();
// Display only. Authorisation and scoping go through GuardianResolver:
// ->first() on a non-unique column meant a guardian holding two rows
// saw one set of children and was 403'd on the rest, and an adult
// member with no guardian row hard-failed on every screen here.
return app(GuardianResolver::class)->guardiansFor($user)->first()
?? Guardian::where('user_id', $user->id)->firstOrFail();
}
private function getChildrenIds(): array
{
return app(GuardianResolver::class)->participantIdsFor(auth()->user());
}
}
<?php
namespace App\Livewire\Portal;
use App\Domain\Document\Enums\DocumentStatus;
use App\Domain\Document\Enums\DocumentType;
use App\Domain\Document\Models\Document;
use App\Domain\Participant\Models\Participant;
use App\Livewire\Portal\Concerns\PortalScreen;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithFileUploads;
/**
* Uploading and renewing the member's own documents.
*
* The admin half of this has been complete for a long time —
* `DocumentApprovalList`, a nightly `documents:expire`, a
* `MedicalCertificateAlert`. The member half did not exist at all, and the
* deleted API's DocumentController was index-only. So a medical certificate
* expired at 06:00 on a Tuesday and the member had no way, inside the product,
* to do anything about it before a coach turned them away at the gate.
*/
#[Layout('layouts.portal')]
#[Title('المستندات')]
class PortalDocuments extends Component
{
use PortalScreen, WithFileUploads;
public string $documentType = 'medical_certificate';
public string $expiresAt = '';
public $file;
public function mount(): void
{
$this->authorizePortal('portal.documents');
}
protected function rules(): array
{
return [
'documentType' => ['required', 'in:' . implode(',', array_column(DocumentType::cases(), 'value'))],
// A medical certificate without an expiry is a certificate nobody
// can act on: the nightly job has nothing to compare against.
'expiresAt' => ['nullable', 'date', 'after:today'],
'file' => ['required', 'file', 'max:6144', 'mimes:png,jpg,jpeg,webp,pdf'],
];
}
protected function messages(): array
{
return [
'file.required' => __('اختر الملف أولاً'),
'file.mimes' => __('الملف يجب أن يكون صورة أو PDF'),
'file.max' => __('حجم الملف يتجاوز ٦ ميجابايت'),
'expiresAt.after' => __('تاريخ الانتهاء يجب أن يكون في المستقبل'),
];
}
public function upload(): void
{
$this->validate();
$participantId = $this->activeParticipantId();
// The private `local` disk, and a streaming controller to read it back.
// The deleted API returned asset('storage/'.$path) for documents stored
// on the private disk — a URL that either 404s or, if anyone had
// "fixed" it by moving the files, would serve national IDs and medical
// records to whoever guessed the path.
$path = $this->file->store(
'documents/' . app('current_academy')->id . '/' . $participantId,
'local'
);
Document::create([
'academy_id' => app('current_academy')->id,
'documentable_type' => Participant::class,
'documentable_id' => $participantId,
'document_type' => $this->documentType,
'file_path' => $path,
'original_filename' => $this->file->getClientOriginalName(),
'mime_type' => $this->file->getMimeType(),
'file_size' => $this->file->getSize(),
// Uploaded, not approved. A member cannot approve their own
// medical certificate any more than they can approve their own
// payment.
'status' => DocumentStatus::Pending,
'expires_at' => $this->expiresAt ?: null,
'uploaded_by' => auth()->id(),
'created_by' => auth()->id(),
]);
$this->reset(['file', 'expiresAt']);
session()->flash('success', __('تم رفع المستند — سيراجعه فريق الأكاديمية'));
}
public function render()
{
$familyIds = $this->familyParticipantIds();
return view('livewire.portal.portal-documents', [
'documents' => Document::where('documentable_type', Participant::class)
->whereIn('documentable_id', $familyIds)
->with('documentable.person')
->orderByDesc('created_at')
->get(),
'types' => DocumentType::cases(),
'activeName' => $this->portal()->activeParticipant()?->person?->name_ar,
]);
}
}
<?php
namespace App\Livewire\Portal;
use App\Domain\Financial\Models\Installment;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Wallet;
use App\Domain\Financial\Services\PaymentService;
use App\Domain\Financial\Services\WalletService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Enums\WaitlistResponse;
use App\Domain\Training\Models\Waitlist;
use App\Livewire\Portal\Concerns\PortalScreen;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\Attributes\Url;
/**
* The three things the product already sends a push about every day and gave
* the member nowhere to act on.
*
* - **Instalments (B3).** `reminders:installments` and `push:installment-due`
* fire daily, and the only payment path ever built charged the whole
* `due_amount`. The push said pay and the app could not.
* - **Waitlist offers (B5).** `waitlists.notified_at/expires_at/response`,
* `WaitlistSpotAvailable` and `SendWaitlistSpotPush` all exist with no
* accept surface anywhere: the push fires, the offer expires, the place goes
* to nobody and the revenue with it.
* - **Renewals (B7).** `enrollments:generate-renewals` bills daily and
* `RenewalPolicy::ManualRenew` explicitly means a human decides — with
* nowhere to decide it.
*
* Paying from the wallet is here too, because a balance a member can see and
* cannot spend is worse than no balance at all.
*/
#[Layout('layouts.portal')]
#[Title('المستحقات')]
class PortalDues extends Component
{
use PortalScreen;
#[Url(as: 'tab')]
public string $tab = 'installments';
#[Locked]
public ?int $payingInstallmentId = null;
public function mount(): void
{
$this->authorizePortal();
}
public function updatedTab(): void
{
if (! in_array($this->tab, ['installments', 'offers'], true)) {
$this->tab = 'installments';
}
}
/**
* Settle one instalment from the member's wallet.
*
* Only from the wallet: a card gateway is feature-flagged off and an
* InstaPay transfer is a proof, not an instant payment. What this does is
* move money that is already the academy's, held on the member's behalf —
* which is why it can be immediate.
*/
public function payInstallmentFromWallet(int $installmentId, PaymentService $payments, WalletService $wallets): void
{
$this->authorize('portal.pay');
$installment = Installment::whereHas('paymentPlan.invoice', function ($q) {
$q->whereIn('billable_id', $this->familyParticipantIds())
->where('billable_type', Participant::class);
})->findOrFail($installmentId);
$invoice = $installment->paymentPlan?->invoice;
if (! $invoice) {
$this->addError('pay', __('لا توجد فاتورة مرتبطة بهذا القسط'));
return;
}
// can_authorize_payment has existed since 2024 with zero readers. This
// is one of the two places it finally gates something.
if (! $this->portal()->mayPayFor((int) $invoice->billable_id)) {
$this->addError('pay', __('لا تملك صلاحية الدفع عن هذا العضو'));
return;
}
$participant = Participant::find($invoice->billable_id);
if (! $participant) {
return;
}
$wallet = Wallet::where('owner_type', Participant::class)
->where('owner_id', $participant->id)
->first();
$amount = min((int) $installment->amount, (int) $invoice->due_amount);
if (! $wallet || (int) $wallet->balance < $amount) {
$this->addError('pay', __('رصيد المحفظة لا يكفي لسداد هذا القسط'));
return;
}
try {
\Illuminate\Support\Facades\DB::transaction(function () use ($wallets, $payments, $wallet, $amount, $invoice, $installment, $participant) {
$wallets->withdraw($wallet, $amount, 'سداد قسط من المحفظة: ' . $invoice->number, $invoice, auth()->user());
$payment = $payments->recordPayment([
'academy_id' => $invoice->academy_id,
// Branch at the service boundary: a payment with no branch
// lands in the all-branches total and in no branch.
'branch_id' => $invoice->branch_id ?: $participant->branch_id,
'invoice_id' => $invoice->id,
'amount' => $amount,
'method' => 'wallet',
'direction' => 'inbound',
'currency' => $invoice->currency ?: 'EGP',
'payment_date' => now()->toDateString(),
'metadata' => ['source' => 'portal_wallet', 'installment_id' => $installment->id],
], auth()->user());
Installment::where('id', $installment->id)
->whereIn('status', ['pending', 'overdue'])
->update(['status' => 'paid', 'paid_at' => now(), 'payment_id' => $payment->id, 'updated_at' => now()]);
});
} catch (DomainException $e) {
$this->addError('pay', $e->getMessage());
return;
}
session()->flash('success', __('تم سداد القسط من المحفظة'));
}
/**
* Accept or decline a waitlist place.
*
* The conditional UPDATE is the control: an offer that has expired, or that
* somebody already answered, matches zero rows rather than being answered
* twice.
*/
public function respondToOffer(int $waitlistId, string $response): void
{
if (! in_array($response, ['accepted', 'declined'], true)) {
return;
}
$updated = Waitlist::where('id', $waitlistId)
->whereIn('participant_id', $this->familyParticipantIds())
->whereNotNull('notified_at')
->whereNull('responded_at')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->update([
'response' => $response,
'responded_at' => now(),
'updated_at' => now(),
]);
if ($updated === 0) {
$this->addError('offer', __('انتهت صلاحية هذا العرض أو تم الرد عليه بالفعل'));
return;
}
session()->flash('success', $response === 'accepted'
// Accepting reserves the intent, not the place: enrolling is a
// staff action with money attached, and pretending otherwise would
// create an enrolment nobody has paid for.
? __('تم تسجيل موافقتك — ستتواصل معك الإدارة لإتمام التسجيل')
: __('تم تسجيل اعتذارك عن المقعد'));
}
public function render()
{
$familyIds = $this->familyParticipantIds();
$installments = Installment::whereHas('paymentPlan.invoice', function ($q) use ($familyIds) {
$q->whereIn('billable_id', $familyIds)->where('billable_type', Participant::class);
})
->whereIn('status', ['pending', 'overdue'])
->with('paymentPlan.invoice')
->orderBy('due_date')
->get();
$walletBalances = Wallet::where('owner_type', Participant::class)
->whereIn('owner_id', $familyIds)
->pluck('balance', 'owner_id');
return view('livewire.portal.portal-dues', [
'installments' => $installments,
'walletBalances' => $walletBalances,
'payableIds' => $this->portal()->payableParticipantIds(),
'offers' => Waitlist::whereIn('participant_id', $familyIds)
->whereNotNull('notified_at')
->whereNull('responded_at')
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->with(['group.program', 'participant.person'])
->get(),
'renewals' => Invoice::whereIn('billable_id', $familyIds)
->where('billable_type', Participant::class)
->whereIn('status', ['sent', 'overdue', 'partially_paid'])
->where('metadata->source', 'renewal')
->get(),
]);
}
}
<?php
namespace App\Livewire\Portal;
use App\Domain\Compliance\Enums\ConsentType;
use App\Domain\Compliance\Models\AccountDeletionRequest;
use App\Domain\Compliance\Services\AccountDeletionService;
use App\Domain\Compliance\Services\ConsentService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Livewire\Portal\Concerns\PortalScreen;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
* Consent, data export, and deleting the account.
*
* Apple 5.1.1(v) and Google both refuse an app that creates accounts without
* letting them be deleted from inside the app, so this screen is what makes a
* store submission possible at all — not a refinement to add afterwards.
*
* It is also the only place in this product where a member can see what they
* agreed to and change their mind. Before this there was no consent record
* anywhere in the schema, while the product published children's photographs
* on a public website.
*/
#[Layout('layouts.portal')]
#[Title('الخصوصية والبيانات')]
class PortalPrivacy extends Component
{
use PortalScreen;
/** @var array<string, bool> */
public array $consents = [];
public bool $confirmingDeletion = false;
public string $deletionReason = '';
public string $deletionPassword = '';
public function mount(ConsentService $service): void
{
$this->authorizePortal();
$current = $service->currentFor(auth()->user(), $this->portal()->activeParticipant());
foreach (ConsentType::cases() as $type) {
$this->consents[$type->value] = $current[$type->value] ?? false;
}
}
public function saveConsents(ConsentService $service): void
{
try {
$service->recordMany(
$this->consents,
auth()->user(),
$this->portal()->activeParticipant(),
request()->ip(),
request()->userAgent(),
);
} catch (DomainException $e) {
$this->addError('consents', $e->getMessage());
return;
}
session()->flash('success', __('تم حفظ تفضيلاتك'));
}
/**
* The export is generated on demand and streamed, never stored. A file of
* somebody's entire record sitting on disk waiting to be collected is a
* second copy of exactly the data they asked to control.
*/
public function export(AccountDeletionService $service)
{
$payload = $service->export(auth()->user());
$name = 'my-data-' . now()->format('Y-m-d') . '.json';
return response()->streamDownload(
fn () => print(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)),
$name,
['Content-Type' => 'application/json; charset=utf-8'],
);
}
public function requestDeletion(AccountDeletionService $service): void
{
try {
$service->request(
auth()->user(),
$this->deletionReason ?: null,
$this->deletionPassword,
request()->ip(),
);
} catch (DomainException $e) {
$this->addError('deletionPassword', $e->getMessage());
return;
}
$this->reset(['deletionPassword', 'deletionReason', 'confirmingDeletion']);
session()->flash('success', __('تم استلام طلب حذف الحساب'));
}
public function cancelDeletion(AccountDeletionService $service): void
{
$request = AccountDeletionRequest::where('user_id', auth()->id())
->whereIn('status', ['pending', 'blocked'])
->first();
if ($request) {
$service->cancel($request);
session()->flash('success', __('تم إلغاء طلب الحذف'));
}
}
public function render()
{
$deletion = AccountDeletionRequest::where('user_id', auth()->id())
->whereIn('status', ['pending', 'blocked'])
->first();
return view('livewire.portal.portal-privacy', [
'types' => ConsentType::cases(),
'deletion' => $deletion,
// Shown up front rather than after the member commits: "you cannot
// delete this yet, and here is exactly why" is respectful; a
// refusal at the last step is not.
'blockers' => app(AccountDeletionService::class)->blockers(auth()->user()),
]);
}
}
<?php
namespace App\Livewire\Portal;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Models\ServiceRequest;
use App\Domain\Shared\Services\ServiceRequestService;
use App\Domain\Training\Models\TrainingSession;
use App\Livewire\Portal\Concerns\PortalScreen;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithFileUploads;
/**
* Where a member asks the academy for something: a freeze, a transfer, a
* cancellation, or an excuse from a session.
*
* The excuse form this replaces validated its input, stored the attachment, and
* then flashed "تم تقديم العذر بنجاح" while discarding the excuse behind a
* `// TODO`. Nothing was recorded and nobody at the academy ever saw it. This
* one writes a real request into a real queue that a real screen reads.
*/
#[Layout('layouts.portal')]
#[Title('تقديم طلب')]
class PortalRequests extends Component
{
use PortalScreen, WithFileUploads;
public string $type = 'excuse';
#[Locked]
public ?int $sessionId = null;
public string $reason = '';
public ?int $branchId = null;
public $attachment;
public function mount(?int $session = null): void
{
$this->authorizePortal('portal.requests');
// Arrives from the training screen's "قدّم عذر" on a session. It is
// #[Locked] and re-validated in the service against the member's own
// groups — a session id from the browser is an input, not a fact.
$this->sessionId = $session;
}
protected function rules(): array
{
return [
'type' => ['required', 'in:excuse,freeze,unfreeze,transfer,cancellation,renewal,other'],
'reason' => ['required', 'string', 'min:5', 'max:1000'],
'sessionId' => ['nullable', 'integer'],
'branchId' => ['nullable', 'integer'],
'attachment' => ['nullable', 'file', 'max:4096', 'mimes:png,jpg,jpeg,webp,pdf'],
];
}
protected function messages(): array
{
return [
'type.required' => __('اختر نوع الطلب'),
'reason.required' => __('اكتب سبب الطلب'),
'reason.min' => __('السبب قصير جداً'),
'attachment.mimes' => __('المرفق يجب أن يكون صورة أو PDF'),
'attachment.max' => __('حجم المرفق يتجاوز ٤ ميجابايت'),
];
}
public function submit(ServiceRequestService $requests): void
{
$this->validate();
if ($this->type === 'excuse' && ! $this->sessionId) {
$this->addError('sessionId', __('اختر الحصة التي تريد الاعتذار عنها'));
return;
}
$path = null;
if ($this->attachment) {
// Private disk. A medical note is the most sensitive thing this
// product holds, and the form this replaces wrote them to the
// PUBLIC disk — orphaned, unauthenticated, undeletable child
// medical records served off the academy's own certificate.
$path = $this->attachment->store(
'requests/' . app('current_academy')->id,
'local'
);
}
try {
$requests->open([
'type' => $this->type,
'participant_id' => $this->activeParticipantId(),
'training_session_id' => $this->sessionId,
'reason' => $this->reason,
'attachment_path' => $path,
'attachment_mime' => $this->attachment?->getMimeType(),
'attachment_size' => $this->attachment?->getSize(),
'metadata' => $this->branchId ? ['branch_id' => $this->branchId] : [],
], auth()->user());
} catch (DomainException $e) {
if ($path) {
\Illuminate\Support\Facades\Storage::disk('local')->delete($path);
}
$this->addError('reason', $e->getMessage());
return;
}
session()->flash('success', __('تم إرسال الطلب — ستصلك النتيجة عند مراجعته'));
$this->redirect(route('portal.account', ['tab' => 'requests']), navigate: true);
}
public function render()
{
$participantId = $this->activeParticipantId();
$participant = $this->portal()->activeParticipant();
$groupIds = $participant
? $participant->activeEnrollments()->pluck('training_group_id')->all()
: [];
// Only sessions an excuse can still be filed against, so the form
// cannot offer something the service will refuse.
$sessions = $groupIds === [] ? collect() : TrainingSession::whereIn('training_group_id', $groupIds)
->whereBetween('session_date', [now()->subDays(7)->toDateString(), now()->addDays(30)->toDateString()])
->with('group')
->orderBy('session_date')
->limit(40)
->get();
return view('livewire.portal.portal-requests', [
'sessions' => $sessions,
'branches' => \App\Domain\Identity\Models\Branch::where('is_active', true)->get(),
'open' => ServiceRequest::where('participant_id', $participantId)
->where('status', 'pending')
->count(),
]);
}
}
<?php
namespace App\Livewire\Requests;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Exceptions\InvalidStatusTransitionException;
use App\Domain\Shared\Models\ServiceRequest;
use App\Domain\Shared\Services\ServiceRequestService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
/**
* One queue for every member request — freezes, transfers, cancellations,
* renewals and excuses.
*
* One queue because they are one interaction: member-initiated,
* pending/approved/rejected, with notes coming back. Two tables would have
* meant two screens, and two screens is how you get one of them built and the
* other left as a `// TODO` — which is precisely the state this replaces.
*
* Approving here has a real effect: the service applies it inside the same
* transaction, so a request cannot end up marked approved with nothing having
* happened. That was the previous behaviour, and a member told their
* subscription was frozen when it was not is worse than a slow queue.
*/
#[Layout('layouts.app')]
#[Title('طلبات الأعضاء')]
class ServiceRequestQueue extends Component
{
use WithPagination;
#[Url(as: 'status')]
public string $status = 'pending';
#[Url(as: 'type')]
public string $type = '';
#[Locked]
public ?int $handlingId = null;
public string $notes = '';
public function mount(): void
{
$this->authorize('participants.update');
}
public function updatedStatus(): void
{
$this->resetPage();
}
public function updatedType(): void
{
$this->resetPage();
}
public function handle(int $requestId): void
{
$this->authorize('participants.update');
$this->handlingId = $this->inScope($requestId)->id;
$this->notes = '';
}
public function cancelHandling(): void
{
$this->handlingId = null;
}
public function approve(ServiceRequestService $requests): void
{
$this->authorize('participants.update');
try {
$requests->approve($this->inScope($this->handlingId), auth()->user(), $this->notes ?: null);
session()->flash('success', __('تمت الموافقة وتنفيذ الطلب'));
} catch (InvalidStatusTransitionException|DomainException $e) {
// The effect failed, so the approval did not stand — the request is
// still pending and the reason is on screen.
$this->addError('notes', $e->getMessage());
return;
}
$this->handlingId = null;
}
public function reject(ServiceRequestService $requests): void
{
$this->authorize('participants.update');
$this->validate(
['notes' => ['required', 'string', 'max:1000']],
['notes.required' => __('اكتب سبب الرفض — سيراه العضو')],
);
try {
$requests->reject($this->inScope($this->handlingId), auth()->user(), $this->notes);
session()->flash('success', __('تم رفض الطلب'));
} catch (InvalidStatusTransitionException $e) {
$this->addError('notes', $e->getMessage());
return;
}
$this->handlingId = null;
}
public function render()
{
$query = ServiceRequest::query()
->when($this->status !== '', fn ($q) => $q->where('status', $this->status))
->when($this->type !== '', fn ($q) => $q->where('type', $this->type))
->with(['participant.person', 'user', 'trainingSession.group', 'handler'])
->orderBy('created_at');
return view('livewire.requests.service-request-queue', [
'requests' => $query->paginate(20),
'pendingCount' => ServiceRequest::where('status', 'pending')->count(),
'handling' => $this->handlingId
? ServiceRequest::with(['participant.person', 'trainingSession.group'])->find($this->handlingId)
: null,
'types' => ServiceRequest::TYPES,
]);
}
private function inScope(?int $requestId): ServiceRequest
{
abort_if($requestId === null, 400);
// The id comes from the browser; the tenant scope is a global scope but
// this re-reads through it rather than trusting the rendered list.
return ServiceRequest::findOrFail($requestId);
}
}
<?php
return [
/*
|--------------------------------------------------------------------------
| Consent document version
|--------------------------------------------------------------------------
|
| A consent is a statement about a PARTICULAR text at a particular moment.
| When the wording of the terms, the privacy policy or the media-release
| changes, bump this — previously recorded consents stop counting as
| current and members are asked again.
|
| That is the only honest behaviour, and it is why consent is a versioned
| row rather than a boolean column: a boolean would silently claim a member
| agreed to text they have never seen.
|
*/
'consent_version' => env('CONSENT_VERSION', '1.0'),
/*
|--------------------------------------------------------------------------
| Age of majority (E2)
|--------------------------------------------------------------------------
|
| Below this a member cannot act for themselves in the portal: no
| self-service enrolment, no requests, no visibility of what the family
| owes. Hardcoded at 18 rather than made per-academy configurable, because
| a settings row nobody tunes is a false choice — and because a twelve
| year old must never open the app and see the household's arrears.
|
| Money stays guardian-gated at every age whenever any guardian holds
| `can_authorize_payment`.
|
*/
'age_of_majority' => 18,
];
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Consent, and the right to leave.
*
* Two things this product does today that it has no record of permission for:
* it publishes children's photographs on a public website, and it sends
* marketing over WhatsApp. There is no consent record anywhere in the schema —
* not a column, not a table.
*
* And Apple 5.1.1(v) and Google both refuse an app that lets an account be
* created without letting it be deleted from inside the app. That is not a
* nice-to-have on the native shell; it is the difference between a submission
* being reviewed and being rejected.
*
* Consents are **versioned and append-only**. A consent is a statement about a
* particular text at a particular moment, so updating a row would destroy the
* only thing that makes it evidence. Withdrawal is a new row, not an edit.
*
* A deletion request is a request, not a deletion. This is a financial system:
* invoices, payments and ledger rows must survive a member leaving, so the
* request is reviewed, personal data is redacted, and the accounting record
* stays. Anything else would let a member erase an academy's books.
*/
return new class extends Migration
{
private const TYPES = [
'terms', 'privacy', 'photo_media', 'marketing', 'medical_treatment', 'data_processing',
];
public function up(): void
{
if (! Schema::hasTable('consents')) {
Schema::create('consents', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained()->cascadeOnDelete();
// Who the consent is ABOUT — usually a child.
$table->foreignId('participant_id')->nullable()->constrained()->cascadeOnDelete();
// Who GAVE it — the adult who is answerable for that decision.
$table->foreignId('given_by')->constrained('users');
$table->foreignId('person_id')->nullable()->constrained('people')->nullOnDelete();
$table->string('type', 32);
$table->boolean('granted');
$table->string('document_version', 20);
$table->text('document_hash')->nullable();
$table->timestamp('granted_at');
$table->string('ip_address', 45)->nullable();
$table->string('user_agent', 255)->nullable();
$table->jsonb('metadata')->default('{}');
// No updated_at, deliberately: see the class comment.
$table->timestamp('created_at')->nullable();
$table->index(['academy_id', 'participant_id', 'type']);
$table->index(['academy_id', 'type', 'granted_at']);
});
if (DB::getDriverName() === 'pgsql') {
$list = implode(', ', array_map(fn ($t) => "'{$t}'", self::TYPES));
DB::statement("ALTER TABLE consents ADD CONSTRAINT consents_type_check CHECK (type IN ({$list}))");
// Append-only in the database, not merely by convention. A
// consent that can be edited is not evidence of anything.
DB::unprepared(<<<'SQL'
CREATE OR REPLACE FUNCTION consents_are_append_only() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'consents are append-only; record a new row to withdraw';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER consents_no_update
BEFORE UPDATE OR DELETE ON consents
FOR EACH ROW EXECUTE FUNCTION consents_are_append_only();
SQL);
}
}
if (! Schema::hasTable('account_deletion_requests')) {
Schema::create('account_deletion_requests', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('status', 20)->default('pending');
$table->text('reason')->nullable();
// A deletion cannot be undone, so it does not happen the moment
// an angry parent taps a button. The window is the cooling-off
// period, and it is also how long an outstanding balance has to
// be settled.
$table->timestamp('requested_at');
$table->timestamp('eligible_at');
$table->timestamp('completed_at')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->foreignId('handled_by')->nullable()->constrained('users')->nullOnDelete();
$table->text('admin_notes')->nullable();
$table->jsonb('blockers')->default('[]');
$table->string('request_ip', 45)->nullable();
$table->timestamps();
$table->index(['academy_id', 'status', 'eligible_at']);
});
if (DB::getDriverName() === 'pgsql') {
DB::statement("ALTER TABLE account_deletion_requests ADD CONSTRAINT account_deletion_requests_status_check CHECK (status IN ('pending','blocked','completed','cancelled'))");
DB::statement("CREATE UNIQUE INDEX account_deletion_requests_one_open ON account_deletion_requests (user_id) WHERE status IN ('pending','blocked')");
}
}
// A redaction marker on people, so a completed deletion is visible
// without having to reconstruct it from the request table.
if (Schema::hasTable('people') && ! Schema::hasColumn('people', 'redacted_at')) {
Schema::table('people', function (Blueprint $table) {
$table->timestamp('redacted_at')->nullable()->after('deleted_at');
});
}
}
public function down(): void
{
if (DB::getDriverName() === 'pgsql') {
DB::unprepared('DROP TRIGGER IF EXISTS consents_no_update ON consents; DROP FUNCTION IF EXISTS consents_are_append_only();');
}
Schema::dropIfExists('account_deletion_requests');
Schema::dropIfExists('consents');
if (Schema::hasTable('people') && Schema::hasColumn('people', 'redacted_at')) {
Schema::table('people', function (Blueprint $table) {
$table->dropColumn('redacted_at');
});
}
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* E7, decided: an excuse lives in `service_requests`.
*
* An excuse and a freeze request are the same interaction — member-initiated,
* pending/approved/rejected, with `admin_notes` back. A separate `excuses`
* table would mean two queues, two admin screens and two half-built workflows,
* which is exactly the state this replaces.
*
* Today there are two contradictory implementations and neither works:
*
* - `ParentExcuseForm` validates the request, stores the attachment, then
* flashes "تم تقديم العذر بنجاح. سيتم مراجعته من قبل الإدارة" while
* discarding the excuse entirely behind a `// TODO`. There is no Excuse
* model and no table. A parent believes the absence was excused; nobody at
* the academy ever sees it.
* - The deleted API's `POST /v1/absences/report` wrote `status='excused'`
* straight into `attendance_records` with no marker, no transition check,
* no audit, and no check that the session even belonged to the participant.
* A player could excuse himself, which corrupts EnforceAttendanceThresholds,
* SuspendOnThreshold and every attendance-rate figure in the product.
*
* So: an excuse is a **request**, never a direct attendance write. Approval
* calls AttendanceMarkingService with the approving staff member as the marker,
* which is the one attendance write path and stays the one.
*/
return new class extends Migration
{
private const TYPES = ['freeze', 'unfreeze', 'transfer', 'cancellation', 'excuse', 'renewal', 'other'];
public function up(): void
{
if (! Schema::hasTable('service_requests')) {
return;
}
Schema::table('service_requests', function (Blueprint $table) {
if (! Schema::hasColumn('service_requests', 'training_session_id')) {
$table->foreignId('training_session_id')->nullable()->after('participant_id')
->constrained()->nullOnDelete();
}
if (! Schema::hasColumn('service_requests', 'attendance_record_id')) {
$table->foreignId('attendance_record_id')->nullable()->after('training_session_id')
->constrained()->nullOnDelete();
}
// Where the member's supporting file lives. Private disk only: a
// medical note is the most sensitive thing this product handles,
// and the form it replaces wrote them to the PUBLIC disk — orphaned,
// unauthenticated, undeletable child medical records on the
// academy's own TLS certificate.
if (! Schema::hasColumn('service_requests', 'attachment_path')) {
$table->string('attachment_path')->nullable();
$table->string('attachment_mime', 60)->nullable();
$table->unsignedBigInteger('attachment_size')->nullable();
}
if (! Schema::hasColumn('service_requests', 'effect_applied_at')) {
// Approving a freeze must actually freeze something. Nothing in
// the codebase reads ServiceRequest beyond the model, the event
// and the listener — approval has never had a domain effect, so
// this records when one was applied.
$table->timestamp('effect_applied_at')->nullable();
$table->string('effect_error', 255)->nullable();
}
});
if (DB::getDriverName() !== 'pgsql') {
return;
}
$list = implode(', ', array_map(fn ($t) => "'{$t}'", self::TYPES));
DB::statement('ALTER TABLE service_requests DROP CONSTRAINT IF EXISTS service_requests_type_check');
DB::statement("ALTER TABLE service_requests ADD CONSTRAINT service_requests_type_check CHECK (type IN ({$list}))");
// One open excuse per session per participant. A member tapping twice
// on a bad connection must not fill the queue with duplicates.
DB::statement("CREATE UNIQUE INDEX IF NOT EXISTS service_requests_one_open_excuse
ON service_requests (academy_id, participant_id, training_session_id)
WHERE type = 'excuse' AND status = 'pending'");
Schema::table('service_requests', function (Blueprint $table) {
$table->index(['academy_id', 'status', 'type'], 'service_requests_academy_status_type_index');
});
}
public function down(): void
{
if (! Schema::hasTable('service_requests')) {
return;
}
if (DB::getDriverName() === 'pgsql') {
DB::statement('DROP INDEX IF EXISTS service_requests_one_open_excuse');
DB::statement('ALTER TABLE service_requests DROP CONSTRAINT IF EXISTS service_requests_type_check');
DB::statement("ALTER TABLE service_requests ADD CONSTRAINT service_requests_type_check CHECK (type IN ('freeze','unfreeze','transfer','cancellation','other'))");
}
Schema::table('service_requests', function (Blueprint $table) {
$table->dropIndex('service_requests_academy_status_type_index');
$table->dropConstrainedForeignId('training_session_id');
$table->dropConstrainedForeignId('attendance_record_id');
$table->dropColumn(['attachment_path', 'attachment_mime', 'attachment_size', 'effect_applied_at', 'effect_error']);
});
}
};
......@@ -77,6 +77,15 @@ public function run(): void
public static function getPermissionsList(): array
{
return [
// Member portal. These MUST be here and not only in the migration
// that introduced them: this seeder deletes every permission_role
// row for a role and reinserts its own list, and db:seed runs on
// EVERY container start when RUN_SEED_ON_FIRST_DEPLOY is true — so
// a permission the seeder does not know about is granted once and
// erased at the next deploy.
'portal.access', 'portal.pay', 'portal.documents', 'portal.requests',
'payments.approve_proof', 'attendance.scan', 'users.merge',
// Dashboard
'dashboard.view', 'dashboard.export',
......@@ -227,6 +236,7 @@ private function getRolePermissions(): array
'accountant' => $this->accountantPermissions(),
'data_entry' => $this->dataEntryPermissions(),
'parent' => $this->parentPermissions(),
'player' => $this->playerPermissions(),
];
}
......@@ -286,7 +296,7 @@ private function headTrainerPermissions(): array
$branchPerms = [
'sessions.cancel', 'sessions.reschedule', 'sessions.generate',
'schedules.list', 'schedules.manage',
'attendance.edit', 'attendance.export',
'attendance.edit', 'attendance.export', 'attendance.scan',
'evaluations.manage', 'evaluations.approve', 'evaluations.share',
'groups.manage_enrollments',
'assignments.list', 'assignments.create',
......@@ -311,6 +321,7 @@ private function trainerPermissions(): array
'sessions.complete' => 'own_groups',
'attendance.list' => 'own_groups',
'attendance.mark' => 'own_groups',
'attendance.scan' => 'own_groups',
'attendance.view_reports' => 'own_groups',
'groups.list' => 'own_groups',
'groups.show' => 'own_groups',
......@@ -341,6 +352,7 @@ private function receptionistPermissions(): array
'enrollments.create' => 'branch',
'activities.list' => 'academy',
'programs.list' => 'academy',
'attendance.scan' => 'branch',
'pos.access' => 'branch',
'pos.sell' => 'branch',
'pos.list' => 'branch',
......@@ -381,6 +393,10 @@ private function accountantPermissions(): array
'payment_plans.list', 'payment_plans.create', 'payment_plans.update',
'cash_sessions.open', 'cash_sessions.close', 'cash_sessions.list', 'cash_sessions.manage',
'refunds.initiate', 'refunds.approve',
// Approving a transfer proof is the moral equivalent of taking
// cash; the maker-checker rule in PaymentProofService is what
// stops it being a one-person act.
'payments.approve_proof',
'daily_closing.create', 'daily_closing.view',
'expenses.create', 'expenses.list', 'expenses.view',
'reports.financial', 'reports.view', 'reports.export_pdf', 'reports.export_excel',
......@@ -430,6 +446,40 @@ private function parentPermissions(): array
'programs.list' => 'academy',
'schedules.view' => 'own_children',
'excuses.submit' => 'own_children',
'portal.access' => 'own_children',
'portal.pay' => 'own_children',
'portal.documents' => 'own_children',
'portal.requests' => 'own_children',
];
}
/**
* A member who plays for himself.
*
* Same scope as a parent, because `own_children` means "the participants
* this account speaks for" — which GuardianResolver resolves to the account
* holder's own participant row when there is no guardian. Without this role
* a player given the `parent` role sees empty lists everywhere.
*/
private function playerPermissions(): array
{
return [
'dashboard.view' => 'academy',
'programs.list' => 'academy',
'attendance.list' => 'own_children',
'participants.view' => 'own_children',
'participants.show' => 'own_children',
'evaluations.list' => 'own_children',
'invoices.list' => 'own_children',
'invoices.view' => 'own_children',
'payments.list' => 'own_children',
'wallets.view' => 'own_children',
'schedules.view' => 'own_children',
'excuses.submit' => 'own_children',
'portal.access' => 'own_children',
'portal.pay' => 'own_children',
'portal.documents' => 'own_children',
'portal.requests' => 'own_children',
];
}
}
......@@ -88,6 +88,11 @@ private function seedRoles(Academy $academy): void
['slug' => 'accountant', 'name' => 'Accountant', 'name_ar' => 'محاسب', 'level' => 35, 'is_system' => true],
['slug' => 'data_entry', 'name' => 'Data Entry', 'name_ar' => 'مدخل بيانات', 'level' => 30, 'is_system' => true],
['slug' => 'parent', 'name' => 'Parent', 'name_ar' => 'ولي الأمر', 'level' => 5, 'is_system' => true],
// Same level as parent: both are member-facing and neither outranks
// the other. A player without this role gets `parent`, whose scope
// resolves only through a Guardian row — and a player has none, so
// every list comes back empty.
['slug' => 'player', 'name' => 'Player', 'name_ar' => 'لاعب', 'level' => 5, 'is_system' => true],
];
foreach ($roles as $role) {
......
<div>
{{-- Success Flash --}}
@if(session()->has('success'))
<div class="mb-4 p-4 rounded-2xl border border-green-200 bg-green-50">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-[#059669] shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="text-sm font-medium text-[#059669]">{{ session('success') }}</p>
</div>
</div>
@endif
{{-- Back Button --}}
<div class="mb-4">
<a href="{{ route('parent.attendance') }}" wire:navigate
class="inline-flex items-center gap-1.5 text-sm text-[#64748B] hover:text-[#2563EB] transition-colors min-h-[44px]">
<svg class="w-4 h-4 rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
{{ __('الحضور') }}
</a>
</div>
{{-- Title --}}
<h1 class="text-xl font-bold text-[#0F172A] mb-6">{{ __('تقديم عذر') }}</h1>
<form wire:submit="submit" class="space-y-5">
{{-- Child Selector --}}
<div>
<label for="child_id" class="block text-sm font-medium text-[#0F172A] mb-2">{{ __('الابن/الابنة') }}</label>
<select
wire:model.live="child_id"
id="child_id"
class="w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-[#0F172A] focus:border-[#2563EB] focus:ring-2 focus:ring-[#2563EB]/20 outline-none min-h-[44px]"
>
<option value="">{{ __('اختر الابن/الابنة') }}</option>
@foreach($children ?? [] as $child)
<option value="{{ $child['id'] ?? '' }}">{{ $child['name'] ?? '' }}</option>
@endforeach
</select>
@error('child_id')
<p class="text-xs text-[#DC2626] mt-1">{{ $message }}</p>
@enderror
</div>
{{-- Session Selector --}}
<div>
<label for="session_id" class="block text-sm font-medium text-[#0F172A] mb-2">{{ __('الحصة') }}</label>
<select
wire:model="session_id"
id="session_id"
class="w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-[#0F172A] focus:border-[#2563EB] focus:ring-2 focus:ring-[#2563EB]/20 outline-none min-h-[44px]"
{{ empty($sessions) ? 'disabled' : '' }}
>
<option value="">{{ __('اختر الحصة') }}</option>
@foreach($sessions ?? [] as $session)
<option value="{{ $session['id'] ?? '' }}">
{{ $session['label'] ?? '' }}
</option>
@endforeach
</select>
@error('session_id')
<p class="text-xs text-[#DC2626] mt-1">{{ $message }}</p>
@enderror
@if(empty($sessions) && $child_id)
<p class="text-xs text-[#64748B] mt-1">{{ __('لا توجد حصص متاحة لهذا الابن') }}</p>
@endif
</div>
{{-- Excuse Type --}}
<div>
<label class="block text-sm font-medium text-[#0F172A] mb-3">{{ __('نوع العذر') }}</label>
@php
$excuseTypes = [
'medical' => 'مرضي',
'family' => 'عائلي',
'travel' => 'سفر',
'academic' => 'دراسي',
'other' => 'آخر',
];
@endphp
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
@foreach($excuseTypes as $key => $label)
<label class="relative cursor-pointer">
<input
type="radio"
wire:model="excuse_type"
value="{{ $key }}"
class="peer sr-only"
>
<div class="flex items-center justify-center px-4 py-3 rounded-xl border border-gray-200 text-sm text-[#64748B] min-h-[44px]
peer-checked:border-[#2563EB] peer-checked:bg-[#2563EB]/5 peer-checked:text-[#2563EB] peer-checked:font-medium
hover:border-gray-300 transition-all">
{{ __($label) }}
</div>
</label>
@endforeach
</div>
@error('excuse_type')
<p class="text-xs text-[#DC2626] mt-1">{{ $message }}</p>
@enderror
</div>
{{-- Description --}}
<div>
<label for="description" class="block text-sm font-medium text-[#0F172A] mb-2">{{ __('الوصف') }}</label>
<textarea
wire:model="description"
id="description"
rows="4"
class="w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-[#0F172A] focus:border-[#2563EB] focus:ring-2 focus:ring-[#2563EB]/20 outline-none resize-none"
placeholder="{{ __('اكتب تفاصيل العذر هنا...') }}"
></textarea>
@error('description')
<p class="text-xs text-[#DC2626] mt-1">{{ $message }}</p>
@enderror
</div>
{{-- File Attachment --}}
<div>
<label for="attachment" class="block text-sm font-medium text-[#0F172A] mb-2">{{ __('مرفق (شهادة طبية أو مستند)') }}</label>
<div class="relative">
<input
type="file"
wire:model="attachment"
id="attachment"
class="w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-[#64748B] file:me-3 file:py-1 file:px-3 file:rounded-lg file:border-0 file:bg-[#2563EB]/10 file:text-[#2563EB] file:text-xs file:font-medium file:cursor-pointer focus:border-[#2563EB] focus:ring-2 focus:ring-[#2563EB]/20 outline-none min-h-[44px]"
accept=".pdf,.jpg,.jpeg,.png"
>
<div wire:loading wire:target="attachment" class="absolute end-3 top-1/2 -translate-y-1/2">
<svg class="animate-spin w-4 h-4 text-[#2563EB]" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
@error('attachment')
<p class="text-xs text-[#DC2626] mt-1">{{ $message }}</p>
@enderror
<p class="text-[10px] text-[#64748B] mt-1">{{ __('الملفات المقبولة: PDF, JPG, PNG (حد أقصى 5 ميجابايت)') }}</p>
</div>
{{-- Submit Button --}}
<div class="pt-2">
<button
type="submit"
wire:loading.attr="disabled"
wire:target="submit"
class="w-full bg-[#2563EB] text-white font-medium py-3.5 px-6 rounded-2xl hover:bg-blue-700 transition-colors min-h-[44px] disabled:opacity-50"
>
<span wire:loading.remove wire:target="submit">{{ __('إرسال العذر') }}</span>
<span wire:loading wire:target="submit">{{ __('جارٍ الإرسال...') }}</span>
</button>
</div>
</form>
</div>
......@@ -83,6 +83,12 @@
</x-portal.card>
@endforelse
<a href="{{ route('portal.documents') }}" wire:navigate
class="block rounded-xl px-4 py-3 text-center text-sm font-bold"
style="background: var(--brand-500); color: var(--brand-fg);">
{{ __('رفع مستند') }}
</a>
@elseif($tab === 'requests')
@forelse($requests as $request)
@php
......@@ -117,22 +123,39 @@
<p class="mt-1 text-[11px]" style="color: var(--portal-muted);">{{ $request->created_at?->diffForHumans() }}</p>
</article>
@empty
<x-portal.card><x-portal.empty :title="__('لا توجد طلبات')" /></x-portal.card>
<x-portal.card>
<x-portal.empty :title="__('لا توجد طلبات')"
:body="__('التجميد والنقل والإلغاء والأعذار — كلها من هنا')" />
</x-portal.card>
@endforelse
<a href="{{ route('portal.requests.create') }}" wire:navigate
class="block rounded-xl px-4 py-3 text-center text-sm font-bold"
style="background: var(--brand-500); color: var(--brand-fg);">
{{ __('تقديم طلب جديد') }}
</a>
@else
<x-portal.card :title="__('الإعدادات')">
<ul class="divide-y" style="border-color: var(--portal-border);">
<li>
<a href="{{ route('portal.notifications') }}" wire:navigate
class="flex items-center justify-between py-3 text-sm font-semibold">
{{ __('الإشعارات') }}
<svg class="h-4 w-4 rtl:rotate-180" fill="none" stroke="currentColor" stroke-width="2"
viewBox="0 0 24 24" aria-hidden="true" style="color: var(--portal-muted);">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
</svg>
</a>
</li>
@foreach([
['portal.notifications', 'الإشعارات'],
['portal.dues', 'الأقساط والعروض'],
['portal.documents', 'المستندات'],
['portal.requests.create', 'تقديم طلب'],
['portal.privacy', 'الخصوصية والبيانات'],
] as [$route, $label])
<li>
<a href="{{ route($route) }}" wire:navigate
class="flex items-center justify-between py-3 text-sm font-semibold">
{{ __($label) }}
<svg class="h-4 w-4 rtl:rotate-180" fill="none" stroke="currentColor" stroke-width="2"
viewBox="0 0 24 24" aria-hidden="true" style="color: var(--portal-muted);">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
</svg>
</a>
</li>
@endforeach
<li>
<form method="POST" action="{{ route('logout') }}">
@csrf
......
<div class="space-y-4">
<form wire:submit="upload" class="portal-card space-y-4 px-4 py-4">
<div>
<h2 class="text-sm font-bold">{{ __('رفع مستند') }}</h2>
@if($activeName)
<p class="mt-0.5 text-[11px]" style="color: var(--portal-muted);">{{ __('لـ') }} {{ $activeName }}</p>
@endif
</div>
<div>
<label for="doctype" class="block text-xs font-semibold">{{ __('نوع المستند') }}</label>
<select id="doctype" wire:model="documentType"
class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
@foreach($types as $t)
<option value="{{ $t->value }}">{{ $t->label() }}</option>
@endforeach
</select>
</div>
<div>
<label for="expires" class="block text-xs font-semibold">
{{ __('تاريخ الانتهاء') }}
<span class="font-normal" style="color: var(--portal-muted);">— {{ __('للشهادة الطبية') }}</span>
</label>
<input id="expires" type="date" dir="ltr" wire:model="expiresAt"
class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
@error('expiresAt') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div>
<div>
<label for="docfile" class="block text-xs font-semibold">{{ __('الملف') }}</label>
<input id="docfile" type="file" wire:model="file" accept="image/*,application/pdf"
class="mt-1 block w-full text-xs">
<div wire:loading wire:target="file" class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('جارٍ الرفع...') }}
</div>
@error('file') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div>
<button type="submit" wire:loading.attr="disabled" wire:target="upload,file"
class="w-full rounded-xl px-4 py-3 text-sm font-bold disabled:opacity-60"
style="background: var(--brand-500); color: var(--brand-fg);">
<span wire:loading.remove wire:target="upload">{{ __('رفع المستند') }}</span>
<span wire:loading wire:target="upload">{{ __('جارٍ الرفع...') }}</span>
</button>
</form>
@forelse($documents as $document)
@php
$expired = $document->expires_at?->isPast();
$soon = ! $expired && $document->expires_at && $document->expires_at->lte(now()->addDays(30));
$tone = $expired ? 'var(--brand-danger)' : ($soon ? 'var(--brand-warning)' : 'var(--brand-success)');
@endphp
<article class="portal-card px-4 py-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-bold">{{ $document->document_type->label() }}</p>
<p class="truncate text-[11px]" style="color: var(--portal-muted);">
{{ $document->documentable?->person?->name_ar }}
</p>
</div>
<span class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
style="background: color-mix(in oklab, {{ $tone }} 14%, var(--portal-surface)); color: {{ $tone }};">
{{ $document->status->label() }}
</span>
</div>
@if($document->expires_at)
<p class="num mt-1 text-[11px]" style="color: {{ $tone }};">
{{ $expired ? __('انتهت في') : __('تنتهي في') }} {{ $document->expires_at->translatedFormat('j M Y') }}
</p>
@endif
@if($document->rejection_reason)
<p class="mt-2 rounded-lg px-2 py-1 text-[11px]"
style="background: color-mix(in oklab, var(--brand-danger) 10%, var(--portal-surface)); color: var(--brand-danger);">
{{ $document->rejection_reason }}
</p>
@endif
</article>
@empty
<x-portal.card>
<x-portal.empty :title="__('لا توجد مستندات بعد')"
:body="__('ارفع الشهادة الطبية وشهادة الميلاد ليكتمل ملف العضو')" />
</x-portal.card>
@endforelse
</div>
<div class="space-y-4">
<x-portal.tabs :tabs="['installments' => __('الأقساط'), 'offers' => __('العروض')]" :active="$tab" />
@error('pay') <div class="portal-card px-4 py-3 text-xs" style="color: var(--brand-danger);">{{ $message }}</div> @enderror
@error('offer') <div class="portal-card px-4 py-3 text-xs" style="color: var(--brand-danger);">{{ $message }}</div> @enderror
@if($tab === 'installments')
@forelse($installments as $installment)
@php
$invoice = $installment->paymentPlan?->invoice;
$overdue = $installment->due_date?->isPast();
$participantId = (int) ($invoice?->billable_id ?? 0);
$balance = (int) ($walletBalances[$participantId] ?? 0);
$amount = min((int) $installment->amount, (int) ($invoice?->due_amount ?? 0));
$canPay = in_array($participantId, $payableIds, true) && $balance >= $amount && $amount > 0;
@endphp
<article class="portal-card px-4 py-3">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-bold">{{ __('قسط') }} #{{ $installment->sequence }}</p>
<p class="num text-[11px]" style="color: {{ $overdue ? 'var(--brand-danger)' : 'var(--portal-muted)' }};">
{{ $installment->due_date?->translatedFormat('j M Y') }}
@if($overdue) · {{ __('متأخر') }} @endif
</p>
@if($invoice)
<p class="num text-[11px]" style="color: var(--portal-muted);">{{ $invoice->number }}</p>
@endif
</div>
<span class="num shrink-0 text-sm font-extrabold">{{ format_money((int) $installment->amount) }}</span>
</div>
@if($canPay)
{{-- Wallet only. A card gateway is feature-flagged off and an
InstaPay transfer is a claim awaiting review, so this is
the one payment the portal can settle immediately —
money the academy already holds for this member. --}}
<button type="button" wire:click="payInstallmentFromWallet({{ $installment->id }})"
wire:loading.attr="disabled"
class="mt-3 w-full rounded-xl px-4 py-2.5 text-xs font-bold"
style="background: var(--brand-500); color: var(--brand-fg);">
{{ __('سداد من المحفظة') }} ({{ format_money($balance) }})
</button>
@elseif($invoice)
<a href="{{ route('portal.invoice.transfer', $invoice->uuid) }}" wire:navigate
class="mt-3 block rounded-xl border px-4 py-2.5 text-center text-xs font-bold"
style="border-color: var(--portal-border); color: var(--brand-600);">
{{ __('تسجيل تحويل') }}
</a>
@endif
</article>
@empty
<x-portal.card>
<x-portal.empty :title="__('لا توجد أقساط مستحقة')" />
</x-portal.card>
@endforelse
@if($renewals->isNotEmpty())
<x-portal.card :title="__('التجديدات')">
<ul class="space-y-2">
@foreach($renewals as $renewal)
<li class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="num truncate text-sm font-semibold">{{ $renewal->number }}</p>
<p class="text-[11px]" style="color: var(--portal-muted);">
{{ __('تجديد الاشتراك') }} · {{ $renewal->status->label() }}
</p>
</div>
<a href="{{ route('portal.invoice', $renewal->uuid) }}" wire:navigate
class="num shrink-0 text-sm font-bold" style="color: var(--brand-600);">
{{ format_money((int) $renewal->due_amount) }}
</a>
</li>
@endforeach
</ul>
</x-portal.card>
@endif
@else
@forelse($offers as $offer)
<article class="portal-card px-4 py-4">
<p class="text-sm font-bold">{{ __('توفّر مقعد') }}</p>
<p class="mt-0.5 text-xs" style="color: var(--portal-muted);">
{{ $offer->group?->name_ar ?? $offer->group?->program?->name_ar ?? __('مجموعة') }}
— {{ $offer->participant?->person?->name_ar }}
</p>
@if($offer->expires_at)
<p class="num mt-1 text-[11px]" style="color: var(--brand-warning);">
{{ __('ينتهي العرض') }} {{ $offer->expires_at->diffForHumans() }}
</p>
@endif
<div class="mt-3 flex gap-2">
<button type="button" wire:click="respondToOffer({{ $offer->id }}, 'accepted')"
class="flex-1 rounded-xl px-4 py-2.5 text-xs font-bold"
style="background: var(--brand-500); color: var(--brand-fg);">
{{ __('أوافق') }}
</button>
<button type="button" wire:click="respondToOffer({{ $offer->id }}, 'declined')"
class="rounded-xl border px-4 py-2.5 text-xs font-bold"
style="border-color: var(--portal-border); color: var(--portal-muted);">
{{ __('أعتذر') }}
</button>
</div>
</article>
@empty
<x-portal.card>
<x-portal.empty :title="__('لا توجد عروض حالياً')"
:body="__('عند توفّر مقعد في مجموعة أنت على قائمة انتظارها سيظهر هنا')" />
</x-portal.card>
@endforelse
@endif
</div>
<div class="space-y-4">
<x-portal.card :title="__('الموافقات')">
<p class="mb-3 text-[11px] leading-relaxed" style="color: var(--portal-muted);">
{{ __('كل بند قرار منفصل. يمكنك سحب أي موافقة اختيارية في أي وقت، ويُسجَّل ذلك بتاريخه.') }}
</p>
<div class="space-y-4">
@foreach($types as $type)
<label class="flex items-start gap-3">
<div class="relative mt-0.5 shrink-0" dir="ltr">
<input type="checkbox" wire:model="consents.{{ $type->value }}"
@disabled($type->isRequired() && ($consents[$type->value] ?? false))
class="peer sr-only">
<div class="h-6 w-11 rounded-full transition-colors after:absolute after:top-[2px] after:left-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full"
style="background: {{ ($consents[$type->value] ?? false) ? 'var(--brand-500)' : 'var(--portal-border)' }};"></div>
</div>
<div class="min-w-0">
<span class="text-sm font-semibold">
{{ $type->label() }}
@if($type->isRequired())
<span class="text-[10px] font-normal" style="color: var(--portal-muted);">
— {{ __('مطلوب') }}
</span>
@endif
</span>
<p class="mt-0.5 text-[11px] leading-relaxed" style="color: var(--portal-muted);">
{{ $type->description() }}
</p>
</div>
</label>
@endforeach
</div>
@error('consents') <p class="mt-3 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
<button type="button" wire:click="saveConsents"
class="mt-4 w-full rounded-xl px-4 py-2.5 text-sm font-bold"
style="background: var(--brand-500); color: var(--brand-fg);">
{{ __('حفظ التفضيلات') }}
</button>
</x-portal.card>
<x-portal.card :title="__('نسخة من بياناتي')">
<p class="text-[11px] leading-relaxed" style="color: var(--portal-muted);">
{{ __('تنزيل كل ما يحتفظ به النظام عن حسابك والأعضاء المرتبطين به: الفواتير، المدفوعات، الموافقات.') }}
</p>
<button type="button" wire:click="export"
class="mt-3 w-full rounded-xl border px-4 py-2.5 text-sm font-bold"
style="border-color: var(--portal-border); color: var(--brand-600);">
{{ __('تنزيل بياناتي') }}
</button>
</x-portal.card>
<x-portal.card :title="__('حذف الحساب')">
@if($deletion)
<div class="rounded-xl px-3 py-2.5 text-xs"
style="background: color-mix(in oklab, var(--brand-warning) 12%, var(--portal-surface)); color: var(--brand-warning);">
<p class="font-bold">{{ $deletion->statusLabel() }}</p>
@if($deletion->status === 'pending')
<p class="num mt-1">
{{ __('سيُنفَّذ بعد') }} {{ $deletion->eligible_at?->diffForHumans(null, true) }}
</p>
@endif
@foreach(($deletion->blockers ?? []) as $blocker)
<p class="mt-1">• {{ $blocker }}</p>
@endforeach
</div>
<button type="button" wire:click="cancelDeletion"
class="mt-3 w-full rounded-xl border px-4 py-2.5 text-sm font-bold"
style="border-color: var(--portal-border);">
{{ __('إلغاء طلب الحذف') }}
</button>
@elseif(! $confirmingDeletion)
<p class="text-[11px] leading-relaxed" style="color: var(--portal-muted);">
{{ __('يُحذف حسابك وبياناتك الشخصية نهائياً. تبقى الفواتير والمدفوعات في سجلات الأكاديمية المحاسبية دون اسمك — فهي دفاتر الأكاديمية وليست بياناتك وحدك.') }}
</p>
@if(count($blockers) > 0)
<div class="mt-3 rounded-xl px-3 py-2.5 text-xs"
style="background: color-mix(in oklab, var(--brand-warning) 12%, var(--portal-surface)); color: var(--brand-warning);">
<p class="font-bold">{{ __('لا يمكن الحذف حالياً:') }}</p>
@foreach($blockers as $blocker)
<p class="mt-1">• {{ $blocker }}</p>
@endforeach
</div>
@endif
<button type="button" wire:click="$set('confirmingDeletion', true)"
class="mt-3 w-full rounded-xl border px-4 py-2.5 text-sm font-bold"
style="border-color: color-mix(in oklab, var(--brand-danger) 40%, var(--portal-border)); color: var(--brand-danger);">
{{ __('طلب حذف الحساب') }}
</button>
@else
<div class="space-y-3">
<div>
<label for="delreason" class="block text-xs font-semibold">
{{ __('السبب') }} <span class="font-normal" style="color: var(--portal-muted);">— {{ __('اختياري') }}</span>
</label>
<textarea id="delreason" rows="2" wire:model="deletionReason"
class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);"></textarea>
</div>
<div>
<label for="delpass" class="block text-xs font-semibold">{{ __('أكِّد كلمة المرور') }}</label>
<input id="delpass" type="password" wire:model="deletionPassword" autocomplete="current-password"
class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
@error('deletionPassword') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div>
<div class="flex gap-2">
<button type="button" wire:click="requestDeletion"
class="flex-1 rounded-xl px-4 py-2.5 text-sm font-bold text-white"
style="background: var(--brand-danger);">
{{ __('تأكيد الطلب') }}
</button>
<button type="button" wire:click="$set('confirmingDeletion', false)"
class="rounded-xl px-4 py-2.5 text-sm font-medium" style="color: var(--portal-muted);">
{{ __('إلغاء') }}
</button>
</div>
</div>
@endif
</x-portal.card>
</div>
<div class="space-y-4">
<form wire:submit="submit" class="portal-card space-y-4 px-4 py-4">
<div>
<label for="type" class="block text-xs font-semibold">{{ __('نوع الطلب') }}</label>
<select id="type" wire:model.live="type"
class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
<option value="excuse">{{ __('عذر عن حصة') }}</option>
<option value="freeze">{{ __('تجميد الاشتراك') }}</option>
<option value="unfreeze">{{ __('إعادة تفعيل الاشتراك') }}</option>
<option value="transfer">{{ __('نقل إلى فرع آخر') }}</option>
<option value="renewal">{{ __('تجديد الاشتراك') }}</option>
<option value="cancellation">{{ __('إلغاء الاشتراك') }}</option>
<option value="other">{{ __('طلب آخر') }}</option>
</select>
</div>
@if($type === 'excuse')
<div>
<label for="session" class="block text-xs font-semibold">{{ __('الحصة') }}</label>
<select id="session" wire:model="sessionId"
class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
<option value="">{{ __('اختر الحصة') }}</option>
@foreach($sessions as $session)
<option value="{{ $session->id }}">
{{ $session->session_date?->translatedFormat('j M') }} —
{{ $session->group?->name_ar ?? __('حصة') }}
({{ \Carbon\Carbon::parse($session->start_time)->format('H:i') }})
</option>
@endforeach
</select>
@error('sessionId') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
<p class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('تُقبل الأعذار حتى ٧ أيام بعد الحصة.') }}
</p>
</div>
@endif
@if($type === 'transfer')
<div>
<label for="branch" class="block text-xs font-semibold">{{ __('الفرع المطلوب') }}</label>
<select id="branch" wire:model="branchId"
class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);">
<option value="">{{ __('اختر الفرع') }}</option>
@foreach($branches as $branch)
<option value="{{ $branch->id }}">{{ $branch->name_ar ?? $branch->name }}</option>
@endforeach
</select>
</div>
@endif
<div>
<label for="reason" class="block text-xs font-semibold">{{ __('السبب') }}</label>
<textarea id="reason" rows="3" wire:model="reason"
class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);"></textarea>
@error('reason') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div>
<div>
<label for="attachment" class="block text-xs font-semibold">
{{ __('مرفق') }} <span class="font-normal" style="color: var(--portal-muted);">— {{ __('اختياري') }}</span>
</label>
<input id="attachment" type="file" wire:model="attachment" accept="image/*,application/pdf"
class="mt-1 block w-full text-xs">
<p class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('مثال: تقرير طبي. يُحفظ بشكل خاص ولا يظهر إلا للإدارة.') }}
</p>
<div wire:loading wire:target="attachment" class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('جارٍ الرفع...') }}
</div>
@error('attachment') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div>
<button type="submit" wire:loading.attr="disabled" wire:target="submit,attachment"
class="w-full rounded-xl px-4 py-3 text-sm font-bold disabled:opacity-60"
style="background: var(--brand-500); color: var(--brand-fg);">
<span wire:loading.remove wire:target="submit">{{ __('إرسال الطلب') }}</span>
<span wire:loading wire:target="submit">{{ __('جارٍ الإرسال...') }}</span>
</button>
</form>
@if($open > 0)
<x-portal.card>
<p class="text-xs" style="color: var(--portal-muted);">
{{ __('لديك') }} {{ $open }} {{ __('طلب قيد المراجعة.') }}
<a href="{{ route('portal.account', ['tab' => 'requests']) }}" wire:navigate
class="font-semibold" style="color: var(--brand-600);">{{ __('عرض طلباتي') }}</a>
</p>
</x-portal.card>
@endif
</div>
......@@ -79,6 +79,19 @@
@if($session->objectives)
<p class="mt-1 line-clamp-2 text-[11px]" style="color: var(--portal-muted);">{{ $session->objectives }}</p>
@endif
@if(! $cancelled && ! in_array($statusValue, ['present', 'late', 'excused'], true)
&& $session->session_date?->gte(now()->subDays(7)))
{{-- From the session, not from a menu three
screens away: this is where a member is
standing when they realise they cannot
make it. --}}
<a href="{{ route('portal.requests.create', $session->id) }}" wire:navigate
class="mt-2 inline-block rounded-lg px-3 py-1.5 text-[11px] font-bold"
style="background: var(--brand-50); color: var(--brand-700);">
{{ __('قدّم عذر') }}
</a>
@endif
</div>
</div>
</li>
......
<div class="space-y-5">
<header class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-xl font-bold text-gray-900">{{ __('طلبات الأعضاء') }}</h1>
<p class="mt-0.5 text-sm text-gray-500">
{{ __('التجميد والنقل والإلغاء والأعذار في قائمة واحدة. الموافقة تنفّذ الطلب فعلياً، ولا تغيّر حالته فقط.') }}
</p>
</div>
@if($pendingCount > 0)
<span class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1.5 text-sm font-bold text-amber-700">
{{ $pendingCount }} {{ __('قيد المراجعة') }}
</span>
@endif
</header>
<div class="flex flex-wrap gap-3">
<select wire:model.live="status" class="rounded-lg border-gray-300 text-sm">
<option value="pending">{{ __('قيد المراجعة') }}</option>
<option value="approved">{{ __('تمت الموافقة') }}</option>
<option value="rejected">{{ __('مرفوض') }}</option>
<option value="">{{ __('كل الحالات') }}</option>
</select>
<select wire:model.live="type" class="rounded-lg border-gray-300 text-sm">
<option value="">{{ __('كل الأنواع') }}</option>
@foreach($types as $t)
<option value="{{ $t }}">{{ $t }}</option>
@endforeach
</select>
</div>
@if($handling)
<section class="rounded-xl border-2 border-blue-200 bg-blue-50/40 p-4 sm:p-5">
<h2 class="font-bold text-gray-900">{{ $handling->typeLabel() }}</h2>
<dl class="mt-3 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div>
<dt class="text-xs text-gray-500">{{ __('العضو') }}</dt>
<dd class="font-semibold">{{ $handling->participant?->person?->name_ar ?? '—' }}</dd>
</div>
@if($handling->trainingSession)
<div>
<dt class="text-xs text-gray-500">{{ __('الحصة') }}</dt>
<dd class="font-semibold">
{{ $handling->trainingSession->session_date?->translatedFormat('j M') }} —
{{ $handling->trainingSession->group?->name_ar }}
</dd>
</div>
@endif
<div>
<dt class="text-xs text-gray-500">{{ __('أُرسل') }}</dt>
<dd class="text-xs">{{ $handling->created_at?->diffForHumans() }}</dd>
</div>
@if($handling->attachment_path)
<div>
<dt class="text-xs text-gray-500">{{ __('المرفق') }}</dt>
<dd>
<a href="{{ route('requests.attachment', $handling->uuid) }}"
class="text-xs font-semibold text-blue-700 hover:underline">{{ __('تنزيل') }}</a>
</dd>
</div>
@endif
</dl>
@if($handling->reason)
<p class="mt-3 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700">
{{ $handling->reason }}
</p>
@endif
<div class="mt-4">
<label for="notes" class="block text-sm font-semibold text-gray-900">{{ __('ملاحظات للعضو') }}</label>
<textarea id="notes" rows="2" wire:model="notes"
class="mt-1 w-full rounded-lg border-gray-300 text-sm"></textarea>
@error('notes') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div class="mt-4 flex flex-wrap gap-3">
<button type="button" wire:click="approve" wire:loading.attr="disabled" wire:target="approve"
class="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-60">
<span wire:loading.remove wire:target="approve">{{ __('موافقة وتنفيذ') }}</span>
<span wire:loading wire:target="approve">{{ __('جارٍ التنفيذ...') }}</span>
</button>
<button type="button" wire:click="reject" wire:loading.attr="disabled" wire:target="reject"
class="rounded-xl border border-red-300 bg-white px-4 py-2.5 text-sm font-bold text-red-700 hover:bg-red-50">
{{ __('رفض') }}
</button>
<button type="button" wire:click="cancelHandling"
class="rounded-xl px-4 py-2.5 text-sm font-medium text-gray-500">{{ __('إغلاق') }}</button>
</div>
</section>
@endif
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-xs text-gray-500">
<tr>
<th class="px-4 py-3 text-start font-medium">{{ __('النوع') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('العضو') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('السبب') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('الحالة') }}</th>
<th class="px-4 py-3 text-end font-medium">{{ __('إجراء') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($requests as $request)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">{{ $request->typeLabel() }}</td>
<td class="px-4 py-3">{{ $request->participant?->person?->name_ar ?? '—' }}</td>
<td class="max-w-xs truncate px-4 py-3 text-gray-600">{{ $request->reason }}</td>
<td class="px-4 py-3">
<span class="text-xs">{{ $request->statusLabel() }}</span>
@if($request->effect_error)
<span class="block text-[11px] text-red-600">{{ $request->effect_error }}</span>
@endif
</td>
<td class="px-4 py-3 text-end">
@if($request->isOpen())
<button type="button" wire:click="handle({{ $request->id }})"
class="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-bold text-white hover:bg-blue-700">
{{ __('معالجة') }}
</button>
@else
<span class="text-xs text-gray-400">{{ $request->handled_at?->diffForHumans() }}</span>
@endif
</td>
</tr>
@empty
<tr><td colspan="5" class="px-4 py-10 text-center text-gray-500">{{ __('لا توجد طلبات') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
<div>{{ $requests->links() }}</div>
</div>
......@@ -611,6 +611,14 @@
Route::get('/contacts', \App\Livewire\Website\ContactSubmissionList::class)->name('contacts');
});
// ─── Member requests ────────────────────────────────────────
// One queue for freezes, transfers, cancellations, renewals and excuses.
// They are one interaction, and two queues is how one of them ends up
// built and the other left as a TODO.
Route::get('/service-requests', \App\Livewire\Requests\ServiceRequestQueue::class)
->middleware('permission:participants.update')
->name('service-requests.index');
// ─── Portal invitations & duplicate merge ───────────────────
Route::get('/portal-invitations', \App\Livewire\Portal\PortalInvitationManager::class)
->middleware('permission:users.create')
......@@ -627,19 +635,42 @@
->middleware('permission:payments.approve_proof')
->name('payment-proofs.index');
// ─── Parents Portal ─────────────────────────────────────────
/*
| ─── Parents Portal — retired, redirecting ──────────────────
|
| The member portal at /app replaces this. Two member portals must not
| coexist: they diverge, and the one nobody is updating is the one a member
| happens to have bookmarked.
|
| These are permanent redirects rather than deletions because members have
| these URLs in their history, in WhatsApp messages and on paper. The
| components stay in the tree for one release so a redirect that turns out
| to be wrong can be answered by reading what the old screen did.
|
| ParentExcuseForm is the one route that is NOT redirected to an equivalent:
| it validated an excuse, stored the attachment to the public disk and then
| discarded the record behind a `// TODO` while telling the parent
| "تم تقديم العذر بنجاح". It never worked, so there is nothing to preserve
| — its members go to the request form, which does.
*/
Route::prefix('parent')->name('parent.')->group(function () {
Route::get('/', \App\Livewire\Parent\ParentHome::class)->name('home');
Route::get('/schedule', \App\Livewire\Parent\ParentSchedule::class)->name('schedule');
Route::get('/attendance', \App\Livewire\Parent\ParentAttendance::class)->name('attendance');
Route::get('/finances', \App\Livewire\Parent\ParentFinances::class)->name('finances');
Route::get('/finances/{invoice}', \App\Livewire\Parent\ParentInvoiceDetail::class)->name('finances.invoice');
Route::get('/profile', \App\Livewire\Parent\ParentProfile::class)->name('profile');
Route::get('/profile/child/{participant}', \App\Livewire\Parent\ParentChildDetail::class)->name('profile.child');
Route::get('/evaluations/{evaluation}', \App\Livewire\Parent\ParentEvaluationDetail::class)->name('evaluations.show');
Route::get('/excuses/create', \App\Livewire\Parent\ParentExcuseForm::class)->name('excuses.create');
Route::get('/notifications', \App\Livewire\Parent\ParentNotifications::class)->name('notifications');
Route::get('/programs', \App\Livewire\Parent\ParentPrograms::class)->name('programs');
Route::redirect('/', '/app')->name('home');
Route::redirect('/schedule', '/app/training')->name('schedule');
Route::redirect('/attendance', '/app/training?v=past')->name('attendance');
Route::redirect('/finances', '/app/payments')->name('finances');
Route::redirect('/profile', '/app/account')->name('profile');
Route::redirect('/notifications', '/app/notifications')->name('notifications');
Route::redirect('/programs', '/app/academy?tab=programs')->name('programs');
Route::redirect('/excuses/create', '/app/requests/new')->name('excuses.create');
// Detail routes keep their parameter so a bookmarked invoice still
// lands on that invoice rather than on a list.
Route::get('/finances/{invoice}', fn (string $invoice) => redirect()->route('portal.invoice', $invoice))
->name('finances.invoice');
Route::get('/profile/child/{participant}', fn () => redirect()->route('portal.account'))
->name('profile.child');
Route::get('/evaluations/{evaluation}', fn () => redirect()->route('portal.training'))
->name('evaluations.show');
});
});
......@@ -721,6 +752,16 @@
Route::get('/account', \App\Livewire\Portal\PortalAccount::class)->name('account');
Route::get('/notifications', \App\Livewire\Portal\PortalNotifications::class)->name('notifications');
Route::get('/pass', \App\Livewire\Portal\PortalPass::class)->name('pass');
Route::get('/dues', \App\Livewire\Portal\PortalDues::class)->name('dues');
Route::get('/privacy', \App\Livewire\Portal\PortalPrivacy::class)->name('privacy');
Route::get('/documents', \App\Livewire\Portal\PortalDocuments::class)
->middleware('permission:portal.documents')
->name('documents');
Route::get('/requests/new/{session?}', \App\Livewire\Portal\PortalRequests::class)
->middleware('permission:portal.requests')
->name('requests.create');
Route::get('/payments/invoice/{invoice}/transfer', \App\Livewire\Portal\PortalPayProof::class)
->middleware('permission:portal.pay')
......@@ -735,10 +776,17 @@
Route::delete('/push/token', [\App\Http\Controllers\Portal\DeviceTokenController::class, 'destroy'])->name('push.token.destroy');
});
// A transfer proof is on the private disk and is streamed, never linked: the
// file carries a phone number, a name and a bank balance.
Route::middleware('auth')->get('/proofs/{uuid}', \App\Http\Controllers\Financial\PaymentProofFileController::class)
->name('proofs.file');
// Member uploads live on the private disk and are streamed, never linked. A
// transfer proof carries a phone number, a name and a bank balance; a medical
// certificate is the most sensitive thing this product holds.
Route::middleware('auth')->group(function () {
Route::get('/proofs/{uuid}', \App\Http\Controllers\Financial\PaymentProofFileController::class)
->name('proofs.file');
Route::get('/request-files/{uuid}', [\App\Http\Controllers\MemberFileController::class, 'requestAttachment'])
->name('requests.attachment');
Route::get('/document-files/{uuid}', [\App\Http\Controllers\MemberFileController::class, 'document'])
->name('documents.file');
});
/*
|--------------------------------------------------------------------------
......
......@@ -43,6 +43,10 @@ public function test_every_portal_screen_renders_for_a_real_member(): void
'portal.account' => null,
'portal.notifications' => null,
'portal.pass' => null,
'portal.dues' => null,
'portal.documents' => null,
'portal.privacy' => null,
'portal.requests.create' => null,
];
foreach ($routes as $name => $needle) {
......
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