Commit f282f7bc authored by Claude's avatar Claude

Filter every management screen by the active branch

The system predates branches, and adoption of the branch switcher was
partial: 44 of 186 Livewire components used UsesBranchScope, and several
that imported it never actually called it. The dashboard was the worst
case — half its widgets were branch-aware and half silently reported
academy-wide totals next to them, so the numbers on one screen were not
comparable with each other.

OC-Sport runs 7 active branches, so every unscoped widget was showing
six other branches' data.

Dashboard: scoped trainers-present, pending payslips, pending documents,
low stock and expiring medical certificates, which were academy-wide.
All six dashboard widgets (revenue, product revenue, subscription
revenue, enrolment trends, overdue renewals, trainer dues) now filter by
branch, including the raw-SQL CTEs in the revenue breakdowns.

Lists and reports: events, evaluations, base prices, pricing rules,
promotions, stock counts, kits, document approvals, trainers, trainer
advances, payroll, essential deliveries and the financial report.

Pickers: participant, group, program, facility, warehouse, product and
employee selectors now offer only the active branch's records, so a
transfer or invoice cannot silently reference another branch.

POS and InvoiceShow used auth()->user()->branch_id directly, ignoring
the switcher entirely — a user who switched branch still transacted
against their home branch. Both now read the active branch.

