Commit 243cac65 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(programs): let a programme state its hours, its kit and its price without leaving the form

Four things the programme form could not say, all of which sent the desk
somewhere else or nowhere at all.

A timetable. Recording that a group trains Sunday and Tuesday from four to
half five required the visual grid, which demands a facility and a space —
so clubs that place nobody on a grid had no timetable at all, and the
proration and attendance engines had nothing to read. The form now writes
plain TrainingSchedule rows on the programme's default group with
facility_id null. Rows the grid HAS placed are left alone: clearing a
checkbox here must not strip a space reservation off a scheduled session.
A day dropped from the selection is deactivated rather than deleted,
because generated sessions hold a hard FK to the row and attendance hangs
off those sessions.

Renewal defaults. A new programme opened as manual_renew, so every one had
to be corrected by hand or quietly stopped billing. It now opens
auto_renew, monthly, on the first.

Kits. program_products already records 'this player must buy that thing',
but only for a single product; a kit sold as one thing could not be
required. program_kits is a separate pivot rather than a nullable kit_id
on program_products, whose product_id is NOT NULL and whose uniqueness is
(academy, programme, product). Nor is a kit expanded into its components:
the POS writes an invoice line carrying itemable_type = Kit, so a
programme requiring the parts would report every buyer as missing all of
them. The group roster flags a missing kit the way it flags a missing
product.

Prices. The programmes list showed no price, so comparing what two
programmes cost meant opening both. Both tiers now show, in one query for
the page, and a tier with no active base price reads 'غير محدد' rather
than 0 — the engine hard-fails there, so a zero would be a figure nobody
will ever be charged.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent e8def3f5
...@@ -594,6 +594,120 @@ public function bundledProductForParticipants( ...@@ -594,6 +594,120 @@ public function bundledProductForParticipants(
return $out; return $out;
} }
/**
* What each participant was billed and has paid toward one kit.
*
* Deliberately much simpler than the product version above. A kit is only
* ever sold through the POS, which writes a line carrying
* itemable_type = Kit, so there is no hand-typed description to read and no
* ambiguity to infer: a line either points at this kit or it does not.
* Nothing here should grow the free-text matching — a bare "القسط الاول"
* that could be a kit's instalment could equally be a product's.
*
* @param array<int> $participantIds
* @return array<int, array{billed: int, paid: int, quantity: int, has_plan: bool, plan: ?array}>
*/
public function bundledKitForParticipants(
array $participantIds,
int $kitId,
?int $branchId = null
): array {
if (empty($participantIds)) {
return [];
}
// DB::table() carries no global scope, so the branch is named here or
// not at all — a kit bought at the branch this player transferred from
// is not this branch's money.
$invoices = DB::table('invoices')
->where('billable_type', Participant::class)
->whereIn('billable_id', $participantIds)
->when($branchId, fn ($q) => $q->where('invoices.branch_id', $branchId))
->whereNull('deleted_at')
->where('status', '!=', 'cancelled')
->where('subtotal_amount', '>', 0)
->get(['id', 'billable_id', 'subtotal_amount']);
if ($invoices->isEmpty()) {
return [];
}
$invoiceIds = $invoices->pluck('id')->all();
$matchedByInvoice = DB::table('invoice_items')
->whereIn('invoice_id', $invoiceIds)
->where('itemable_type', \App\Domain\Inventory\Models\Kit::class)
->where('itemable_id', $kitId)
->groupBy('invoice_id')
->select(
'invoice_id',
DB::raw('SUM(total_amount) as billed'),
DB::raw('SUM(quantity) as quantity')
)
->get()
->keyBy('invoice_id');
if ($matchedByInvoice->isEmpty()) {
return [];
}
$payments = DB::table('payments')
->whereIn('invoice_id', $matchedByInvoice->keys()->all())
->where('status', 'confirmed')
->where('direction', 'inbound')
->whereNull('deleted_at')
->groupBy('invoice_id')
->select('invoice_id', DB::raw('SUM(amount) as paid'))
->pluck('paid', 'invoice_id');
$plans = DB::table('payment_plans')
->whereIn('invoice_id', $matchedByInvoice->keys()->all())
->whereIn('status', ['active', 'partial', 'completed'])
->get(['invoice_id', 'total_installments', 'paid_installments', 'installment_amount'])
->keyBy('invoice_id');
$out = [];
foreach ($invoices as $invoice) {
$match = $matchedByInvoice[$invoice->id] ?? null;
if (! $match) {
continue;
}
$billed = (int) $match->billed;
$subtotal = (int) $invoice->subtotal_amount;
$paidOnInvoice = (int) ($payments[$invoice->id] ?? 0);
// Same rounding rule as the product path: round down, and never
// allocate more toward the kit than it cost — a payment settles
// total_amount while the share is of subtotal_amount.
$paid = $subtotal > 0
? min(intdiv($paidOnInvoice * $billed, $subtotal), $billed)
: 0;
$pid = (int) $invoice->billable_id;
$row = $out[$pid] ?? ['billed' => 0, 'paid' => 0, 'quantity' => 0, 'has_plan' => false, 'plan' => null];
$row['billed'] += $billed;
$row['paid'] += $paid;
$row['quantity'] += (int) $match->quantity;
if ($plan = $plans[$invoice->id] ?? null) {
$row['has_plan'] = true;
$row['plan'] ??= [
'paid' => (int) $plan->paid_installments,
'total' => (int) $plan->total_installments,
'amount' => (int) $plan->installment_amount,
];
}
$out[$pid] = $row;
}
return $out;
}
/** /**
* Allocate confirmed inbound payments across the lines matched by $filter, * Allocate confirmed inbound payments across the lines matched by $filter,
* in proportion to those lines' share of each invoice. * in proportion to those lines' share of each invoice.
......
...@@ -50,4 +50,18 @@ public function creator(): BelongsTo ...@@ -50,4 +50,18 @@ public function creator(): BelongsTo
{ {
return $this->belongsTo(\App\Models\User::class, 'created_by'); return $this->belongsTo(\App\Models\User::class, 'created_by');
} }
/**
* Programmes that require this kit. The inverse of
* TrainingProgram::bundledKits().
*/
public function programs()
{
return $this->belongsToMany(
\App\Domain\Training\Models\TrainingProgram::class,
'program_kits',
'kit_id',
'training_program_id'
)->withPivot(['is_required', 'quantity'])->withTimestamps();
}
} }
...@@ -104,11 +104,36 @@ public function bundledProducts() ...@@ -104,11 +104,36 @@ public function bundledProducts()
)->withPivot(['is_required', 'quantity'])->withTimestamps(); )->withPivot(['is_required', 'quantity'])->withTimestamps();
} }
/**
* Kits a participant must buy to be properly enrolled — the same obligation
* as bundledProducts(), for a set sold as one thing.
*/
public function bundledKits()
{
return $this->belongsToMany(
\App\Domain\Inventory\Models\Kit::class,
'program_kits',
'training_program_id',
'kit_id'
)->withPivot(['is_required', 'quantity'])->withTimestamps();
}
public function enrollments(): HasMany public function enrollments(): HasMany
{ {
return $this->hasMany(Enrollment::class, 'training_program_id'); return $this->hasMany(Enrollment::class, 'training_program_id');
} }
/**
* The group the programme is created with. Most programmes are run as a
* single squad and never grow a second group, so this is where their weekly
* timetable lives. Oldest first, so opening a second group later never
* moves the schedule out from under the form that wrote it.
*/
public function defaultGroup(): ?TrainingGroup
{
return $this->groups()->orderBy('id')->first();
}
public function scopeActive($query) public function scopeActive($query)
{ {
return $query->where('status', ProgramStatus::Active); return $query->where('status', ProgramStatus::Active);
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\TrainingGroup; use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingProgram; use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Training\Models\TrainingSchedule;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
...@@ -64,6 +65,76 @@ private function createDefaultGroup(TrainingProgram $program, User $actor): Trai ...@@ -64,6 +65,76 @@ private function createDefaultGroup(TrainingProgram $program, User $actor): Trai
]); ]);
} }
/**
* Put a plain weekly timetable on the programme's default group: which days
* it trains and between which hours, and nothing else.
*
* This is the deliberate counterpart to the visual builder. A programme
* that meets three afternoons a week does not need a facility, a space, or
* a placement on the grid to say so, and demanding one is why small clubs
* end up with no recorded timetable at all. facility_id stays null; the
* grid can place these rows later without them being recreated.
*
* Only facility-less rows are touched. A row the grid has already placed
* belongs to the grid, and clearing a checkbox here must not silently strip
* a space reservation off it.
*
* Days that fall out of the selection are deactivated rather than deleted —
* sessions already generated from them point at the row, and attendance
* hangs off those sessions.
*
* @param array<int> $days day_of_week values, 0 = Sunday
*/
public function syncDefaultGroupSchedule(
TrainingProgram $program,
array $days,
?string $startTime,
?string $endTime,
?int $trainerId = null,
): void {
$group = $program->defaultGroup();
if (! $group) {
return;
}
$days = array_values(array_unique(array_map('intval', $days)));
DB::transaction(function () use ($group, $program, $days, $startTime, $endTime, $trainerId) {
$managed = $group->schedules()->whereNull('facility_id')->get();
foreach ($days as $day) {
if ($startTime === null || $endTime === null) {
continue;
}
$row = $managed->firstWhere('day_of_week', $day)
?? new TrainingSchedule([
'academy_id' => $group->academy_id,
'training_group_id' => $group->id,
'day_of_week' => $day,
]);
$row->fill([
'academy_id' => $group->academy_id,
'training_group_id' => $group->id,
'day_of_week' => $day,
'start_time' => $startTime,
'end_time' => $endTime,
'trainer_id' => $trainerId,
'effective_from' => $row->effective_from
?? ($program->program_start_date ?? now()->toDateString()),
'effective_until' => $program->program_end_date,
'is_active' => true,
])->save();
}
$managed->whereNotIn('day_of_week', $days)
->where('is_active', true)
->each(fn (TrainingSchedule $row) => $row->update(['is_active' => false]));
});
}
public function update(TrainingProgram $program, array $data): TrainingProgram public function update(TrainingProgram $program, array $data): TrainingProgram
{ {
return DB::transaction(function () use ($program, $data) { return DB::transaction(function () use ($program, $data) {
......
...@@ -494,6 +494,7 @@ public function render() ...@@ -494,6 +494,7 @@ public function render()
} }
$bundleColumns[] = [ $bundleColumns[] = [
'label' => (string) $product->name_ar,
'product' => $product, 'product' => $product,
'required' => (bool) ($product->pivot->is_required ?? true), 'required' => (bool) ($product->pivot->is_required ?? true),
'rows' => $rows, 'rows' => $rows,
...@@ -501,6 +502,48 @@ public function render() ...@@ -501,6 +502,48 @@ public function render()
]; ];
} }
// Required kits, alongside the required products. A kit is only ever
// sold as a kit, so what was billed IS the obligation — there is no
// hand-typed instalment line to read and no price to estimate from.
$bundledKits = $this->group->program
? $this->group->program->bundledKits()->get()
: collect();
foreach ($bundledKits as $kit) {
$facts = $billing->bundledKitForParticipants($participantIds, $kit->id, $billingBranchId);
$rows = [];
foreach ($participantIds as $pid) {
$row = $facts[$pid] ?? null;
$billedAmount = (int) ($row['billed'] ?? 0);
$paidAmount = (int) ($row['paid'] ?? 0);
$owned = $billedAmount > 0 || $paidAmount > 0;
$rows[$pid] = [
'owned' => $owned,
'exempt' => (bool) $participantsById[$pid]?->is_free,
'billed' => $billedAmount,
'paid' => $paidAmount,
'percent' => $billedAmount > 0
? min(100, (int) round($paidAmount * 100 / $billedAmount))
: 0,
'fully_paid' => $owned && $paidAmount >= $billedAmount,
'installment' => $owned && $paidAmount > 0 && $paidAmount < $billedAmount,
'plan' => $row['plan'] ?? null,
'estimated_total' => false,
'from_text' => false,
];
}
$bundleColumns[] = [
'label' => (string) $kit->name_ar,
'kit' => $kit,
'required' => (bool) ($kit->pivot->is_required ?? true),
'rows' => $rows,
'missing_count' => count(array_filter($rows, fn ($r) => ! $r['owned'] && ! $r['exempt'])),
];
}
// Collected for this group, for the header summary. Only shown to users // Collected for this group, for the header summary. Only shown to users
// allowed to see money. // allowed to see money.
// Subscription money for THIS cycle only; product money is one-off, so // Subscription money for THIS cycle only; product money is one-off, so
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
namespace App\Livewire\Programs; namespace App\Livewire\Programs;
use App\Domain\HR\Services\TrainerWorkloadService; use App\Domain\HR\Services\TrainerWorkloadService;
use App\Domain\Inventory\Models\Kit;
use App\Domain\Inventory\Models\Product; use App\Domain\Inventory\Models\Product;
use App\Domain\Pricing\Models\BasePrice; use App\Domain\Pricing\Models\BasePrice;
use App\Domain\Shared\Traits\UsesBranchScope; use App\Domain\Shared\Traits\UsesBranchScope;
...@@ -58,7 +59,11 @@ class ProgramForm extends Component ...@@ -58,7 +59,11 @@ class ProgramForm extends Component
public ?string $registration_deadline = null; public ?string $registration_deadline = null;
public ?string $program_start_date = null; public ?string $program_start_date = null;
public ?string $program_end_date = null; public ?string $program_end_date = null;
public string $renewal_policy = 'manual_renew'; // A new programme is assumed to renew itself on the first of the month —
// that is what almost every subscription here actually does, and leaving
// the default at manual_renew meant every programme had to be corrected by
// hand or quietly stopped billing.
public string $renewal_policy = 'auto_renew';
public ?string $billing_cycle = 'monthly'; public ?string $billing_cycle = 'monthly';
public ?int $billing_day = 1; public ?int $billing_day = 1;
public string $cancellation_policy = ''; public string $cancellation_policy = '';
...@@ -85,6 +90,27 @@ class ProgramForm extends Component ...@@ -85,6 +90,27 @@ class ProgramForm extends Component
*/ */
public array $bundled_product_ids = []; public array $bundled_product_ids = [];
/**
* Kit ids that come bundled with this programme — the same obligation as
* the products above, for a set sold as one thing.
*
* @var array<int>
*/
public array $bundled_kit_ids = [];
/**
* The weekly timetable of the programme's default group, stated the way a
* club states it: these days, between these hours. No facility, no space,
* no placement on the grid — a programme that meets Sunday/Tuesday 4-5:30
* can say so here and be placed on the grid later, or never.
*
* @var array<int> day_of_week values, 0 = Sunday
*/
public array $schedule_days = [];
public ?string $schedule_start_time = null;
public ?string $schedule_end_time = null;
public function mount(?TrainingProgram $program = null): void public function mount(?TrainingProgram $program = null): void
{ {
if ($program && $program->exists) { if ($program && $program->exists) {
...@@ -95,6 +121,8 @@ public function mount(?TrainingProgram $program = null): void ...@@ -95,6 +121,8 @@ public function mount(?TrainingProgram $program = null): void
$this->program = $program; $this->program = $program;
$this->editing = true; $this->editing = true;
$this->bundled_product_ids = $program->bundledProducts()->pluck('products.id')->all(); $this->bundled_product_ids = $program->bundledProducts()->pluck('products.id')->all();
$this->bundled_kit_ids = $program->bundledKits()->pluck('kits.id')->all();
$this->loadDefaultGroupSchedule($program);
$this->name = $program->name ?? ''; $this->name = $program->name ?? '';
$this->name_ar = $program->name_ar; $this->name_ar = $program->name_ar;
$this->slug = $program->slug ?? ''; $this->slug = $program->slug ?? '';
...@@ -154,6 +182,30 @@ public function mount(?TrainingProgram $program = null): void ...@@ -154,6 +182,30 @@ public function mount(?TrainingProgram $program = null): void
} }
} }
/**
* Read back what this form wrote: the default group's active, unplaced
* schedule rows. Rows the grid has placed carry a facility and are left out
* — they are edited on the grid, and showing them here would invite the
* save below to flatten their hours onto one shared pair.
*/
private function loadDefaultGroupSchedule(TrainingProgram $program): void
{
$rows = $program->defaultGroup()
?->schedules()
->whereNull('facility_id')
->where('is_active', true)
->orderBy('day_of_week')
->get();
if (! $rows || $rows->isEmpty()) {
return;
}
$this->schedule_days = $rows->pluck('day_of_week')->map('intval')->all();
$this->schedule_start_time = substr((string) $rows->first()->start_time, 0, 5);
$this->schedule_end_time = substr((string) $rows->first()->end_time, 0, 5);
}
public function rules(): array public function rules(): array
{ {
return [ return [
...@@ -201,6 +253,15 @@ public function rules(): array ...@@ -201,6 +253,15 @@ public function rules(): array
// again here, against the products this branch actually sells. // again here, against the products this branch actually sells.
'bundled_product_ids' => ['array'], 'bundled_product_ids' => ['array'],
'bundled_product_ids.*' => ['integer', $this->productExistsRule()], 'bundled_product_ids.*' => ['integer', $this->productExistsRule()],
'bundled_kit_ids' => ['array'],
'bundled_kit_ids.*' => ['integer', $this->kitExistsRule()],
// Times arrive from <input type="time">, which sends H:i in every
// browser but H:i:s in a few — both are accepted rather than
// rejecting a timetable over a seconds field nobody typed.
'schedule_days' => ['array', 'max:7'],
'schedule_days.*' => ['integer', 'between:0,6'],
'schedule_start_time' => ['nullable', 'required_with:schedule_days', 'date_format:H:i,H:i:s'],
'schedule_end_time' => ['nullable', 'required_with:schedule_days', 'date_format:H:i,H:i:s', 'after:schedule_start_time'],
]; ];
} }
...@@ -220,6 +281,22 @@ private function productExistsRule(): \Illuminate\Validation\Rules\Exists ...@@ -220,6 +281,22 @@ private function productExistsRule(): \Illuminate\Validation\Rules\Exists
return $rule; return $rule;
} }
/**
* A kit this branch may bundle. Same reading as productExistsRule(): the
* checkbox list is branch-scoped by the model, but the posted array is not.
*/
private function kitExistsRule(): \Illuminate\Validation\Rules\Exists
{
$rule = Rule::exists('kits', 'id');
$branchId = $this->getActiveBranchId();
if ($branchId !== null) {
$rule->where(fn ($q) => $q->where('branch_id', $branchId));
}
return $rule;
}
/** /**
* Users who may be the default trainer of a programme in this branch. * Users who may be the default trainer of a programme in this branch.
* *
...@@ -287,6 +364,7 @@ public function messages(): array ...@@ -287,6 +364,7 @@ public function messages(): array
'branch_id.in' => 'الفرع المحدد غير متاح', 'branch_id.in' => 'الفرع المحدد غير متاح',
'default_trainer_id.in' => 'المدرب المحدد غير متاح في هذا الفرع', 'default_trainer_id.in' => 'المدرب المحدد غير متاح في هذا الفرع',
'bundled_product_ids.*.exists' => 'أحد المنتجات المحددة غير متاح في هذا الفرع', 'bundled_product_ids.*.exists' => 'أحد المنتجات المحددة غير متاح في هذا الفرع',
'bundled_kit_ids.*.exists' => 'أحد الأطقم المحددة غير متاح في هذا الفرع',
'skill_level.required' => 'مستوى المهارة مطلوب', 'skill_level.required' => 'مستوى المهارة مطلوب',
'skill_level.in' => 'مستوى المهارة غير صالح', 'skill_level.in' => 'مستوى المهارة غير صالح',
'age_min.min' => 'الحد الأدنى للعمر يجب أن يكون 1 على الأقل', 'age_min.min' => 'الحد الأدنى للعمر يجب أن يكون 1 على الأقل',
...@@ -311,6 +389,12 @@ public function messages(): array ...@@ -311,6 +389,12 @@ public function messages(): array
'billing_day.integer' => 'يوم الفوترة يجب أن يكون رقم', 'billing_day.integer' => 'يوم الفوترة يجب أن يكون رقم',
'billing_day.min' => 'يوم الفوترة يجب أن يكون 1 على الأقل', 'billing_day.min' => 'يوم الفوترة يجب أن يكون 1 على الأقل',
'billing_day.max' => 'يوم الفوترة يجب ألا يتجاوز 28', 'billing_day.max' => 'يوم الفوترة يجب ألا يتجاوز 28',
'schedule_days.*.between' => 'يوم التدريب غير صالح',
'schedule_start_time.required_with' => 'وقت بداية التمرين مطلوب عند تحديد أيام التدريب',
'schedule_start_time.date_format' => 'وقت بداية التمرين غير صالح',
'schedule_end_time.required_with' => 'وقت نهاية التمرين مطلوب عند تحديد أيام التدريب',
'schedule_end_time.date_format' => 'وقت نهاية التمرين غير صالح',
'schedule_end_time.after' => 'وقت نهاية التمرين يجب أن يكون بعد وقت البداية',
'member_price.numeric' => term('member_price') . ' يجب أن يكون رقم', 'member_price.numeric' => term('member_price') . ' يجب أن يكون رقم',
'member_price.min' => term('member_price') . ' يجب ألا يكون سالب', 'member_price.min' => term('member_price') . ' يجب ألا يكون سالب',
'non_member_price.numeric' => term('non_member_price') . ' يجب أن يكون رقم', 'non_member_price.numeric' => term('non_member_price') . ' يجب أن يكون رقم',
...@@ -374,6 +458,8 @@ public function save(TrainingProgramService $service): void ...@@ -374,6 +458,8 @@ public function save(TrainingProgramService $service): void
$service->update($this->program, $data); $service->update($this->program, $data);
$this->savePrices($this->program); $this->savePrices($this->program);
$this->saveBundledProducts($this->program); $this->saveBundledProducts($this->program);
$this->saveBundledKits($this->program);
$this->saveSchedule($service, $this->program->fresh());
session()->flash('success', __('تم تحديث البرنامج بنجاح')); session()->flash('success', __('تم تحديث البرنامج بنجاح'));
} else { } else {
$program = $service->create($data, auth()->user()); $program = $service->create($data, auth()->user());
...@@ -381,6 +467,8 @@ public function save(TrainingProgramService $service): void ...@@ -381,6 +467,8 @@ public function save(TrainingProgramService $service): void
$this->savePrices($program); $this->savePrices($program);
$this->saveBundledProducts($program); $this->saveBundledProducts($program);
$this->saveBundledKits($program);
$this->saveSchedule($service, $program);
session()->flash('success', __('تم إنشاء البرنامج بنجاح')); session()->flash('success', __('تم إنشاء البرنامج بنجاح'));
} }
...@@ -390,6 +478,58 @@ public function save(TrainingProgramService $service): void ...@@ -390,6 +478,58 @@ public function save(TrainingProgramService $service): void
} }
} }
/**
* Hand the chosen days and hours to the service, which owns the write.
*
* The times are normalised to H:i here so a browser that sends seconds does
* not produce a row that reads back differently from the one just saved.
*/
private function saveSchedule(TrainingProgramService $service, TrainingProgram $program): void
{
$service->syncDefaultGroupSchedule(
$program,
$this->schedule_days,
$this->schedule_start_time ? substr($this->schedule_start_time, 0, 5) : null,
$this->schedule_end_time ? substr($this->schedule_end_time, 0, 5) : null,
$this->default_trainer_id,
);
}
/**
* Sync the required kits. Same reasoning as saveBundledProducts(),
* including carrying through a kit from another branch's catalogue that
* this form cannot see.
*/
private function saveBundledKits(TrainingProgram $program): void
{
$academyId = $program->academy_id ?? app('current_academy')?->id;
$payload = [];
foreach (array_filter($this->bundled_kit_ids) as $kitId) {
$payload[(int) $kitId] = [
'academy_id' => $academyId,
'is_required' => true,
'quantity' => 1,
];
}
$visibleIds = $program->bundledKits()->pluck('kits.id')->all();
$hidden = $this->acrossBranches(
fn () => $program->bundledKits()->whereNotIn('kits.id', $visibleIds)->get()
);
foreach ($hidden as $kit) {
$payload[$kit->id] = [
'academy_id' => $academyId,
'is_required' => $kit->pivot->is_required,
'quantity' => $kit->pivot->quantity,
];
}
$program->bundledKits()->sync($payload);
}
/** /**
* Sync the bundle. academy_id is written explicitly because the pivot has * Sync the bundle. academy_id is written explicitly because the pivot has
* no model and so no BelongsToAcademy hook to stamp it. * no model and so no BelongsToAcademy hook to stamp it.
...@@ -497,6 +637,8 @@ public function render() ...@@ -497,6 +637,8 @@ public function render()
// academy-wide catalogue. Activities are academy-level. // academy-wide catalogue. Activities are academy-level.
'availableProducts' => Product::where('is_active', true) 'availableProducts' => Product::where('is_active', true)
->orderBy('name_ar')->get(['id', 'name_ar', 'selling_price']), ->orderBy('name_ar')->get(['id', 'name_ar', 'selling_price']),
'availableKits' => Kit::where('is_active', true)
->orderBy('name_ar')->get(['id', 'name_ar', 'selling_price']),
'activities' => Activity::where('is_active', true)->orderBy('name_ar')->get(['id', 'name_ar']), 'activities' => Activity::where('is_active', true)->orderBy('name_ar')->get(['id', 'name_ar']),
'branches' => $this->selectableBranches(), 'branches' => $this->selectableBranches(),
'trainers' => $trainers, 'trainers' => $trainers,
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
namespace App\Livewire\Programs; namespace App\Livewire\Programs;
use App\Domain\Pricing\Models\BasePrice;
use App\Domain\Shared\Traits\UsesBranchScope; use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Enums\ProgramStatus; use App\Domain\Training\Enums\ProgramStatus;
use App\Domain\Training\Models\Activity; use App\Domain\Training\Models\Activity;
...@@ -101,6 +102,45 @@ public function delete(string $uuid, TrainingProgramService $service): void ...@@ -101,6 +102,45 @@ public function delete(string $uuid, TrainingProgramService $service): void
} }
} }
/**
* The two tier prices of each programme on the page, in piasters.
*
* One query for the whole page rather than two per row. The prices are the
* same rows ProgramForm writes — base_prices tagged with a membership_type
* in metadata — so what the list shows is exactly what the sale will
* charge. BasePrice is branch-scoped, so a programme visible from here
* cannot show another branch's price.
*
* @param array<int> $programIds
* @return array<int, array{member: ?int, non_member: ?int}>
*/
private function pricesFor(array $programIds): array
{
if ($programIds === []) {
return [];
}
$rows = BasePrice::query()
->where('priceable_type', TrainingProgram::class)
->whereIn('priceable_id', $programIds)
->where('is_active', true)
->get(['priceable_id', 'amount', 'metadata']);
$prices = [];
foreach ($rows as $row) {
$tier = $row->metadata['membership_type'] ?? null;
if ($tier !== 'member' && $tier !== 'non_member') {
continue;
}
$prices[$row->priceable_id][$tier] = (int) $row->amount;
}
return $prices;
}
public function render() public function render()
{ {
$branchId = $this->getActiveBranchId(); $branchId = $this->getActiveBranchId();
...@@ -145,8 +185,11 @@ public function render() ...@@ -145,8 +185,11 @@ public function render()
->orderBy('name_ar') ->orderBy('name_ar')
->get(); ->get();
$programs = $query->paginate(15);
return view('livewire.programs.program-list', [ return view('livewire.programs.program-list', [
'programs' => $query->paginate(15), 'programs' => $programs,
'prices' => $this->pricesFor($programs->pluck('id')->all()),
'activities' => Activity::where('is_active', true)->orderBy('name_ar')->get(['id', 'name_ar']), 'activities' => Activity::where('is_active', true)->orderBy('name_ar')->get(['id', 'name_ar']),
'trainers' => $trainers, 'trainers' => $trainers,
'statusOptions' => [ 'statusOptions' => [
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Kits that come bundled with a training programme — the same obligation
* program_products records, for a set sold as one thing.
*
* A separate table rather than a nullable kit_id on program_products: that
* pivot's product_id is NOT NULL and its uniqueness is (academy, programme,
* product), so a kit row would have to break both.
*
* Nor is a kit expanded into its component products. A kit is sold through the
* POS as a kit — the invoice line carries itemable_type = Kit — so a programme
* that required the components instead would report every buyer as missing all
* of them.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('program_kits')) {
return;
}
Schema::create('program_kits', function (Blueprint $table) {
$table->id();
$table->foreignId('academy_id')->constrained('academies')->cascadeOnDelete();
$table->foreignId('training_program_id')->constrained('training_programs')->cascadeOnDelete();
$table->foreignId('kit_id')->constrained('kits')->cascadeOnDelete();
$table->boolean('is_required')->default(true);
$table->unsignedInteger('quantity')->default(1);
$table->timestamps();
// Tenant-scoped uniqueness, per the academy_id rule.
$table->unique(['academy_id', 'training_program_id', 'kit_id'], 'program_kits_unique');
$table->index(['training_program_id', 'is_required']);
});
}
public function down(): void
{
Schema::dropIfExists('program_kits');
}
};
...@@ -489,7 +489,7 @@ class="w-full ps-9 pe-3 py-2 text-sm border border-gray-200 rounded-lg focus:rin ...@@ -489,7 +489,7 @@ class="w-full ps-9 pe-3 py-2 text-sm border border-gray-200 rounded-lg focus:rin
</th> </th>
@foreach($bundleColumns as $col) @foreach($bundleColumns as $col)
<th class="px-4 py-3 text-start font-medium text-gray-600"> <th class="px-4 py-3 text-start font-medium text-gray-600">
{{ $col['product']->name_ar }} {{ $col['label'] }}
@if($col['missing_count'] > 0) @if($col['missing_count'] > 0)
<span class="ms-1 px-1.5 py-0.5 text-[10px] bg-red-100 text-red-700 rounded-full">{{ $col['missing_count'] }} {{ __('بدون') }}</span> <span class="ms-1 px-1.5 py-0.5 text-[10px] bg-red-100 text-red-700 rounded-full">{{ $col['missing_count'] }} {{ __('بدون') }}</span>
@endif @endif
...@@ -605,11 +605,11 @@ class="text-sm font-medium text-blue-600 hover:text-blue-800 hover:underline"> ...@@ -605,11 +605,11 @@ class="text-sm font-medium text-blue-600 hover:text-blue-800 hover:underline">
? __('مدفوع بالكامل') ? __('مدفوع بالكامل')
: ($b['installment'] ? __('أقساط') : __('لم يُسدَّد')); : ($b['installment'] ? __('أقساط') : __('لم يُسدَّد'));
$bTitle = $b['fully_paid'] $bTitle = $b['fully_paid']
? __('سدد قيمة :name كاملة', ['name' => $col['product']->name_ar]) ? __('سدد قيمة :name كاملة', ['name' => $col['label']])
: __('سدد :paid من :total من قيمة :name', [ : __('سدد :paid من :total من قيمة :name', [
'paid' => number_format($b['paid'] / 100, 0) . ' ' . __('ج.م'), 'paid' => number_format($b['paid'] / 100, 0) . ' ' . __('ج.م'),
'total' => number_format($b['billed'] / 100, 0) . ' ' . __('ج.م'), 'total' => number_format($b['billed'] / 100, 0) . ' ' . __('ج.م'),
'name' => $col['product']->name_ar, 'name' => $col['label'],
]); ]);
if ($b['plan']) { if ($b['plan']) {
$bTitle .= ' — ' . __('قسط :n من :total', ['n' => $b['plan']['paid'], 'total' => $b['plan']['total']]); $bTitle .= ' — ' . __('قسط :n من :total', ['n' => $b['plan']['paid'], 'total' => $b['plan']['total']]);
......
...@@ -239,6 +239,44 @@ class="text-sm text-gray-600 hover:text-gray-800">{{ __('← العودة للق ...@@ -239,6 +239,44 @@ class="text-sm text-gray-600 hover:text-gray-800">{{ __('← العودة للق
</div> </div>
</div> </div>
{{-- Weekly timetable of the default group. Deliberately not the grid:
days and hours only, no facility and no space. A club that trains
Sunday/Tuesday 4-5:30 can say so here and be placed on the grid
later, or never. --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<h2 class="text-base sm:text-lg font-semibold text-gray-800 mb-1 border-b border-gray-100 pb-2">{{ __('مواعيد التمرين') }}</h2>
<p class="text-xs text-gray-500 mb-4">{{ __('أيام وساعات المجموعة الافتراضية للبرنامج. لا يلزم تحديد ملعب أو مكان.') }}</p>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('أيام التمرين') }}</label>
<div class="flex flex-wrap gap-2">
@foreach(['الأحد', 'الاثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت'] as $dayIndex => $dayName)
<label class="flex items-center gap-2 px-3 py-2 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50 has-[:checked]:bg-blue-50 has-[:checked]:border-blue-400">
<input type="checkbox" wire:model="schedule_days" value="{{ $dayIndex }}"
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
<span class="text-sm text-gray-700">{{ __($dayName) }}</span>
</label>
@endforeach
</div>
@error('schedule_days.*') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('من الساعة') }}</label>
<input type="time" wire:model="schedule_start_time" dir="ltr"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('schedule_start_time') border-red-500 @enderror">
@error('schedule_start_time') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('إلى الساعة') }}</label>
<input type="time" wire:model="schedule_end_time" dir="ltr"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('schedule_end_time') border-red-500 @enderror">
@error('schedule_end_time') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
</div>
</div>
{{-- Pricing --}} {{-- Pricing --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<h2 class="text-base sm:text-lg font-semibold text-gray-800 mb-4 border-b border-gray-100 pb-2">{{ __('الأسعار') }}</h2> <h2 class="text-base sm:text-lg font-semibold text-gray-800 mb-4 border-b border-gray-100 pb-2">{{ __('الأسعار') }}</h2>
...@@ -284,6 +322,29 @@ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"> ...@@ -284,6 +322,29 @@ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
@endif @endif
</div> </div>
{{-- Bundled kits — the same obligation, for a set sold as one
thing. A kit is sold through the POS as a kit, so requiring its
components separately would report every buyer as missing them. --}}
<div class="pt-6 border-t border-gray-200">
<h3 class="text-sm font-bold text-gray-800 mb-1">{{ __('أطقم مرتبطة بالبرنامج') }}</h3>
<p class="text-xs text-gray-500 mb-3">{{ __('أي لاعب في هذا البرنامج لازم يشتري الأطقم دي. هتظهر في شاشة المجموعة ومين اشتراها ومين لأ.') }}</p>
@if(count($availableKits) === 0)
<p class="text-xs text-gray-400">{{ __('لا توجد أطقم نشطة') }}</p>
@else
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-64 overflow-y-auto p-1">
@foreach($availableKits as $kit)
<label class="flex items-center gap-2 p-2.5 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50 transition">
<input type="checkbox" wire:model="bundled_kit_ids" value="{{ $kit->id }}"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700 flex-1 truncate">{{ $kit->name_ar }}</span>
<span class="text-xs text-gray-400 whitespace-nowrap" dir="ltr">{{ number_format($kit->selling_price / 100, 0) }}</span>
</label>
@endforeach
</div>
@endif
</div>
</div> </div>
{{-- Submit --}} {{-- Submit --}}
......
...@@ -62,6 +62,8 @@ class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 f ...@@ -62,6 +62,8 @@ class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 f
<th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('الفرع') }}</th> <th class="px-4 py-3 text-start font-semibold text-gray-600">{{ __('الفرع') }}</th>
<th class="px-4 py-3 text-center font-semibold text-gray-600">{{ __('المجموعات') }}</th> <th class="px-4 py-3 text-center font-semibold text-gray-600">{{ __('المجموعات') }}</th>
<x-ui.sort-header column="sessions_per_week" :sortBy="$sortBy" :sortDir="$sortDir" align="center">{{ __('الحصص/أسبوع') }}</x-ui.sort-header> <x-ui.sort-header column="sessions_per_week" :sortBy="$sortBy" :sortDir="$sortDir" align="center">{{ __('الحصص/أسبوع') }}</x-ui.sort-header>
<th class="px-4 py-3 text-center font-semibold text-gray-600">{{ term('member_price') }}</th>
<th class="px-4 py-3 text-center font-semibold text-gray-600">{{ term('non_member_price') }}</th>
<x-ui.sort-header column="status" :sortBy="$sortBy" :sortDir="$sortDir" align="center">{{ __('الحالة') }}</x-ui.sort-header> <x-ui.sort-header column="status" :sortBy="$sortBy" :sortDir="$sortDir" align="center">{{ __('الحالة') }}</x-ui.sort-header>
<th class="px-4 py-3 text-end font-semibold text-gray-600">{{ __('إجراءات') }}</th> <th class="px-4 py-3 text-end font-semibold text-gray-600">{{ __('إجراءات') }}</th>
</tr> </tr>
...@@ -91,6 +93,23 @@ class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 f ...@@ -91,6 +93,23 @@ class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 f
<td class="px-4 py-3 text-center text-gray-600" dir="ltr"> <td class="px-4 py-3 text-center text-gray-600" dir="ltr">
{{ $program->sessions_per_week }} {{ $program->sessions_per_week }}
</td> </td>
{{-- A programme with no active base price for a tier cannot be
sold to that tier at all — the engine hard-fails. Say so
here rather than printing a 0 nobody will ever be charged. --}}
<td class="px-4 py-3 text-center" dir="ltr">
@isset($prices[$program->id]['member'])
<span class="font-medium text-gray-800">{{ format_money($prices[$program->id]['member']) }}</span>
@else
<span class="text-xs text-amber-600">{{ __('غير محدد') }}</span>
@endisset
</td>
<td class="px-4 py-3 text-center" dir="ltr">
@isset($prices[$program->id]['non_member'])
<span class="font-medium text-gray-800">{{ format_money($prices[$program->id]['non_member']) }}</span>
@else
<span class="text-xs text-amber-600">{{ __('غير محدد') }}</span>
@endisset
</td>
<td class="px-4 py-3 text-center"> <td class="px-4 py-3 text-center">
@php @php
$statusValue = $program->status->value ?? $program->status; $statusValue = $program->status->value ?? $program->status;
...@@ -138,7 +157,7 @@ class="text-blue-600 hover:text-blue-800 text-sm font-medium"> ...@@ -138,7 +157,7 @@ class="text-blue-600 hover:text-blue-800 text-sm font-medium">
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="7" class="px-4 py-12 text-center"> <td colspan="9" class="px-4 py-12 text-center">
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg> <svg class="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
<p class="text-lg font-medium text-gray-500">{{ __('لا توجد برامج تدريبية') }}</p> <p class="text-lg font-medium text-gray-500">{{ __('لا توجد برامج تدريبية') }}</p>
<p class="text-sm text-gray-400 mt-1">{{ __('ابدأ بإضافة برنامج جديد') }}</p> <p class="text-sm text-gray-400 mt-1">{{ __('ابدأ بإضافة برنامج جديد') }}</p>
...@@ -191,6 +210,21 @@ class="text-blue-600 hover:text-blue-800 text-sm font-medium"> ...@@ -191,6 +210,21 @@ class="text-blue-600 hover:text-blue-800 text-sm font-medium">
</span> </span>
</div> </div>
<div class="grid grid-cols-2 gap-2 mb-3 text-sm">
<div class="bg-gray-50 rounded-lg px-3 py-2">
<p class="text-xs text-gray-500">{{ term('member_price') }}</p>
<p class="font-medium text-gray-800" dir="ltr">
{{ isset($prices[$program->id]['member']) ? format_money($prices[$program->id]['member']) : __('غير محدد') }}
</p>
</div>
<div class="bg-gray-50 rounded-lg px-3 py-2">
<p class="text-xs text-gray-500">{{ term('non_member_price') }}</p>
<p class="font-medium text-gray-800" dir="ltr">
{{ isset($prices[$program->id]['non_member']) ? format_money($prices[$program->id]['non_member']) : __('غير محدد') }}
</p>
</div>
</div>
<div class="flex items-center justify-end gap-4 border-t border-gray-100 pt-3"> <div class="flex items-center justify-end gap-4 border-t border-gray-100 pt-3">
@permission('programs.update') @permission('programs.update')
@if($statusValue === 'draft' || $statusValue === 'closed') @if($statusValue === 'draft' || $statusValue === 'closed')
......
<?php
namespace Tests\Feature;
use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Training\Models\TrainingSchedule;
use App\Livewire\Programs\ProgramForm;
use App\Models\User;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Setting a programme's weekly timetable without the grid.
*
* A club that trains Sunday and Tuesday from four to half five should be able
* to say so on the programme form and be done — no facility, no space, no
* placement. The visual builder still owns anything actually placed on the
* grid, and this form must not touch those rows: clearing a checkbox here
* cannot be allowed to strip a space reservation off a scheduled session.
*
* Runs against a restored Postgres tenant; skips on the default SQLite suite:
*
* DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter ProgramScheduleTest
*/
class ProgramScheduleTest extends TestCase
{
private TrainingProgram $program;
/** Everything on the group created after this moment is this test's doing. */
private \Carbon\CarbonInterface $startedAt;
protected function setUp(): void
{
parent::setUp();
if (config('database.default') !== 'pgsql') {
$this->markTestSkipped('Needs a restored Postgres tenant; see the class comment.');
}
$academy = \App\Domain\Shared\Models\Academy::query()->first();
if (! $academy) {
$this->markTestSkipped('No academy in the restored tenant.');
}
app()->instance('current_academy', $academy);
// A programme whose default group carries no timetable yet, so the test
// adds rows rather than editing a real one, and teardown can remove
// exactly what it made.
$program = TrainingProgram::withoutGlobalScopes()
->whereHas('groups', fn ($q) => $q->whereDoesntHave('schedules'))
->first();
if (! $program) {
$this->markTestSkipped('No programme with an unscheduled group in the restored tenant.');
}
$this->program = $program;
$this->startedAt = now()->subSecond();
}
protected function tearDown(): void
{
if (isset($this->program)) {
$this->clearSchedules();
}
parent::tearDown();
}
/**
* Remove every schedule row on the default group, and the sessions saving
* one generated.
*
* The sessions are the point: TrainingSchedule::saved() generates a week of
* them, and training_sessions.schedule_id is a hard foreign key — which is
* precisely why the service under test retires a row instead of deleting
* it.
*/
private function clearSchedules(): void
{
$group = $this->program->defaultGroup();
if (! $group) {
return;
}
// Sessions first, and by creation time rather than by schedule id: if
// the assertions failed part-way the schedule rows may already be gone,
// and a session left pointing at nothing is a row on no branch that
// shows on no screen — which the isolation sweep rightly fails on.
\App\Domain\Training\Models\TrainingSession::withoutGlobalScopes()
->where('training_group_id', $group->id)
->where('created_at', '>=', $this->startedAt)
->forceDelete();
TrainingSchedule::withoutGlobalScopes()
->where('training_group_id', $group->id)
->delete();
}
private function anOwner(): User
{
$user = User::withoutGlobalScopes()
->whereHas('primaryRole', fn ($q) => $q->where('slug', 'academy_owner'))
->first();
if (! $user) {
$this->markTestSkipped('No academy_owner in the restored tenant.');
}
return $user;
}
/** The rows this form owns: the default group's, with no facility. */
private function managedRows()
{
return TrainingSchedule::withoutGlobalScopes()
->where('training_group_id', $this->program->defaultGroup()->id)
->whereNull('facility_id')
->get();
}
public function test_the_edit_screen_renders(): void
{
$this->actingAs($this->anOwner())
->get(route('programs.edit', $this->program))
->assertOk()
->assertSee('مواعيد التمرين', escape: false);
}
public function test_days_and_hours_alone_write_a_timetable(): void
{
$this->assertNotNull($this->program->defaultGroup(), 'Every programme is created with a default group.');
app(\App\Domain\Training\Services\TrainingProgramService::class)
->syncDefaultGroupSchedule($this->program, [0, 2], '16:00', '17:30');
$rows = $this->managedRows()->where('is_active', true);
$this->assertEqualsCanonicalizing([0, 2], $rows->pluck('day_of_week')->map('intval')->all());
foreach ($rows as $row) {
$this->assertSame('16:00', substr((string) $row->start_time, 0, 5));
$this->assertSame('17:30', substr((string) $row->end_time, 0, 5));
$this->assertNull($row->facility_id, 'No facility should be demanded to record a timetable.');
}
}
public function test_a_day_removed_from_the_selection_is_deactivated_not_deleted(): void
{
$service = app(\App\Domain\Training\Services\TrainingProgramService::class);
$service->syncDefaultGroupSchedule($this->program, [0, 2], '16:00', '17:30');
$tuesdayId = $this->managedRows()->firstWhere('day_of_week', 2)?->id;
$this->assertNotNull($tuesdayId, 'Tuesday was never written.');
$service->syncDefaultGroupSchedule($this->program, [0], '16:00', '17:30');
$tuesday = TrainingSchedule::withoutGlobalScopes()->find($tuesdayId);
// Sessions already generated point at this row and attendance hangs off
// those sessions, so dropping the row would orphan them.
$this->assertNotNull($tuesday, 'The row was deleted rather than retired.');
$this->assertFalse((bool) $tuesday->is_active);
}
public function test_a_row_the_grid_placed_is_left_alone(): void
{
$group = $this->program->defaultGroup();
$facility = \App\Domain\Facility\Models\Facility::withoutGlobalScopes()->first();
if (! $facility) {
$this->markTestSkipped('No facility in the restored tenant to place a row at.');
}
$placed = TrainingSchedule::withoutGlobalScopes()->create([
'academy_id' => $group->academy_id,
'training_group_id' => $group->id,
'facility_id' => $facility->id,
'day_of_week' => 4,
'start_time' => '19:00',
'end_time' => '20:30',
'effective_from' => now()->toDateString(),
'is_active' => true,
]);
// Thursday is deliberately absent from the selection below.
app(\App\Domain\Training\Services\TrainingProgramService::class)
->syncDefaultGroupSchedule($this->program, [0], '16:00', '17:30');
$placed->refresh();
$this->assertTrue((bool) $placed->is_active, 'The form retired a row belonging to the grid.');
$this->assertSame($facility->id, $placed->facility_id);
// tearDown() clears both rows and the sessions they generated.
}
public function test_a_new_programme_defaults_to_renewing_on_the_first(): void
{
Livewire::actingAs($this->anOwner())
->test(ProgramForm::class)
->assertSet('renewal_policy', 'auto_renew')
->assertSet('billing_cycle', 'monthly')
->assertSet('billing_day', 1);
}
}
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