Deliberately left unscoped: parent- and guardian-facing screens, which
are scoped to their own children and have no branch switcher, and
single-record detail screens, which are already scoped by the record and
would hide legitimately related history for participants who moved
between branches.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent 60eabb70
......@@ -3,6 +3,7 @@
namespace App\Livewire\Activities;
use App\Domain\HR\Models\Trainer;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\Activity;
use App\Domain\Training\Services\ActivityService;
use App\Livewire\Concerns\WithSorting;
......@@ -15,7 +16,7 @@
#[Title('الأنشطة')]
class ActivityList extends Component
{
use WithSorting;
use WithSorting, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -57,6 +58,7 @@ public function delete(string $uuid, ActivityService $service): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = Activity::query()
->withCount('participants')
->when($this->search, fn ($q) => $q->where('name_ar', 'ilike', "%{$this->search}%")
......@@ -65,7 +67,9 @@ public function render()
->orderBy($this->sortBy, $this->sortDir);
$trainersByActivity = [];
foreach (Trainer::with('employee.person')->where('status', 'active')->get() as $trainer) {
foreach (Trainer::with('employee.person')->where('status', 'active')
->when($branchId, fn ($q) => $q->whereHas('employee', fn ($e) => $e->forBranch($branchId)))
->get() as $trainer) {
if (!empty($trainer->sports)) {
foreach ($trainer->sports as $actId) {
$trainersByActivity[$actId][] = $trainer->employee->person->name_ar ?? '';
......
......@@ -9,6 +9,7 @@
use App\Domain\Financial\Services\PaymentService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Layout;
......@@ -19,6 +20,8 @@
#[Title('تصحيح فاتورة')]
class InvoiceCorrectionWizard extends Component
{
use UsesBranchScope;
public int $currentStep = 1;
// Step 1: Find participant
......@@ -322,7 +325,9 @@ public function render()
{
$searchResults = collect();
if (strlen($this->search) >= 2 && !$this->selectedParticipantId) {
$branchId = $this->getActiveBranchId();
$searchResults = Participant::query()
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->with('person')
->where(function ($q) {
$search = $this->search;
......
......@@ -6,6 +6,7 @@
use App\Domain\Scheduling\Enums\AssignmentStatus;
use App\Domain\Scheduling\Services\AssignmentService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
......@@ -17,6 +18,8 @@
#[Title('إنشاء تكليف')]
class AssignmentForm extends Component
{
use UsesBranchScope;
public string $user_id = '';
public string $assignable_type = 'group';
public string $assignable_id = '';
......@@ -122,12 +125,15 @@ public function render()
$assignableOptions = [];
if ($this->assignable_type === 'group') {
$assignableOptions = TrainingGroup::orderBy('name_ar')
$assignableOptions = TrainingGroup::query()
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->orderBy('name_ar')
->get(['id', 'name_ar', 'name'])
->mapWithKeys(fn ($g) => [$g->id => $g->name_ar])
->toArray();
} elseif ($this->assignable_type === 'session') {
$assignableOptions = TrainingSession::with('group')
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->orderByDesc('session_date')
->limit(100)
->get()
......
......@@ -131,7 +131,10 @@ public function render()
->where('assignable_type', TrainingGroup::class)
->pluck('assignable_id');
$branchId = $this->getActiveBranchId();
$headTrainerGroupIds = TrainingGroup::where('head_trainer_id', $user->id)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereIn('status', ['active', 'forming'])
->pluck('id');
......@@ -139,6 +142,7 @@ public function render()
// Today's sessions for assigned groups
$todaySessions = TrainingSession::whereIn('training_group_id', $allGroupIds)
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->where('session_date', $today)
->whereIn('status', [SessionStatus::Scheduled, SessionStatus::InProgress, SessionStatus::Completed])
->with('group')
......
......@@ -60,7 +60,10 @@ public function render()
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->count();
$trainersPresent = AttendanceRecord::whereHas('session', fn ($q) => $q->where('session_date', $today))
$trainersPresent = AttendanceRecord::whereHas('session', function ($q) use ($today, $branchId) {
$q->where('session_date', $today)
->when($branchId, fn ($s) => $s->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)));
})
->where('subject_type', \App\Models\User::class)
->whereIn('status', [AttendanceStatus::Present, AttendanceStatus::Late])
->count();
......@@ -101,16 +104,22 @@ public function render()
->count();
$pendingPayslips = Payslip::whereIn('status', [PayslipStatus::Draft, PayslipStatus::PendingApproval])
->when($branchId, fn ($q) => $q->whereHas('trainer.employee', fn ($e) => $e->forBranch($branchId)))
->count();
$pendingDocuments = Document::where('status', DocumentStatus::Pending)
->when($branchId, fn ($q) => $q->whereHasMorph('documentable', [Participant::class], fn ($p) => $p->where('branch_id', $branchId)))
->count();
$lowStockItems = Product::where('track_inventory', true)
->where('is_active', true)
->whereNotNull('min_stock_level')
->where('min_stock_level', '>', 0)
->whereHas('inventoryLevels', fn ($q) => $q->whereRaw('quantity_on_hand <= (SELECT min_stock_level FROM products WHERE products.id = inventory_levels.product_id)'))
->when($branchId, fn ($q) => $q->where(fn ($b) => $b->whereNull('branch_id')->orWhere('branch_id', $branchId)))
->whereHas('inventoryLevels', function ($q) use ($branchId) {
$q->whereRaw('quantity_on_hand <= (SELECT min_stock_level FROM products WHERE products.id = inventory_levels.product_id)')
->when($branchId, fn ($l) => $l->where('branch_id', $branchId));
})
->count();
$expiringMedicalCerts = Document::where('document_type', DocumentType::MedicalCertificate)
......@@ -118,6 +127,7 @@ public function render()
->whereNotNull('expires_at')
->where('expires_at', '<=', now()->addDays(7))
->where('expires_at', '>=', $today)
->when($branchId, fn ($q) => $q->whereHasMorph('documentable', [Participant::class], fn ($p) => $p->where('branch_id', $branchId)))
->count();
// Sessions that ended but attendance not taken
......
......@@ -2,20 +2,25 @@
namespace App\Livewire\Dashboard;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\Enrollment;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class EnrollmentTrends extends Component
{
use UsesBranchScope;
public string $period = '30';
public function render()
{
$branchId = $this->getActiveBranchId();
$days = (int) $this->period;
$startDate = now()->subDays($days)->toDateString();
$dailyEnrollments = Enrollment::where('created_at', '>=', $startDate)
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->select(DB::raw('DATE(created_at) as date'), DB::raw('COUNT(*) as count'))
->groupBy('date')
->orderBy('date')
......@@ -25,9 +30,12 @@ public function render()
$totalNew = array_sum($dailyEnrollments);
$cancelled = Enrollment::where('status', 'cancelled')
->where('updated_at', '>=', $startDate)
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->count();
$active = Enrollment::where('status', 'active')->count();
$active = Enrollment::where('status', 'active')
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->count();
return view('livewire.dashboard.enrollment-trends', [
'dailyEnrollments' => $dailyEnrollments,
......
......@@ -5,6 +5,7 @@
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Enums\EnrollmentStatus;
use App\Domain\Training\Enums\RenewalPolicy;
use App\Domain\Training\Models\Enrollment;
......@@ -12,6 +13,8 @@
class OverdueRenewalsAlert extends Component
{
use UsesBranchScope;
public bool $showList = false;
public function toggleList(): void
......@@ -22,11 +25,13 @@ public function toggleList(): void
public function render()
{
$today = now()->toDateString();
$branchId = $this->getActiveBranchId();
$unpaidInvoices = Invoice::whereIn('status', [InvoiceStatus::Sent, InvoiceStatus::PartiallyPaid, InvoiceStatus::Overdue])
->where('due_amount', '>', 0)
->where('billable_type', Participant::class)
->where('notes', 'like', '%تجديد%')
->when($branchId, fn ($q) => $q->whereHasMorph('billable', [Participant::class], fn ($p) => $p->where('branch_id', $branchId)))
->with(['billable.person'])
->orderBy('due_date')
->get();
......@@ -38,6 +43,7 @@ public function render()
RenewalPolicy::AutoRenew->value,
RenewalPolicy::ManualRenew->value,
]))
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->with(['participant.person', 'program', 'group.branch'])
->get()
->filter(function ($enrollment) {
......
......@@ -3,11 +3,14 @@
namespace App\Livewire\Dashboard;
use App\Domain\Financial\Models\Payment;
use App\Domain\Shared\Traits\UsesBranchScope;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class ProductRevenueWidget extends Component
{
use UsesBranchScope;
public string $period = 'month';
public function render()
......@@ -29,25 +32,27 @@ public function render()
};
$academyId = app('current_academy')?->id;
$branchId = $this->getActiveBranchId();
$productType = 'App\\Domain\\Inventory\\Models\\Product';
// Current period product revenue (pro-rata)
$totalProductRevenue = $this->getProductRevenue($startDate, null, $academyId, $productType);
$totalProductRevenue = $this->getProductRevenue($startDate, null, $academyId, $branchId, $productType);
// Previous period
$previousProductRevenue = $this->getProductRevenue($previousStart, $startDate, $academyId, $productType);
$previousProductRevenue = $this->getProductRevenue($previousStart, $startDate, $academyId, $branchId, $productType);
$change = $previousProductRevenue > 0
? round(($totalProductRevenue - $previousProductRevenue) / $previousProductRevenue * 100, 1)
: ($totalProductRevenue > 0 ? 100 : 0);
// Revenue per product
$byProduct = $this->getRevenueByProduct($startDate, $academyId, $productType);
$byProduct = $this->getRevenueByProduct($startDate, $academyId, $branchId, $productType);
// Total confirmed payments this period
$totalRevenue = Payment::where('status', 'confirmed')
->where('created_at', '>=', $startDate)
->when($academyId, fn ($q) => $q->where('academy_id', $academyId))
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->sum('amount');
$productPercent = $totalRevenue > 0
......@@ -62,6 +67,7 @@ public function render()
->where('payments.status', 'confirmed')
->where('payments.created_at', '>=', $startDate)
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->when($branchId, fn ($q) => $q->where('payments.branch_id', $branchId))
->sum('invoice_items.quantity');
return view('livewire.dashboard.product-revenue-widget', [
......@@ -74,7 +80,7 @@ public function render()
]);
}
private function getProductRevenue($from, $to, $academyId, string $productType): int
private function getProductRevenue($from, $to, $academyId, ?int $branchId, string $productType): int
{
$result = DB::table('payments')
->join('invoices', 'payments.invoice_id', '=', 'invoices.id')
......@@ -91,6 +97,7 @@ private function getProductRevenue($from, $to, $academyId, string $productType):
->where('payments.created_at', '>=', $from)
->when($to, fn ($q) => $q->where('payments.created_at', '<', $to))
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->when($branchId, fn ($q) => $q->where('payments.branch_id', $branchId))
->select(
DB::raw('SUM(payments.amount * prod_items.product_total / invoices.total_amount) as product_revenue')
)
......@@ -99,7 +106,7 @@ private function getProductRevenue($from, $to, $academyId, string $productType):
return (int) ($result ?? 0);
}
private function getRevenueByProduct($from, $academyId, string $productType)
private function getRevenueByProduct($from, $academyId, ?int $branchId, string $productType)
{
return DB::table('payments')
->join('invoices', 'payments.invoice_id', '=', 'invoices.id')
......@@ -112,6 +119,7 @@ private function getRevenueByProduct($from, $academyId, string $productType)
->where('invoices.total_amount', '>', 0)
->where('payments.created_at', '>=', $from)
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->when($branchId, fn ($q) => $q->where('payments.branch_id', $branchId))
->select(
'products.id as product_id',
'products.name_ar as product_name',
......
......@@ -2,6 +2,7 @@
namespace App\Livewire\Dashboard;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Enums\EnrollmentStatus;
use App\Domain\Training\Enums\RenewalPolicy;
use App\Domain\Training\Models\Enrollment;
......@@ -10,8 +11,11 @@
class RenewalsDueWidget extends Component
{
use UsesBranchScope;
public function render()
{
$branchId = $this->getActiveBranchId();
$today = now()->toDateString();
$upcoming3Days = now()->addDays(3)->toDateString();
......@@ -21,6 +25,7 @@ public function render()
RenewalPolicy::AutoRenew->value,
RenewalPolicy::ManualRenew->value,
]))
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->count();
$dueThisWeek = Enrollment::where('status', EnrollmentStatus::Active)
......@@ -29,6 +34,7 @@ public function render()
RenewalPolicy::AutoRenew->value,
RenewalPolicy::ManualRenew->value,
]))
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->count();
$overdue = Enrollment::where('status', EnrollmentStatus::Active)
......@@ -37,6 +43,7 @@ public function render()
RenewalPolicy::AutoRenew->value,
RenewalPolicy::ManualRenew->value,
]))
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->count();
$byBranch = Enrollment::where('enrollments.status', EnrollmentStatus::Active)
......@@ -45,6 +52,7 @@ public function render()
RenewalPolicy::AutoRenew->value,
RenewalPolicy::ManualRenew->value,
]))
->when($branchId, fn ($q) => $q->where('training_groups.branch_id', $branchId))
->join('training_groups', 'enrollments.training_group_id', '=', 'training_groups.id')
->join('branches', 'training_groups.branch_id', '=', 'branches.id')
->select('branches.name_ar as branch_name', DB::raw('COUNT(*) as count'))
......
......@@ -3,15 +3,20 @@
namespace App\Livewire\Dashboard;
use App\Domain\Financial\Models\Payment;
use App\Domain\Shared\Traits\UsesBranchScope;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class RevenueWidget extends Component
{
use UsesBranchScope;
public string $period = 'month';
public function render()
{
$branchId = $this->getActiveBranchId();
$startDate = match ($this->period) {
'today' => now()->startOfDay(),
'week' => now()->startOfWeek(),
......@@ -22,6 +27,7 @@ public function render()
$revenue = Payment::where('status', 'confirmed')
->where('created_at', '>=', $startDate)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->sum('amount');
$previousStart = match ($this->period) {
......@@ -34,6 +40,7 @@ public function render()
$previousRevenue = Payment::where('status', 'confirmed')
->whereBetween('created_at', [$previousStart, $startDate])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->sum('amount');
$change = $previousRevenue > 0
......@@ -42,6 +49,7 @@ public function render()
$byMethod = Payment::where('status', 'confirmed')
->where('created_at', '>=', $startDate)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->select('method', DB::raw('SUM(amount) as total'))
->groupBy('method')
->pluck('total', 'method')
......
......@@ -3,12 +3,15 @@
namespace App\Livewire\Dashboard;
use App\Domain\Financial\Models\Payment;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\Enrollment;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class SubscriptionRevenueWidget extends Component
{
use UsesBranchScope;
public string $period = 'month';
public function render()
......@@ -30,26 +33,28 @@ public function render()
};
$academyId = app('current_academy')?->id;
$branchId = $this->getActiveBranchId();
// Current period subscription revenue (pro-rata from invoice items)
$totalSubscriptionRevenue = $this->getSubscriptionRevenue($startDate, null, $academyId);
$totalSubscriptionRevenue = $this->getSubscriptionRevenue($startDate, null, $academyId, $branchId);
// Previous period
$previousSubscriptionRevenue = $this->getSubscriptionRevenue($previousStart, $startDate, $academyId);
$previousSubscriptionRevenue = $this->getSubscriptionRevenue($previousStart, $startDate, $academyId, $branchId);
$change = $previousSubscriptionRevenue > 0
? round(($totalSubscriptionRevenue - $previousSubscriptionRevenue) / $previousSubscriptionRevenue * 100, 1)
: ($totalSubscriptionRevenue > 0 ? 100 : 0);
// Revenue by activity
$byActivity = collect($this->getRevenueByActivity($startDate, $academyId));
$byActivity = collect($this->getRevenueByActivity($startDate, $academyId, $branchId));
// Top programs by subscription revenue
$topPrograms = collect($this->getTopPrograms($startDate, $academyId));
$topPrograms = collect($this->getTopPrograms($startDate, $academyId, $branchId));
// Enrollment counts
$enrollmentQuery = Enrollment::where('created_at', '>=', $startDate)
->when($academyId, fn ($q) => $q->where('academy_id', $academyId));
->when($academyId, fn ($q) => $q->where('academy_id', $academyId))
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)));
$newEnrollments = (clone $enrollmentQuery)->count();
$activeEnrollments = (clone $enrollmentQuery)->where('status', 'active')->count();
......@@ -58,12 +63,14 @@ public function render()
$previousEnrollments = Enrollment::whereBetween('created_at', [$previousStart, $startDate])
->when($academyId, fn ($q) => $q->where('academy_id', $academyId))
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->count();
// Total confirmed payments this period
$totalRevenue = Payment::where('status', 'confirmed')
->where('created_at', '>=', $startDate)
->when($academyId, fn ($q) => $q->where('academy_id', $academyId))
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->sum('amount');
$subscriptionPercent = $totalRevenue > 0
......@@ -90,7 +97,7 @@ public function render()
* Subscription items = invoice_items WHERE itemable_type IS NULL.
* For each payment: payment.amount * (invoice_sub_total / invoice.total_amount)
*/
private function getSubscriptionRevenue($from, $to, $academyId): int
private function getSubscriptionRevenue($from, $to, $academyId, ?int $branchId): int
{
$result = DB::table('payments')
->join('invoices', 'payments.invoice_id', '=', 'invoices.id')
......@@ -107,6 +114,7 @@ private function getSubscriptionRevenue($from, $to, $academyId): int
->where('payments.created_at', '>=', $from)
->when($to, fn ($q) => $q->where('payments.created_at', '<', $to))
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->when($branchId, fn ($q) => $q->where('payments.branch_id', $branchId))
->select(
DB::raw('SUM(payments.amount * sub_items.sub_total / invoices.total_amount) as subscription_revenue')
)
......@@ -120,7 +128,7 @@ private function getSubscriptionRevenue($from, $to, $academyId): int
* Uses the participant's latest enrollment to determine which activity the subscription belongs to.
* For participants with multiple activities, revenue is attributed to each proportionally.
*/
private function getRevenueByActivity($from, $academyId)
private function getRevenueByActivity($from, $academyId, ?int $branchId)
{
return DB::select("
WITH payment_sub_revenue AS (
......@@ -141,6 +149,7 @@ private function getRevenueByActivity($from, $academyId)
AND i.billable_type = 'App\\Domain\\Participant\\Models\\Participant'
AND p.created_at >= ?
" . ($academyId ? "AND p.academy_id = {$academyId}" : "") . "
" . ($branchId ? "AND p.branch_id = " . (int) $branchId : "") . "
),
participant_activity AS (
SELECT DISTINCT ON (e.participant_id)
......@@ -165,6 +174,7 @@ private function getRevenueByActivity($from, $academyId)
WHERE e.status IN ('active', 'completed')
AND e.created_at >= ?
" . ($academyId ? "AND e.academy_id = {$academyId}" : "") . "
" . ($branchId ? "AND e.training_group_id IN (SELECT id FROM training_groups WHERE branch_id = " . (int) $branchId . ")" : "") . "
GROUP BY a.id
)
SELECT
......@@ -185,7 +195,7 @@ private function getRevenueByActivity($from, $academyId)
/**
* Top programs by subscription revenue collected.
*/
private function getTopPrograms($from, $academyId)
private function getTopPrograms($from, $academyId, ?int $branchId)
{
return DB::select("
WITH payment_sub_revenue AS (
......@@ -206,6 +216,7 @@ private function getTopPrograms($from, $academyId)
AND i.billable_type = 'App\\Domain\\Participant\\Models\\Participant'
AND p.created_at >= ?
" . ($academyId ? "AND p.academy_id = {$academyId}" : "") . "
" . ($branchId ? "AND p.branch_id = " . (int) $branchId : "") . "
),
participant_program AS (
SELECT DISTINCT ON (e.participant_id)
......
......@@ -5,13 +5,17 @@
use App\Domain\HR\Models\Employee;
use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Models\TrainerCompensation;
use App\Domain\Shared\Traits\UsesBranchScope;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class TrainerDuesWidget extends Component
{
use UsesBranchScope;
public function render()
{
$branchId = $this->getActiveBranchId();
$totalDues = 0;
$trainerTotals = [];
$topTrainers = collect();
......@@ -22,6 +26,7 @@ public function render()
$compensations = TrainerCompensation::whereIn('status', ['pending', 'approved'])
->forPeriod($startOfMonth, $endOfMonth)
->when($branchId, fn ($q) => $q->whereHas('trainer.employee', fn ($e) => $e->forBranch($branchId)))
->select('trainer_id', DB::raw('SUM(amount) as total_amount'), DB::raw('COUNT(*) as records_count'))
->groupBy('trainer_id')
->get()
......@@ -29,6 +34,7 @@ public function render()
$salaryTrainers = Trainer::whereIn('compensation_model', ['salary', 'hybrid'])
->where('status', 'active')
->when($branchId, fn ($q) => $q->whereHas('employee', fn ($e) => $e->forBranch($branchId)))
->with('employee:id,salary_amount')
->get();
......
......@@ -5,6 +5,7 @@
use App\Domain\Document\Enums\DocumentStatus;
use App\Domain\Document\Enums\DocumentType;
use App\Domain\Document\Models\Document;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Livewire\Concerns\WithSorting;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -16,7 +17,7 @@
#[Title('اعتماد المستندات')]
class DocumentApprovalList extends Component
{
use WithPagination, WithSorting;
use WithPagination, WithSorting, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -46,7 +47,9 @@ public function render()
{
$this->authorize('documents.approve');
$branchId = $this->getActiveBranchId();
$query = Document::with(['documentable', 'uploader'])
->when($branchId, fn ($q) => $q->whereHasMorph('documentable', [\App\Domain\Participant\Models\Participant::class], fn ($p) => $p->where('branch_id', $branchId)))
->orderBy($this->sortBy, $this->sortDir);
if ($this->status) {
......
......@@ -6,6 +6,7 @@
use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingProgram;
......@@ -18,6 +19,8 @@
#[Title('نقل مشتركين')]
class TransferParticipantWizard extends Component
{
use UsesBranchScope;
public int $currentStep = 1;
public int $totalSteps = 4;
public bool $completed = false;
......@@ -312,7 +315,9 @@ public function render()
// Search results for single mode
$searchResults = collect();
if ($this->mode === 'single' && strlen($this->participantSearch) >= 2 && !$this->singleParticipantInfo) {
$branchId = $this->getActiveBranchId();
$searchResults = Participant::with('person')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereHas('person', function ($q) {
$q->where('name_ar', 'ilike', "%{$this->participantSearch}%")
->orWhere('name', 'ilike', "%{$this->participantSearch}%")
......@@ -327,6 +332,7 @@ public function render()
$sourceGroups = collect();
if ($this->mode === 'bulk' && !$this->sourceGroupId) {
$sourceGroups = TrainingGroup::whereIn('status', ['active', 'forming'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->where('current_count', '>', 0)
->with('program')
->orderBy('name_ar')
......
......@@ -4,6 +4,7 @@
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\Evaluation;
use App\Domain\Training\Models\EvaluationCriterion;
use App\Domain\Training\Models\TrainingGroup;
......@@ -16,6 +17,8 @@
#[Title('تقييم جديد')]
class EvaluationForm extends Component
{
use UsesBranchScope;
public ?Evaluation $evaluation = null;
public string $participant_id = '';
......@@ -154,8 +157,13 @@ private function saveEvaluation(EvaluationService $service, bool $shouldSubmit):
public function render()
{
$participants = Participant::with('person')->get();
$groups = TrainingGroup::orderBy('name_ar')->get(['id', 'name_ar']);
$branchId = $this->getActiveBranchId();
$participants = Participant::with('person')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->get();
$groups = TrainingGroup::orderBy('name_ar')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->get(['id', 'name_ar']);
$criteria = EvaluationCriterion::active()->orderBy('sort_order')->get();
return view('livewire.evaluations.evaluation-form', [
......
......@@ -2,6 +2,7 @@
namespace App\Livewire\Evaluations;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Enums\EvaluationStatus;
use App\Domain\Training\Models\Evaluation;
use App\Domain\Training\Models\TrainingGroup;
......@@ -18,7 +19,7 @@
#[Title('التقييمات')]
class EvaluationList extends Component
{
use WithPagination, AppliesRoleScope, WithSorting;
use WithPagination, AppliesRoleScope, WithSorting, UsesBranchScope;
protected string $scopePermission = 'evaluations.list';
......@@ -68,7 +69,9 @@ public function updatedGroupId(): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = Evaluation::query()
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->with(['participant.person', 'group.program', 'evaluator'])
->when($this->search, fn ($q) => $q->whereHas('participant.person', fn ($pq) => $pq->where('name_ar', 'ilike', "%{$this->search}%")))
->when($this->status, fn ($q) => $q->where('status', $this->status))
......
......@@ -9,6 +9,7 @@
use App\Domain\Event\Services\EventService;
use App\Domain\Facility\Models\Facility;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Website\Enums\MediaCollection;
use App\Domain\Website\Services\MediaService;
use Livewire\Attributes\Layout;
......@@ -20,7 +21,7 @@
#[Title('إنشاء حدث')]
class CreateEventWizard extends Component
{
use WithFileUploads;
use WithFileUploads, UsesBranchScope;
public ?Event $event = null;
public bool $editing = false;
......@@ -365,11 +366,14 @@ private function sanitizeFormFields(): void
public function render()
{
$branchId = $this->getActiveBranchId();
return view('livewire.events.create-event-wizard', [
'eventTypes' => EventType::cases(),
'locationTypes' => LocationType::cases(),
'fieldTypes' => FormFieldType::cases(),
'facilities' => Facility::where('status', 'active')->orderBy('name_ar')->get(['id', 'name_ar']),
'facilities' => Facility::where('status', 'active')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->orderBy('name_ar')->get(['id', 'name_ar']),
]);
}
}
......@@ -5,6 +5,7 @@
use App\Domain\Event\Enums\EventStatus;
use App\Domain\Event\Enums\EventType;
use App\Domain\Event\Models\Event;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Livewire\Concerns\WithSorting;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -16,7 +17,7 @@
#[Title('الأحداث')]
class EventList extends Component
{
use WithPagination, WithSorting;
use WithPagination, WithSorting, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -53,7 +54,9 @@ public function updatedType(): void
public function render()
{
$branchId = $this->getActiveBranchId();
$events = Event::query()
->when($branchId, fn ($q) => $q->whereHas('facility', fn ($f) => $f->where('branch_id', $branchId)))
->when($this->search, fn ($q) => $q->where('title', 'ilike', "%{$this->search}%"))
->when($this->status, fn ($q) => $q->where('status', $this->status))
->when($this->type, fn ($q) => $q->where('type', $this->type))
......
......@@ -8,6 +8,7 @@
use App\Domain\Facility\Models\SpaceSegment;
use App\Domain\Facility\Services\SpaceCollisionService;
use App\Domain\Facility\Services\SpaceLayoutService;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSchedule;
use Livewire\Attributes\Layout;
......@@ -18,6 +19,8 @@
#[Title('تعيين المجموعات على الملاعب')]
class SpaceAssignmentWizard extends Component
{
use UsesBranchScope;
public int $step = 1;
// Step 1: Select Group
......@@ -50,6 +53,7 @@ public function mount(): void
{
$this->authorize('facilities.manage_layouts');
$this->facilities = Facility::where('status', 'active')
->when($this->getActiveBranchId(), fn ($q) => $q->where('branch_id', $this->getActiveBranchId()))
->orderBy('name_ar')
->get(['id', 'name', 'name_ar', 'type'])
->toArray();
......@@ -301,6 +305,7 @@ public function render()
$searchResults = collect();
if (strlen($this->groupSearch) >= 2) {
$searchResults = TrainingGroup::with('program')
->when($this->getActiveBranchId(), fn ($q) => $q->where('branch_id', $this->getActiveBranchId()))
->where(function ($q) {
$q->where('name_ar', 'ilike', "%{$this->groupSearch}%")
->orWhere('name', 'ilike', "%{$this->groupSearch}%")
......
......@@ -3,11 +3,14 @@
namespace App\Livewire\Financial;
use App\Domain\Pricing\Models\Promotion;
use App\Domain\Shared\Traits\UsesBranchScope;
use Carbon\Carbon;
use Livewire\Component;
class CouponValidator extends Component
{
use UsesBranchScope;
public string $code = '';
public ?array $result = null;
public bool $checking = false;
......@@ -20,7 +23,10 @@ public function check(): void
return;
}
$branchId = $this->getActiveBranchId();
$promotion = Promotion::where('code', strtoupper(trim($this->code)))
->when($branchId, fn ($q) => $q->where(fn ($b) => $b->whereNull('branch_id')->orWhere('branch_id', $branchId)))
->where('is_active', true)
->first();
......
......@@ -5,6 +5,7 @@
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\PaymentPlan;
use App\Domain\Shared\Models\SystemSetting;
use App\Domain\Shared\Traits\UsesBranchScope;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
......@@ -13,6 +14,8 @@
#[Layout('layouts.app')]
class PaymentPlanCreate extends Component
{
use UsesBranchScope;
public ?int $invoiceId = null;
public int $installmentCount = 3;
public string $frequency = 'monthly';
......@@ -161,8 +164,10 @@ public function save(): void
public function render()
{
$branchId = $this->getActiveBranchId();
$invoice = $this->invoiceId ? Invoice::find($this->invoiceId) : null;
$invoices = Invoice::whereIn('status', ['sent', 'partially_paid', 'overdue'])
->when($branchId, fn ($q) => $q->whereHasMorph('billable', [\App\Domain\Participant\Models\Participant::class], fn ($pp) => $pp->where('branch_id', $branchId)))
->where('due_amount', '>', 0)
->orderByDesc('created_at')
->limit(50)
......
......@@ -6,6 +6,7 @@
use App\Domain\HR\Enums\EmploymentType;
use App\Domain\HR\Models\Employee;
use App\Domain\Identity\Services\PermissionService;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Livewire\Concerns\WithSorting;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -17,7 +18,7 @@
#[Title('الموظفين')]
class EmployeeList extends Component
{
use WithPagination, WithSorting;
use WithPagination, WithSorting, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -31,6 +32,12 @@ class EmployeeList extends Component
#[Url]
public string $branchId = '';
public function mount(): void
{
// Default the branch filter to whatever branch the user is working in.
$this->branchId = $this->branchId ?: (string) ($this->getActiveBranchId() ?? '');
}
public function updatedSearch(): void
{
$this->resetPage();
......
......@@ -8,6 +8,7 @@
use App\Domain\HR\Models\Payslip;
use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Services\PayrollService;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -18,7 +19,7 @@
#[Title('إدارة الرواتب')]
class PayrollDashboard extends Component
{
use WithPagination;
use WithPagination, UsesBranchScope;
#[Url]
public string $periodFilter = '';
......@@ -77,8 +78,10 @@ public function loadStats(): void
->first();
$this->totalPayrollThisMonth = $currentPeriod?->total_net ?? 0;
$branchId = $this->getActiveBranchId();
$this->pendingApprovals = Payslip::where('academy_id', $academyId)
->when($branchId, fn ($q) => $q->whereHas('trainer.employee', fn ($e) => $e->forBranch($branchId)))
->whereIn('status', [
PayslipStatus::Draft->value,
PayslipStatus::PendingApproval->value,
......@@ -86,12 +89,14 @@ public function loadStats(): void
->count();
$this->paidThisMonth = Payslip::where('academy_id', $academyId)
->when($branchId, fn ($q) => $q->whereHas('trainer.employee', fn ($e) => $e->forBranch($branchId)))
->where('status', PayslipStatus::Paid->value)
->whereMonth('paid_at', now()->month)
->whereYear('paid_at', now()->year)
->sum('net_amount');
$this->activeTrainers = Trainer::where('academy_id', $academyId)
->when($branchId, fn ($q) => $q->whereHas('employee', fn ($e) => $e->forBranch($branchId)))
->where('status', 'active')
->count();
}
......@@ -221,6 +226,7 @@ public function render()
$payslipsQuery = Payslip::with(['trainer.employee.person', 'trainer.person', 'period'])
->where('academy_id', app('current_academy')->id)
->when($this->getActiveBranchId(), fn ($q) => $q->whereHas('trainer.employee', fn ($e) => $e->forBranch($this->getActiveBranchId())))
->latest();
if ($this->payslipStatus) {
......
......@@ -6,6 +6,7 @@
use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Models\TrainerAdvance;
use App\Domain\HR\Services\TrainerAdvanceService;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -16,7 +17,7 @@
#[Title('السلف')]
class TrainerAdvances extends Component
{
use WithPagination;
use WithPagination, UsesBranchScope;
#[Url]
public string $statusFilter = '';
......@@ -125,7 +126,9 @@ public function resumeAdvance(int $id): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = TrainerAdvance::with(['trainer.employee.person', 'trainer.person'])
->when($branchId, fn ($q) => $q->whereHas('trainer.employee', fn ($e) => $e->forBranch($branchId)))
->latest('issued_date');
if ($this->statusFilter) {
......@@ -142,6 +145,7 @@ public function render()
}
$trainers = Trainer::where('status', 'active')
->when($branchId, fn ($q) => $q->whereHas('employee', fn ($e) => $e->forBranch($branchId)))
->with('employee.person', 'person')
->get();
......
......@@ -7,6 +7,7 @@
use App\Domain\HR\Models\Trainer;
use App\Domain\HR\Services\TrainerService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
......@@ -15,6 +16,8 @@
#[Title('بيانات المدرب')]
class TrainerForm extends Component
{
use UsesBranchScope;
public ?Trainer $trainer = null;
public bool $editing = false;
......@@ -250,7 +253,9 @@ public function render()
{
$searchResults = [];
if (strlen($this->employeeSearch) >= 2) {
$branchId = $this->getActiveBranchId();
$searchResults = Employee::with('person')
->when($branchId, fn ($q) => $q->forBranch($branchId))
->active()
->doesntHave('trainer')
->whereHas('person', function ($q) {
......
......@@ -6,6 +6,7 @@
use App\Domain\HR\Enums\TrainerStatus;
use App\Domain\HR\Models\Trainer;
use App\Domain\Identity\Services\PermissionService;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\Activity;
use App\Livewire\Concerns\WithSorting;
use Livewire\Attributes\Layout;
......@@ -18,7 +19,7 @@
#[Title('المدربين')]
class TrainerList extends Component
{
use WithPagination, WithSorting;
use WithPagination, WithSorting, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -46,7 +47,9 @@ public function updatedCompensationModel(): void
public function render()
{
$query = Trainer::with(['employee.person', 'employee.branch', 'person']);
$branchId = $this->getActiveBranchId();
$query = Trainer::with(['employee.person', 'employee.branch', 'person'])
->when($branchId, fn ($q) => $q->whereHas('employee', fn ($e) => $e->forBranch($branchId)));
if ($this->sortBy === 'name_ar') {
$query->join('employees', 'trainers.employee_id', '=', 'employees.id')
......
......@@ -4,6 +4,7 @@
use App\Domain\Financial\Models\InvoiceItem;
use App\Domain\Inventory\Models\Product;
use App\Domain\Shared\Traits\UsesBranchScope;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -15,7 +16,7 @@
#[Title('تسليم المنتجات الأساسية')]
class EssentialDeliveries extends Component
{
use WithPagination;
use WithPagination, UsesBranchScope;
#[Url]
public ?int $product_id = null;
......@@ -71,14 +72,17 @@ public function markUndelivered(int $itemId): void
public function render()
{
$branchId = $this->getActiveBranchId();
$essentialProductIds = Product::where('is_essential', true)
->where('is_active', true)
->when($branchId, fn ($q) => $q->where(fn ($b) => $b->whereNull('branch_id')->orWhere('branch_id', $branchId)))
->pluck('id');
$query = InvoiceItem::with(['invoice.billable.person', 'deliveredBy'])
->whereIn('itemable_id', $essentialProductIds)
->where('itemable_type', Product::class)
->whereHas('invoice', fn ($q) => $q->whereIn('status', ['sent', 'paid', 'partially_paid']))
->when($branchId, fn ($q) => $q->whereHas('invoice', fn ($i) => $i->whereHasMorph('billable', [\App\Domain\Participant\Models\Participant::class], fn ($pp) => $pp->where('branch_id', $branchId))))
->when($this->product_id, fn ($q) => $q->where('itemable_id', $this->product_id))
->when($this->status === 'pending', fn ($q) => $q->where('is_delivered', false))
->when($this->status === 'delivered', fn ($q) => $q->where('is_delivered', true))
......
......@@ -6,6 +6,7 @@
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Services\KitService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
......@@ -14,6 +15,8 @@
#[Title('الأطقم')]
class KitForm extends Component
{
use UsesBranchScope;
public ?Kit $kit = null;
public bool $editing = false;
......@@ -158,8 +161,10 @@ public function save(KitService $kitService): void
public function render()
{
$branchId = $this->getActiveBranchId();
return view('livewire.inventory.kit-form', [
'products' => Product::where('is_active', true)
->when($branchId, fn ($q) => $q->where(fn ($b) => $b->whereNull('branch_id')->orWhere('branch_id', $branchId)))
->orderBy('name_ar')
->get(['id', 'name_ar', 'name', 'sku']),
]);
......
......@@ -6,6 +6,7 @@
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\KitService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Livewire\Concerns\AppliesRoleScope;
use App\Livewire\Concerns\WithSorting;
use Livewire\Attributes\Layout;
......@@ -18,7 +19,7 @@
#[Title('الأطقم')]
class KitList extends Component
{
use WithPagination, AppliesRoleScope, WithSorting;
use WithPagination, AppliesRoleScope, WithSorting, UsesBranchScope;
protected string $scopePermission = 'inventory.manage';
......@@ -123,7 +124,9 @@ public function disassemble(KitService $kitService): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = Kit::query()
->when($branchId, fn ($q) => $q->whereHas('components.product', fn ($p) => $p->where(fn ($b) => $b->whereNull('branch_id')->orWhere('branch_id', $branchId))))
->withCount('components')
->when($this->search, function ($q) {
$search = $this->search;
......
......@@ -8,6 +8,7 @@
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\InventoryService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
......@@ -16,6 +17,8 @@
#[Title('تسوية المخزون')]
class StockAdjustmentWizard extends Component
{
use UsesBranchScope;
public int $currentStep = 1;
public int $totalSteps = 5;
public bool $completed = false;
......@@ -191,7 +194,10 @@ private function rulesForStep(int $step): array
public function render()
{
$warehouses = Warehouse::active()->orderBy('name_ar')->get(['id', 'name_ar', 'name', 'code']);
$branchId = $this->getActiveBranchId();
$warehouses = Warehouse::active()
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->orderBy('name_ar')->get(['id', 'name_ar', 'name', 'code']);
$products = collect();
if ($this->warehouseId) {
......
......@@ -8,6 +8,7 @@
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\StockCountService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
......@@ -16,6 +17,8 @@
#[Title('جرد مخزني جديد')]
class StockCountForm extends Component
{
use UsesBranchScope;
public ?int $warehouse_id = null;
public ?int $stockCountId = null;
public string $notes = '';
......@@ -174,7 +177,9 @@ public function save(): void
public function render()
{
return view('livewire.inventory.stock-count-form', [
'warehouses' => Warehouse::active()->orderBy('name_ar')->get(['id', 'name_ar', 'code']),
'warehouses' => Warehouse::active()
->when($this->getActiveBranchId(), fn ($q) => $q->where('branch_id', $this->getActiveBranchId()))
->orderBy('name_ar')->get(['id', 'name_ar', 'code']),
]);
}
}
......@@ -7,6 +7,7 @@
use App\Domain\Inventory\Services\StockCountService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Exceptions\InvalidStatusTransitionException;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Livewire\Concerns\AppliesRoleScope;
use App\Livewire\Concerns\WithSorting;
use Livewire\Attributes\Layout;
......@@ -19,7 +20,7 @@
#[Title('الجرد المخزني')]
class StockCountList extends Component
{
use WithPagination, AppliesRoleScope, WithSorting;
use WithPagination, AppliesRoleScope, WithSorting, UsesBranchScope;
protected string $scopePermission = 'inventory.manage';
......@@ -76,7 +77,9 @@ public function cancel(int $stockCountId): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = StockCount::query()
->when($branchId, fn ($q) => $q->whereHas('warehouse', fn ($w) => $w->where('branch_id', $branchId)))
->with(['warehouse', 'creator'])
->withCount('items')
->when($this->search, function ($q) {
......
......@@ -5,6 +5,7 @@
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
......@@ -13,6 +14,8 @@
#[Title('إنشاء فاتورة جديدة')]
class CreateInvoiceWizard extends Component
{
use UsesBranchScope;
public int $currentStep = 1;
public int $totalSteps = 5;
public bool $completed = false;
......@@ -213,7 +216,9 @@ public function render()
{
$searchResults = collect();
if (strlen($this->participantSearch) >= 2 && !$this->participantId) {
$branchId = $this->getActiveBranchId();
$searchResults = Participant::query()
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->with('person')
->where(function ($q) {
$search = $this->participantSearch;
......
......@@ -5,6 +5,7 @@
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -14,6 +15,8 @@
#[Title('إنشاء فاتورة')]
class InvoiceCreate extends Component
{
use UsesBranchScope;
public ?int $participant_id = null;
public string $contact_name = '';
public string $contact_phone = '';
......@@ -142,8 +145,10 @@ public function total(): float
public function render()
{
$branchId = $this->getActiveBranchId();
return view('livewire.invoices.invoice-create', [
'participants' => Participant::with('person')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereIn('status', ['registered', 'active'])
->orderByDesc('created_at')
->limit(200)
......
......@@ -9,6 +9,7 @@
use App\Domain\Financial\Services\PaymentService;
use App\Domain\Financial\Services\RefundService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
......@@ -19,6 +20,8 @@
#[Title('تفاصيل الفاتورة')]
class InvoiceShow extends Component
{
use UsesBranchScope;
public Invoice $invoice;
// Payment form
......@@ -62,7 +65,7 @@ public function recordPayment(PaymentService $service): void
try {
$service->recordPayment([
'academy_id' => $this->invoice->academy_id,
'branch_id' => auth()->user()->branch_id ?? $this->invoice->branch_id ?? null,
'branch_id' => $this->getActiveBranchId() ?? auth()->user()->branch_id ?? $this->invoice->branch_id ?? null,
'invoice_id' => $this->invoice->id,
'reference' => 'PAY-' . now()->format('YmdHis') . '-' . rand(100, 999),
'direction' => 'inbound',
......
......@@ -10,6 +10,7 @@
use App\Domain\POS\Enums\POSPaymentMethod;
use App\Domain\POS\Services\POSService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
......@@ -18,6 +19,8 @@
#[Title('نقطة البيع')]
class POSTerminal extends Component
{
use UsesBranchScope;
// Cart state
public array $cart = [];
public ?int $participantId = null;
......@@ -74,7 +77,8 @@ public function updatedParticipantSearch(): void
return;
}
$this->searchResults = Participant::whereHas('person', fn ($q) => $q->where('name_ar', 'ilike', "%{$this->participantSearch}%")
$this->searchResults = Participant::when($this->getActiveBranchId(), fn ($q) => $q->where('branch_id', $this->getActiveBranchId()))
->whereHas('person', fn ($q) => $q->where('name_ar', 'ilike', "%{$this->participantSearch}%")
->orWhere('phone', 'like', "%{$this->participantSearch}%")
)->with('person')->limit(10)->get()
->map(fn ($p) => ['id' => $p->id, 'name' => $p->person->name_ar, 'phone' => $p->person->phone ?? ''])
......@@ -362,7 +366,7 @@ public function checkout(POSService $posService): void
$transaction = $posService->processTransaction(
cartItems: $this->cart,
cashier: auth()->user(),
branchId: auth()->user()->branch_id ?? 0,
branchId: $this->getActiveBranchId() ?? auth()->user()->branch_id ?? 0,
paymentMethod: $this->paymentMethod,
participant: $participant,
couponCode: $this->couponCode ?: null,
......@@ -422,7 +426,7 @@ public function newTransaction(): void
public function render()
{
$branchId = auth()->user()->branch_id ?? null;
$branchId = $this->getActiveBranchId() ?? auth()->user()->branch_id ?? null;
$products = Product::where('is_active', true)
->when($branchId, fn ($q) => $q->where(function ($sub) use ($branchId) {
......
......@@ -3,6 +3,7 @@
namespace App\Livewire\Pricing;
use App\Domain\Pricing\Models\BasePrice;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Livewire\Concerns\WithSorting;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -14,7 +15,7 @@
#[Title('الأسعار الأساسية')]
class BasePriceList extends Component
{
use WithPagination, WithSorting;
use WithPagination, WithSorting, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -46,7 +47,9 @@ public function toggleActive(string $uuid): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = BasePrice::query()
->when($branchId, fn ($q) => $q->forBranch($branchId))
->with(['branch', 'priceable'])
->when($this->search, fn ($q) => $q->where('name_ar', 'ilike', "%{$this->search}%"))
->when($this->activeFilter !== '', fn ($q) => $q->where('is_active', $this->activeFilter === '1'))
......
......@@ -4,6 +4,7 @@
use App\Domain\Pricing\Enums\PricingRuleType;
use App\Domain\Pricing\Models\PricingRule;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Livewire\Concerns\WithSorting;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -15,7 +16,7 @@
#[Title('قواعد التسعير')]
class PricingRuleList extends Component
{
use WithPagination, WithSorting;
use WithPagination, WithSorting, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -61,7 +62,9 @@ public function toggleActive(string $uuid): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = PricingRule::query()
->when($branchId, fn ($q) => $q->forBranch($branchId))
->with(['branch'])
->when($this->search, fn ($q) => $q->where('name_ar', 'ilike', "%{$this->search}%"))
->when($this->ruleType, fn ($q) => $q->where('rule_type', $this->ruleType))
......
......@@ -4,6 +4,7 @@
use App\Domain\Pricing\Enums\PromotionType;
use App\Domain\Pricing\Models\Promotion;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Livewire\Concerns\WithSorting;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -15,7 +16,7 @@
#[Title('العروض والكوبونات')]
class PromotionList extends Component
{
use WithPagination, WithSorting;
use WithPagination, WithSorting, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -55,7 +56,9 @@ public function toggleActive(string $uuid): void
public function render()
{
$branchId = $this->getActiveBranchId();
$query = Promotion::query()
->when($branchId, fn ($q) => $q->where(fn ($b) => $b->whereNull('branch_id')->orWhere('branch_id', $branchId)))
->with(['branch'])
->when($this->search, fn ($q) => $q->where(function ($q2) {
$q2->where('name_ar', 'ilike', "%{$this->search}%")
......
<?php
namespace App\Livewire\Public;
use App\Domain\Website\Models\ContactSubmission;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Component;
/**
* Public contact form rendered by the contact_form block.
*
* Unauthenticated and internet-facing, so it is rate limited by IP and carries
* a honeypot; submissions land in the existing admin inbox unchanged.
*/
class WebsiteContactForm extends Component
{
public ?int $blockId = null;
public bool $showSubject = true;
public bool $showPhone = true;
public bool $requirePhone = false;
public ?string $submitLabel = null;
public ?string $successMessage = null;
public string $name = '';
public string $email = '';
public string $phone = '';
public string $subject = '';
public string $message = '';
/** Bots fill hidden fields; humans do not. */
public string $website = '';
public bool $sent = false;
protected function rules(): array
{
return [
'name' => 'required|string|min:3|max:120',
'email' => 'required|email|max:180',
'phone' => ($this->requirePhone ? 'required' : 'nullable').'|string|max:30',
'subject' => ($this->showSubject ? 'nullable' : 'nullable').'|string|max:200',
'message' => 'required|string|min:10|max:5000',
];
}
protected function messages(): array
{
return [
'name.required' => __('الاسم مطلوب'),
'name.min' => __('الاسم قصير جدًا'),
'email.required' => __('البريد الإلكتروني مطلوب'),
'email.email' => __('البريد الإلكتروني غير صالح'),
'phone.required' => __('رقم الهاتف مطلوب'),
'message.required' => __('الرسالة مطلوبة'),
'message.min' => __('الرسالة قصيرة جدًا'),
];
}
public function submit(): void
{
if (filled($this->website)) {
// Honeypot tripped — report success without persisting anything.
$this->sent = true;
return;
}
$key = 'website-contact:'.request()->ip();
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->addError('message', __('لقد أرسلت رسائل كثيرة. برجاء المحاولة لاحقًا.'));
return;
}
$data = $this->validate();
RateLimiter::hit($key, 3600);
ContactSubmission::create([
'name' => $data['name'],
'email' => $data['email'],
'phone' => $data['phone'] ?: null,
'subject' => $this->showSubject ? ($data['subject'] ?: null) : null,
'message' => $data['message'],
'status' => 'new',
]);
$this->reset(['name', 'email', 'phone', 'subject', 'message']);
$this->sent = true;
}
public function render()
{
return view('livewire.public.website-contact-form');
}
}
......@@ -4,6 +4,7 @@
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Payment;
use App\Domain\Shared\Traits\UsesBranchScope;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
......@@ -12,6 +13,8 @@
#[Layout('layouts.app')]
class FinancialReport extends Component
{
use UsesBranchScope;
#[Url]
public string $period = 'month';
......@@ -59,21 +62,26 @@ public function updatedPeriod(): void
public function render()
{
$branchId = $this->getActiveBranchId();
$payments = Payment::where('status', 'confirmed')
->whereBetween('created_at', [$this->dateFrom, $this->dateTo . ' 23:59:59'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->get();
$totalRevenue = $payments->sum('amount');
$paymentsByMethod = $payments->groupBy('method')
->map(fn ($group) => $group->sum('amount'));
$invoices = Invoice::whereBetween('created_at', [$this->dateFrom, $this->dateTo . ' 23:59:59'])->get();
$invoices = Invoice::whereBetween('created_at', [$this->dateFrom, $this->dateTo . ' 23:59:59'])
->when($branchId, fn ($q) => $q->whereHasMorph('billable', [\App\Domain\Participant\Models\Participant::class], fn ($pp) => $pp->where('branch_id', $branchId)))
->get();
$totalInvoiced = $invoices->sum('total_amount');
$totalOutstanding = $invoices->whereIn('status', ['sent', 'partially_paid', 'overdue'])->sum('due_amount');
$overdueCount = $invoices->where('status', 'overdue')->count();
$dailyRevenue = Payment::where('status', 'confirmed')
->whereBetween('created_at', [$this->dateFrom, $this->dateTo . ' 23:59:59'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->select(DB::raw('DATE(created_at) as date'), DB::raw('SUM(amount) as total'))
->groupBy('date')
->orderBy('date')
......@@ -85,6 +93,12 @@ public function render()
->where('invoices.status', '!=', 'cancelled')
->whereBetween('invoices.created_at', [$this->dateFrom, $this->dateTo . ' 23:59:59'])
->whereNotNull('invoice_items.itemable_type')
->when($branchId, fn ($q) => $q->whereExists(function ($s) use ($branchId) {
$s->selectRaw('1')->from('participants')
->whereColumn('participants.id', 'invoices.billable_id')
->where('invoices.billable_type', \App\Domain\Participant\Models\Participant::class)
->where('participants.branch_id', $branchId);
}))
->select('invoice_items.description', DB::raw('SUM(invoice_items.total_amount) as revenue'), DB::raw('COUNT(*) as count'))
->groupBy('invoice_items.description')
->orderByDesc('revenue')
......
<?php
namespace App\Livewire\Website;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Website\Models\WebsiteMenu;
use App\Domain\Website\Models\WebsiteMenuItem;
use App\Domain\Website\Models\WebsitePage;
use App\Domain\Website\Services\WebsiteMenuService;
use Livewire\Attributes\Url;
use Livewire\Component;
class MenuManager extends Component
{
#[Url]
public string $menuKey = 'primary';
public bool $showForm = false;
public ?int $editingId = null;
public ?int $parentId = null;
public string $label = '';
public string $label_en = '';
public string $link_type = 'page';
public ?int $website_page_id = null;
public string $url = '';
public string $anchor = '';
public string $icon = '';
public bool $open_in_new_tab = false;
public bool $is_visible = true;
public bool $highlight = false;
public function mount(): void
{
$this->authorize('settings.manage');
}
protected function rules(): array
{
return [
'label' => 'required|string|max:255',
'label_en' => 'nullable|string|max:255',
'link_type' => 'required|in:page,url,anchor,route,none',
'website_page_id' => 'nullable|integer|exists:website_pages,id',
'url' => 'nullable|string|max:500',
'anchor' => 'nullable|string|max:120',
'icon' => 'nullable|string|max:60',
];
}
protected function messages(): array
{
return [
'label.required' => __('نص العنصر مطلوب'),
'link_type.required' => __('نوع الرابط مطلوب'),
'link_type.in' => __('نوع الرابط غير صالح'),
'website_page_id.exists' => __('الصفحة المختارة غير موجودة'),
'url.max' => __('الرابط طويل جدًا'),
];
}
public function addItem(?int $parentId = null): void
{
$this->resetForm();
$this->parentId = $parentId;
$this->showForm = true;
}
public function edit(int $id): void
{
$item = WebsiteMenuItem::findOrFail($id);
$this->editingId = $item->id;
$this->parentId = $item->parent_id;
$this->label = $item->label ?? '';
$this->label_en = $item->label_en ?? '';
$this->link_type = $item->link_type;
$this->website_page_id = $item->website_page_id;
$this->url = $item->url ?? '';
$this->anchor = $item->anchor ?? '';
$this->icon = $item->icon ?? '';
$this->open_in_new_tab = $item->open_in_new_tab;
$this->is_visible = $item->is_visible;
$this->highlight = $item->highlight;
$this->showForm = true;
}
public function save(WebsiteMenuService $service): void
{
$this->authorize('settings.manage');
$data = $this->validate();
$data += [
'open_in_new_tab' => $this->open_in_new_tab,
'is_visible' => $this->is_visible,
'highlight' => $this->highlight,
];
try {
if ($this->editingId) {
$service->updateItem(WebsiteMenuItem::findOrFail($this->editingId), $data);
session()->flash('success', __('تم تحديث العنصر'));
} else {
$menu = $service->getOrCreate($this->menuKey);
$parent = $this->parentId ? WebsiteMenuItem::find($this->parentId) : null;
$service->addItem($menu, $data, $parent);
session()->flash('success', __('تمت إضافة العنصر'));
}
$this->showForm = false;
$this->resetForm();
} catch (DomainException $e) {
$this->addError('label', $e->getMessage());
}
}
public function delete(int $id, WebsiteMenuService $service): void
{
$this->authorize('settings.manage');
$service->deleteItem(WebsiteMenuItem::findOrFail($id));
session()->flash('success', __('تم حذف العنصر'));
}
public function move(int $id, int $direction, WebsiteMenuService $service): void
{
$this->authorize('settings.manage');
$service->move(WebsiteMenuItem::findOrFail($id), $direction);
}
public function toggleVisible(int $id, WebsiteMenuService $service): void
{
$this->authorize('settings.manage');
$item = WebsiteMenuItem::findOrFail($id);
$service->updateItem($item, ['is_visible' => ! $item->is_visible]);
}
private function resetForm(): void
{
$this->reset([
'editingId', 'parentId', 'label', 'label_en', 'website_page_id',
'url', 'anchor', 'icon', 'open_in_new_tab', 'highlight',
]);
$this->link_type = 'page';
$this->is_visible = true;
$this->resetErrorBag();
}
public function render(WebsiteMenuService $service)
{
$menu = $service->getOrCreate($this->menuKey);
return view('livewire.website.menu-manager', [
'menu' => $menu,
'items' => $menu->items()->with('childrenRecursive')->get(),
'pages' => WebsitePage::orderBy('sort_order')->get(),
'menuKeys' => WebsiteMenuService::KEYS,
]);
}
}
This diff is collapsed.
<?php
namespace App\Livewire\Website;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Website\Models\WebsitePage;
use App\Domain\Website\Services\WebsitePageService;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class PageManager extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $status = '';
#[Url]
public string $sortBy = 'sort_order';
#[Url]
public string $sortDir = 'asc';
public bool $showForm = false;
public ?int $editingId = null;
public string $title = '';
public string $title_en = '';
public string $slug = '';
public string $layout = 'default';
public string $meta_description = '';
public bool $is_published = false;
public bool $is_homepage = false;
public function mount(): void
{
$this->authorize('settings.manage');
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedStatus(): void
{
$this->resetPage();
}
protected function rules(): array
{
return [
'title' => 'required|string|max:255',
'title_en' => 'nullable|string|max:255',
'slug' => 'nullable|string|max:160|regex:/^[a-z0-9\-\/]*$/',
'layout' => 'required|in:default,full_width,narrow,blank,landing',
'meta_description' => 'nullable|string|max:320',
];
}
protected function messages(): array
{
return [
'title.required' => __('اسم الصفحة مطلوب'),
'title.max' => __('اسم الصفحة طويل جدًا'),
'slug.regex' => __('الرابط يجب أن يحتوي على حروف إنجليزية صغيرة وأرقام وشرطات فقط'),
'layout.required' => __('التخطيط مطلوب'),
'layout.in' => __('التخطيط المختار غير صالح'),
'meta_description.max' => __('وصف الصفحة طويل جدًا'),
];
}
public function create(): void
{
$this->resetForm();
$this->showForm = true;
}
public function edit(int $id): void
{
$page = WebsitePage::findOrFail($id);
$this->editingId = $page->id;
$this->title = $page->title ?? '';
$this->title_en = $page->title_en ?? '';
$this->slug = $page->slug;
$this->layout = $page->layout;
$this->meta_description = $page->meta_description ?? '';
$this->is_published = $page->is_published;
$this->is_homepage = $page->is_homepage;
$this->showForm = true;
}
public function save(WebsitePageService $service): void
{
$this->authorize('settings.manage');
$data = $this->validate();
$data['is_published'] = $this->is_published;
$data['is_homepage'] = $this->is_homepage;
try {
if ($this->editingId) {
$service->update(WebsitePage::findOrFail($this->editingId), $data);
session()->flash('success', __('تم تحديث الصفحة'));
} else {
$page = $service->create($data, auth()->user());
session()->flash('success', __('تم إنشاء الصفحة'));
$this->redirectRoute('website.manage.builder', ['page' => $page->id], navigate: true);
return;
}
$this->showForm = false;
$this->resetForm();
} catch (DomainException $e) {
$this->addError('title', $e->getMessage());
}
}
public function duplicate(int $id, WebsitePageService $service): void
{
$this->authorize('settings.manage');
try {
$service->duplicate(WebsitePage::findOrFail($id), auth()->user());
session()->flash('success', __('تم نسخ الصفحة'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function makeHomepage(int $id, WebsitePageService $service): void
{
$this->authorize('settings.manage');
$service->makeHomepage(WebsitePage::findOrFail($id));
session()->flash('success', __('تم تعيين الصفحة كصفحة رئيسية'));
}
public function togglePublish(int $id, WebsitePageService $service): void
{
$this->authorize('settings.manage');
$page = WebsitePage::findOrFail($id);
try {
$service->update($page, ['is_published' => ! $page->is_published]);
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function delete(int $id, WebsitePageService $service): void
{
$this->authorize('settings.manage');
try {
$service->delete(WebsitePage::findOrFail($id));
session()->flash('success', __('تم حذف الصفحة'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function sort(string $column): void
{
$this->sortDir = ($this->sortBy === $column && $this->sortDir === 'asc') ? 'desc' : 'asc';
$this->sortBy = $column;
}
private function resetForm(): void
{
$this->reset(['editingId', 'title', 'title_en', 'slug', 'meta_description', 'is_published', 'is_homepage']);
$this->layout = 'default';
$this->resetErrorBag();
}
public function render()
{
$pages = WebsitePage::query()
->when($this->search, fn ($q) => $q->where(fn ($w) => $w
->where('title', 'ilike', "%{$this->search}%")
->orWhere('title_en', 'ilike', "%{$this->search}%")
->orWhere('slug', 'ilike', "%{$this->search}%")))
->when($this->status === 'published', fn ($q) => $q->where('is_published', true))
->when($this->status === 'draft', fn ($q) => $q->where('is_published', false))
->withCount('allBlocks')
->orderBy($this->sortBy, $this->sortDir)
->paginate(15);
return view('livewire.website.page-manager', compact('pages'));
}
}
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