Commit 71a6c06f authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add financial overview dashboard, enforce setup wizard, fix permission system

Financial Overview:
- New FinancialOverview Livewire component with P&L, break-even gauge,
  facility costs breakdown, revenue sources, collection rate
- Migration adds monthly_rental_cost to facilities table
- Monthly cost field added to facility form
- Sidebar entry under المالية section

Setup Wizard Enforcement:
- Activities are now mandatory (min 1)
- Programs are now mandatory with price > 0
- Facilities are now mandatory with monthly cost field
- Auto-creates BasePrice records for programs during setup
- Facility form includes operating hours and monthly cost

Permission System Fix:
- PermissionSeeder now called from DatabaseSeeder (has correct permission
  names matching route middleware: pos.sell, attendance.mark, etc.)
- Branch manager gets all relevant module prefixes (inventory, pricing,
  wallets, activities, audit, notifications)
- Receptionist gets pos.sell, pos.list, cash_sessions.manage
- Accountant gets reports.view, wallets.view, pricing.list
- PermissionService falls back to roles() pivot when primaryRole is null
- SetCurrentAcademy middleware eager-loads roles.permissions as fallback
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 2fc16d13
...@@ -39,6 +39,7 @@ class Facility extends Model ...@@ -39,6 +39,7 @@ class Facility extends Model
'operating_start', 'operating_start',
'operating_end', 'operating_end',
'rental_cost_per_hour', 'rental_cost_per_hour',
'monthly_rental_cost',
'address', 'address',
'latitude', 'latitude',
'longitude', 'longitude',
...@@ -62,6 +63,7 @@ protected function casts(): array ...@@ -62,6 +63,7 @@ protected function casts(): array
'has_lighting' => 'boolean', 'has_lighting' => 'boolean',
'has_ac' => 'boolean', 'has_ac' => 'boolean',
'rental_cost_per_hour' => 'integer', 'rental_cost_per_hour' => 'integer',
'monthly_rental_cost' => 'integer',
'amenities' => 'array', 'amenities' => 'array',
'metadata' => 'array', 'metadata' => 'array',
'sort_order' => 'integer', 'sort_order' => 'integer',
......
...@@ -19,9 +19,16 @@ public function can(User $user, string $permission, ?Model $record = null): bool ...@@ -19,9 +19,16 @@ public function can(User $user, string $permission, ?Model $record = null): bool
// Get user's role (primary role via role_id for performance) // Get user's role (primary role via role_id for performance)
$role = $user->primaryRole; $role = $user->primaryRole;
if (!$role) {
// Fallback: check many-to-many roles
$role = $user->roles->first();
if (!$role) { if (!$role) {
return false; return false;
} }
if (!$role->relationLoaded('permissions')) {
$role->load('permissions');
}
}
// Check if role has this permission // Check if role has this permission
$rolePermission = $role->permissions->firstWhere('name', $permission); $rolePermission = $role->permissions->firstWhere('name', $permission);
...@@ -56,7 +63,13 @@ public function applyScope(Builder $query, User $user, string $permission): Buil ...@@ -56,7 +63,13 @@ public function applyScope(Builder $query, User $user, string $permission): Buil
$role = $user->primaryRole; $role = $user->primaryRole;
if (!$role) { if (!$role) {
return $query->whereRaw('1 = 0'); // no results $role = $user->roles->first();
if (!$role) {
return $query->whereRaw('1 = 0');
}
if (!$role->relationLoaded('permissions')) {
$role->load('permissions');
}
} }
$rolePermission = $role->permissions->firstWhere('name', $permission); $rolePermission = $role->permissions->firstWhere('name', $permission);
......
...@@ -21,6 +21,8 @@ public function handle(Request $request, Closure $next): Response ...@@ -21,6 +21,8 @@ public function handle(Request $request, Closure $next): Response
// Eager-load role with permissions for permission checks // Eager-load role with permissions for permission checks
if ($user->role_id && !$user->relationLoaded('primaryRole')) { if ($user->role_id && !$user->relationLoaded('primaryRole')) {
$user->load('primaryRole.permissions'); $user->load('primaryRole.permissions');
} elseif (!$user->role_id && !$user->relationLoaded('roles')) {
$user->load('roles.permissions');
} }
} }
......
...@@ -37,6 +37,7 @@ class FacilityForm extends Component ...@@ -37,6 +37,7 @@ class FacilityForm extends Component
public string $operating_start = ''; public string $operating_start = '';
public string $operating_end = ''; public string $operating_end = '';
public ?string $rental_cost_display = null; public ?string $rental_cost_display = null;
public ?string $monthly_rental_cost_display = null;
public string $address = ''; public string $address = '';
public ?string $latitude = null; public ?string $latitude = null;
public ?string $longitude = null; public ?string $longitude = null;
...@@ -68,6 +69,7 @@ public function mount(?Facility $facility = null): void ...@@ -68,6 +69,7 @@ public function mount(?Facility $facility = null): void
$this->operating_start = $facility->operating_start ?? ''; $this->operating_start = $facility->operating_start ?? '';
$this->operating_end = $facility->operating_end ?? ''; $this->operating_end = $facility->operating_end ?? '';
$this->rental_cost_display = $facility->rental_cost_per_hour ? (string) ($facility->rental_cost_per_hour / 100) : null; $this->rental_cost_display = $facility->rental_cost_per_hour ? (string) ($facility->rental_cost_per_hour / 100) : null;
$this->monthly_rental_cost_display = $facility->monthly_rental_cost ? (string) ($facility->monthly_rental_cost / 100) : null;
$this->address = $facility->address ?? ''; $this->address = $facility->address ?? '';
$this->latitude = $facility->latitude; $this->latitude = $facility->latitude;
$this->longitude = $facility->longitude; $this->longitude = $facility->longitude;
...@@ -97,6 +99,7 @@ public function rules(): array ...@@ -97,6 +99,7 @@ public function rules(): array
'operating_start' => 'nullable|date_format:H:i', 'operating_start' => 'nullable|date_format:H:i',
'operating_end' => 'nullable|date_format:H:i|after:operating_start', 'operating_end' => 'nullable|date_format:H:i|after:operating_start',
'rental_cost_display' => 'nullable|numeric|min:0', 'rental_cost_display' => 'nullable|numeric|min:0',
'monthly_rental_cost_display' => 'nullable|numeric|min:0',
'address' => 'nullable|string|max:255', 'address' => 'nullable|string|max:255',
'latitude' => 'nullable|numeric|between:-90,90', 'latitude' => 'nullable|numeric|between:-90,90',
'longitude' => 'nullable|numeric|between:-180,180', 'longitude' => 'nullable|numeric|between:-180,180',
...@@ -123,8 +126,10 @@ public function messages(): array ...@@ -123,8 +126,10 @@ public function messages(): array
'operating_start.date_format' => 'وقت البداية يجب أن يكون بصيغة ساعة:دقيقة', 'operating_start.date_format' => 'وقت البداية يجب أن يكون بصيغة ساعة:دقيقة',
'operating_end.date_format' => 'وقت النهاية يجب أن يكون بصيغة ساعة:دقيقة', 'operating_end.date_format' => 'وقت النهاية يجب أن يكون بصيغة ساعة:دقيقة',
'operating_end.after' => 'وقت النهاية يجب أن يكون بعد وقت البداية', 'operating_end.after' => 'وقت النهاية يجب أن يكون بعد وقت البداية',
'rental_cost_display.numeric' => 'تكلفة الإيجار يجب أن تكون رقم', 'rental_cost_display.numeric' => 'تكلفة الإيجار بالساعة يجب أن تكون رقم',
'rental_cost_display.min' => 'تكلفة الإيجار لا يمكن أن تكون سالبة', 'rental_cost_display.min' => 'تكلفة الإيجار بالساعة لا يمكن أن تكون سالبة',
'monthly_rental_cost_display.numeric' => 'تكلفة الإيجار الشهري يجب أن تكون رقم',
'monthly_rental_cost_display.min' => 'تكلفة الإيجار الشهري لا يمكن أن تكون سالبة',
'branch_id.required' => 'الفرع مطلوب', 'branch_id.required' => 'الفرع مطلوب',
'branch_id.exists' => 'الفرع غير موجود', 'branch_id.exists' => 'الفرع غير موجود',
'sort_order.integer' => 'الترتيب يجب أن يكون رقم صحيح', 'sort_order.integer' => 'الترتيب يجب أن يكون رقم صحيح',
...@@ -158,6 +163,7 @@ public function save(FacilityService $service): void ...@@ -158,6 +163,7 @@ public function save(FacilityService $service): void
'operating_start' => $this->operating_start ?: null, 'operating_start' => $this->operating_start ?: null,
'operating_end' => $this->operating_end ?: null, 'operating_end' => $this->operating_end ?: null,
'rental_cost_per_hour' => $this->rental_cost_display ? (int) round((float) $this->rental_cost_display * 100) : null, 'rental_cost_per_hour' => $this->rental_cost_display ? (int) round((float) $this->rental_cost_display * 100) : null,
'monthly_rental_cost' => $this->monthly_rental_cost_display ? (int) round((float) $this->monthly_rental_cost_display * 100) : null,
'address' => $this->address ?: null, 'address' => $this->address ?: null,
'latitude' => $this->latitude, 'latitude' => $this->latitude,
'longitude' => $this->longitude, 'longitude' => $this->longitude,
......
<?php
namespace App\Livewire\Financial;
use App\Domain\Facility\Models\Facility;
use App\Domain\Financial\Enums\AccountType;
use App\Domain\Financial\Models\FinancialAccount;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Payment;
use App\Domain\Financial\Models\Transaction;
use App\Domain\Identity\Models\Branch;
use App\Domain\Inventory\Models\PurchaseOrder;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('النظرة المالية العامة')]
class FinancialOverview extends Component
{
#[Url]
public string $period = 'this_month';
#[Url]
public string $branch_id = '';
public function mount()
{
$this->authorize('invoices.list');
}
public function render()
{
[$from, $to] = $this->resolvePeriod();
$branches = Branch::orderBy('name_ar')->get();
$facilityCosts = $this->getFacilityCosts();
$revenue = $this->getRevenue($from, $to);
$expenses = $this->getExpenses($from, $to);
$monthlyPL = $this->getMonthlyPL();
$breakEven = $this->getBreakEvenData($facilityCosts, $revenue);
$revenueBySource = $this->getRevenueBySource($from, $to);
$collectionRate = $this->getCollectionRate($from, $to);
return view('livewire.financial.financial-overview', [
'branches' => $branches,
'facilityCosts' => $facilityCosts,
'revenue' => $revenue,
'expenses' => $expenses,
'monthlyPL' => $monthlyPL,
'breakEven' => $breakEven,
'revenueBySource' => $revenueBySource,
'collectionRate' => $collectionRate,
'periodLabel' => $this->getPeriodLabel(),
'from' => $from,
'to' => $to,
]);
}
private function resolvePeriod(): array
{
return match ($this->period) {
'today' => [now()->startOfDay(), now()->endOfDay()],
'this_week' => [now()->startOfWeek(), now()->endOfWeek()],
'this_month' => [now()->startOfMonth(), now()->endOfMonth()],
'last_month' => [now()->subMonth()->startOfMonth(), now()->subMonth()->endOfMonth()],
'this_quarter' => [now()->firstOfQuarter(), now()->lastOfQuarter()->endOfDay()],
'this_year' => [now()->startOfYear(), now()->endOfYear()],
default => [now()->startOfMonth(), now()->endOfMonth()],
};
}
private function getPeriodLabel(): string
{
return match ($this->period) {
'today' => 'اليوم',
'this_week' => 'هذا الأسبوع',
'this_month' => 'هذا الشهر',
'last_month' => 'الشهر الماضي',
'this_quarter' => 'هذا الربع',
'this_year' => 'هذا العام',
default => 'هذا الشهر',
};
}
private function getFacilityCosts(): array
{
$query = Facility::query()->whereNotNull('monthly_rental_cost')->where('monthly_rental_cost', '>', 0);
if ($this->branch_id) {
$query->where('branch_id', $this->branch_id);
}
$facilities = $query->with('branch')->get();
$totalMonthly = $facilities->sum('monthly_rental_cost');
return [
'facilities' => $facilities->map(fn ($f) => [
'name' => $f->name_ar,
'branch' => $f->branch?->name_ar ?? '-',
'monthly_cost' => $f->monthly_rental_cost,
'hourly_cost' => $f->rental_cost_per_hour,
'status' => $f->status->value,
])->toArray(),
'total_monthly' => $totalMonthly,
'total_yearly' => $totalMonthly * 12,
];
}
private function getRevenue($from, $to): array
{
$query = Payment::where('direction', 'inbound')
->where('status', 'confirmed')
->whereBetween('payment_date', [$from, $to]);
if ($this->branch_id) {
$query->whereHas('invoice', fn ($q) => $q->whereHas('items', fn ($iq) => $iq->where('branch_id', $this->branch_id)));
}
$totalRevenue = $query->sum('amount');
$byMethod = Payment::where('direction', 'inbound')
->where('status', 'confirmed')
->whereBetween('payment_date', [$from, $to])
->select('method', DB::raw('SUM(amount) as total'), DB::raw('COUNT(*) as count'))
->groupBy('method')
->get();
return [
'total' => $totalRevenue,
'by_method' => $byMethod->map(fn ($p) => [
'method' => $p->method?->value ?? $p->method,
'total' => $p->total,
'count' => $p->count,
])->toArray(),
'transaction_count' => $query->count(),
];
}
private function getExpenses($from, $to): array
{
$outboundPayments = Payment::where('direction', 'outbound')
->where('status', 'confirmed')
->whereBetween('payment_date', [$from, $to])
->sum('amount');
$purchaseOrders = PurchaseOrder::whereIn('status', ['confirmed', 'received', 'partially_received'])
->whereBetween('created_at', [$from, $to])
->sum('total_amount');
$facilityCostsThisPeriod = $this->calculateProRatedFacilityCost($from, $to);
return [
'outbound_payments' => $outboundPayments,
'purchase_orders' => $purchaseOrders,
'facility_costs' => $facilityCostsThisPeriod,
'total' => $outboundPayments + $purchaseOrders + $facilityCostsThisPeriod,
];
}
private function calculateProRatedFacilityCost($from, $to): int
{
$query = Facility::whereNotNull('monthly_rental_cost')->where('monthly_rental_cost', '>', 0);
if ($this->branch_id) {
$query->where('branch_id', $this->branch_id);
}
$totalMonthly = $query->sum('monthly_rental_cost');
$days = max(1, now()->parse($from)->diffInDays(now()->parse($to)));
$daysInMonth = now()->daysInMonth;
return (int) round($totalMonthly * ($days / $daysInMonth));
}
private function getMonthlyPL(): array
{
$months = [];
for ($i = 5; $i >= 0; $i--) {
$month = now()->subMonths($i);
$start = $month->copy()->startOfMonth();
$end = $month->copy()->endOfMonth();
$income = Payment::where('direction', 'inbound')
->where('status', 'confirmed')
->whereBetween('payment_date', [$start, $end])
->sum('amount');
$outbound = Payment::where('direction', 'outbound')
->where('status', 'confirmed')
->whereBetween('payment_date', [$start, $end])
->sum('amount');
$purchases = PurchaseOrder::whereIn('status', ['confirmed', 'received', 'partially_received'])
->whereBetween('created_at', [$start, $end])
->sum('total_amount');
$facilityQuery = Facility::whereNotNull('monthly_rental_cost')->where('monthly_rental_cost', '>', 0);
if ($this->branch_id) {
$facilityQuery->where('branch_id', $this->branch_id);
}
$facilityCost = $facilityQuery->sum('monthly_rental_cost');
$totalExpenses = $outbound + $purchases + $facilityCost;
$months[] = [
'label' => $month->translatedFormat('M Y'),
'month_key' => $month->format('Y-m'),
'income' => $income,
'expenses' => $totalExpenses,
'profit' => $income - $totalExpenses,
];
}
return $months;
}
private function getBreakEvenData(array $facilityCosts, array $revenue): array
{
$monthlyFixedCosts = $facilityCosts['total_monthly'];
if ($monthlyFixedCosts <= 0) {
return [
'has_data' => false,
'message' => 'لم يتم تحديد تكاليف المنشآت الشهرية بعد',
];
}
$currentMonthRevenue = $revenue['total'];
$percentage = min(100, round(($currentMonthRevenue / $monthlyFixedCosts) * 100));
$remaining = max(0, $monthlyFixedCosts - $currentMonthRevenue);
return [
'has_data' => true,
'fixed_costs' => $monthlyFixedCosts,
'current_revenue' => $currentMonthRevenue,
'percentage' => $percentage,
'remaining' => $remaining,
'is_profitable' => $currentMonthRevenue >= $monthlyFixedCosts,
];
}
private function getRevenueBySource($from, $to): array
{
$revenueAccounts = FinancialAccount::where('type', AccountType::Revenue)->pluck('id', 'name_ar');
$bySource = Transaction::whereBetween('transaction_date', [$from, $to])
->whereIn('credit_account_id', $revenueAccounts->values())
->select('credit_account_id', DB::raw('SUM(amount) as total'))
->groupBy('credit_account_id')
->get();
$accountNames = FinancialAccount::whereIn('id', $bySource->pluck('credit_account_id'))->pluck('name_ar', 'id');
return $bySource->map(fn ($t) => [
'source' => $accountNames[$t->credit_account_id] ?? 'أخرى',
'total' => $t->total,
])->sortByDesc('total')->values()->toArray();
}
private function getCollectionRate($from, $to): array
{
$invoiced = Invoice::whereBetween('issue_date', [$from, $to])->sum('total_amount');
$collected = Invoice::whereBetween('issue_date', [$from, $to])->sum('paid_amount');
$overdue = Invoice::where('status', 'overdue')->sum('due_amount');
$overdueCount = Invoice::where('status', 'overdue')->count();
return [
'invoiced' => $invoiced,
'collected' => $collected,
'rate' => $invoiced > 0 ? round(($collected / $invoiced) * 100) : 0,
'overdue_amount' => $overdue,
'overdue_count' => $overdueCount,
];
}
}
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
use App\Domain\Training\Models\Activity; use App\Domain\Training\Models\Activity;
use App\Domain\Training\Models\TrainingProgram; use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Facility\Models\Facility; use App\Domain\Facility\Models\Facility;
use App\Domain\Pricing\Models\BasePrice;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
...@@ -178,6 +179,9 @@ public function addFacility(): void ...@@ -178,6 +179,9 @@ public function addFacility(): void
'name_ar' => '', 'name_ar' => '',
'type' => 'field', 'type' => 'field',
'capacity' => '', 'capacity' => '',
'monthly_cost' => '',
'operating_start' => '08:00',
'operating_end' => '22:00',
'address' => '', 'address' => '',
]; ];
} }
...@@ -260,9 +264,10 @@ protected function validateBranches(): void ...@@ -260,9 +264,10 @@ protected function validateBranches(): void
protected function validateActivities(): void protected function validateActivities(): void
{ {
// Activities are optional, but if added, must be valid
if (empty($this->activities)) { if (empty($this->activities)) {
return; throw \Illuminate\Validation\ValidationException::withMessages([
'activities' => __('يجب إضافة نشاط واحد على الأقل حتى يعمل النظام'),
]);
} }
$validCategories = implode(',', array_column(ActivityCategory::cases(), 'value')); $validCategories = implode(',', array_column(ActivityCategory::cases(), 'value'));
...@@ -281,7 +286,9 @@ protected function validateActivities(): void ...@@ -281,7 +286,9 @@ protected function validateActivities(): void
protected function validatePrograms(): void protected function validatePrograms(): void
{ {
if (empty($this->programs)) { if (empty($this->programs)) {
return; throw \Illuminate\Validation\ValidationException::withMessages([
'programs' => __('يجب إضافة برنامج واحد على الأقل مع تحديد السعر'),
]);
} }
$this->validate([ $this->validate([
...@@ -289,19 +296,22 @@ protected function validatePrograms(): void ...@@ -289,19 +296,22 @@ protected function validatePrograms(): void
'programs.*.activity_index' => 'required|integer|min:0', 'programs.*.activity_index' => 'required|integer|min:0',
'programs.*.duration_months' => 'required|integer|min:1|max:24', 'programs.*.duration_months' => 'required|integer|min:1|max:24',
'programs.*.max_participants' => 'required|integer|min:1|max:500', 'programs.*.max_participants' => 'required|integer|min:1|max:500',
'programs.*.monthly_fee' => 'required|numeric|min:0', 'programs.*.monthly_fee' => 'required|numeric|min:1',
], [ ], [
'programs.*.name_ar.required' => __('اسم البرنامج بالعربية مطلوب'), 'programs.*.name_ar.required' => __('اسم البرنامج بالعربية مطلوب'),
'programs.*.duration_months.required' => __('مدة البرنامج مطلوبة'), 'programs.*.duration_months.required' => __('مدة البرنامج مطلوبة'),
'programs.*.max_participants.required' => __('الحد الأقصى للمشتركين مطلوب'), 'programs.*.max_participants.required' => __('الحد الأقصى للمشتركين مطلوب'),
'programs.*.monthly_fee.required' => __('الرسوم الشهرية مطلوبة'), 'programs.*.monthly_fee.required' => __('الرسوم الشهرية مطلوبة'),
'programs.*.monthly_fee.min' => __('يجب تحديد سعر البرنامج (لا يمكن أن يكون صفر)'),
]); ]);
} }
protected function validateFacilities(): void protected function validateFacilities(): void
{ {
if (empty($this->facilities)) { if (empty($this->facilities)) {
return; throw \Illuminate\Validation\ValidationException::withMessages([
'facilities' => __('يجب إضافة منشأة واحدة على الأقل (ملعب، قاعة، حمام سباحة...)'),
]);
} }
$validTypes = implode(',', array_column(FacilityType::cases(), 'value')); $validTypes = implode(',', array_column(FacilityType::cases(), 'value'));
...@@ -310,10 +320,14 @@ protected function validateFacilities(): void ...@@ -310,10 +320,14 @@ protected function validateFacilities(): void
'facilities.*.name_ar' => 'required|string|min:2|max:255', 'facilities.*.name_ar' => 'required|string|min:2|max:255',
'facilities.*.type' => "required|string|in:{$validTypes}", 'facilities.*.type' => "required|string|in:{$validTypes}",
'facilities.*.capacity' => 'nullable|integer|min:1|max:10000', 'facilities.*.capacity' => 'nullable|integer|min:1|max:10000',
'facilities.*.monthly_cost' => 'required|numeric|min:0',
'facilities.*.operating_start' => 'nullable|date_format:H:i',
'facilities.*.operating_end' => 'nullable|date_format:H:i',
'facilities.*.address' => 'nullable|string|max:500', 'facilities.*.address' => 'nullable|string|max:500',
], [ ], [
'facilities.*.name_ar.required' => __('اسم المنشأة بالعربية مطلوب'), 'facilities.*.name_ar.required' => __('اسم المنشأة بالعربية مطلوب'),
'facilities.*.type.required' => __('نوع المنشأة مطلوب'), 'facilities.*.type.required' => __('نوع المنشأة مطلوب'),
'facilities.*.monthly_cost.required' => __('الإيجار الشهري مطلوب (أدخل 0 إذا كانت ملك)'),
]); ]);
} }
...@@ -382,14 +396,16 @@ public function completeSetup(): void ...@@ -382,14 +396,16 @@ public function completeSetup(): void
]); ]);
} }
// Create programs // Create programs + base prices
foreach ($this->programs as $programData) { foreach ($this->programs as $programData) {
$activityIndex = (int) $programData['activity_index']; $activityIndex = (int) $programData['activity_index'];
$activity = $createdActivities[$activityIndex] ?? null; $activity = $createdActivities[$activityIndex] ?? null;
if ($activity) { if ($activity) {
$durationMonths = (int) $programData['duration_months']; $durationMonths = (int) $programData['duration_months'];
TrainingProgram::create([ $monthlyFeePiasters = (int) round((float) $programData['monthly_fee'] * 100);
$program = TrainingProgram::create([
'academy_id' => $academyId, 'academy_id' => $academyId,
'activity_id' => $activity->id, 'activity_id' => $activity->id,
'name_ar' => $programData['name_ar'], 'name_ar' => $programData['name_ar'],
...@@ -400,7 +416,23 @@ public function completeSetup(): void ...@@ -400,7 +416,23 @@ public function completeSetup(): void
'status' => 'active', 'status' => 'active',
'registration_open' => true, 'registration_open' => true,
'created_by' => $actor->id, 'created_by' => $actor->id,
'metadata' => ['monthly_fee_piasters' => (int) round((float) $programData['monthly_fee'] * 100)], 'metadata' => ['monthly_fee_piasters' => $monthlyFeePiasters],
]);
BasePrice::create([
'academy_id' => $academyId,
'priceable_type' => TrainingProgram::class,
'priceable_id' => $program->id,
'branch_id' => null,
'name_ar' => 'الاشتراك الشهري — ' . $programData['name_ar'],
'name' => 'Monthly — ' . $programData['name_ar'],
'amount' => $monthlyFeePiasters,
'currency' => 'EGP',
'effective_from' => now()->toDateString(),
'effective_to' => null,
'is_active' => true,
'priority' => 1,
'created_by' => $actor->id,
]); ]);
} }
} }
...@@ -414,8 +446,12 @@ public function completeSetup(): void ...@@ -414,8 +446,12 @@ public function completeSetup(): void
'name' => $facilityData['name_ar'], 'name' => $facilityData['name_ar'],
'type' => $facilityData['type'], 'type' => $facilityData['type'],
'capacity' => !empty($facilityData['capacity']) ? (int) $facilityData['capacity'] : null, 'capacity' => !empty($facilityData['capacity']) ? (int) $facilityData['capacity'] : null,
'monthly_rental_cost' => !empty($facilityData['monthly_cost']) ? (int) round((float) $facilityData['monthly_cost'] * 100) : 0,
'operating_start' => $facilityData['operating_start'] ?? null,
'operating_end' => $facilityData['operating_end'] ?? null,
'address' => $facilityData['address'] ?? null, 'address' => $facilityData['address'] ?? null,
'status' => 'active', 'status' => 'active',
'created_by' => $actor->id,
]); ]);
} }
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('facilities', function (Blueprint $table) {
$table->bigInteger('monthly_rental_cost')->nullable()->after('rental_cost_per_hour');
});
}
public function down(): void
{
Schema::table('facilities', function (Blueprint $table) {
$table->dropColumn('monthly_rental_cost');
});
}
};
...@@ -40,6 +40,7 @@ public function run(): void ...@@ -40,6 +40,7 @@ public function run(): void
$this->call(FinancialAccountsSeeder::class); $this->call(FinancialAccountsSeeder::class);
$this->call(RolesAndPermissionsSeeder::class); $this->call(RolesAndPermissionsSeeder::class);
$this->call(PermissionSeeder::class);
// Assign academy_owner role to the admin user // Assign academy_owner role to the admin user
$ownerRole = Role::where('academy_id', $academy->id) $ownerRole = Role::where('academy_id', $academy->id)
......
...@@ -190,6 +190,7 @@ private function getRolePermissions(array $allPermissions): array ...@@ -190,6 +190,7 @@ private function getRolePermissions(array $allPermissions): array
'branch_manager' => array_filter($allPermissions, fn ($p) => 'branch_manager' => array_filter($allPermissions, fn ($p) =>
str_starts_with($p, 'participants.') || str_starts_with($p, 'participants.') ||
str_starts_with($p, 'guardians.') || str_starts_with($p, 'guardians.') ||
str_starts_with($p, 'activities.') ||
str_starts_with($p, 'programs.') || str_starts_with($p, 'programs.') ||
str_starts_with($p, 'groups.') || str_starts_with($p, 'groups.') ||
str_starts_with($p, 'sessions.') || str_starts_with($p, 'sessions.') ||
...@@ -199,14 +200,22 @@ private function getRolePermissions(array $allPermissions): array ...@@ -199,14 +200,22 @@ private function getRolePermissions(array $allPermissions): array
str_starts_with($p, 'excuses.') || str_starts_with($p, 'excuses.') ||
str_starts_with($p, 'invoices.') || str_starts_with($p, 'invoices.') ||
str_starts_with($p, 'payments.') || str_starts_with($p, 'payments.') ||
str_starts_with($p, 'wallets.') ||
str_starts_with($p, 'cash_sessions.') || str_starts_with($p, 'cash_sessions.') ||
str_starts_with($p, 'pricing.') ||
str_starts_with($p, 'pos.') || str_starts_with($p, 'pos.') ||
str_starts_with($p, 'inventory.') ||
str_starts_with($p, 'products.') ||
str_starts_with($p, 'warehouses.') ||
str_starts_with($p, 'facilities.') || str_starts_with($p, 'facilities.') ||
str_starts_with($p, 'reservations.') || str_starts_with($p, 'reservations.') ||
str_starts_with($p, 'assignments.') || str_starts_with($p, 'assignments.') ||
str_starts_with($p, 'evaluations.') || str_starts_with($p, 'evaluations.') ||
str_starts_with($p, 'notifications.') ||
str_starts_with($p, 'reports.') || str_starts_with($p, 'reports.') ||
$p === 'dashboard.view' || $p === 'holidays.list' str_starts_with($p, 'audit.') ||
$p === 'dashboard.view' || $p === 'holidays.list' ||
$p === 'settings.view'
), ),
'head_trainer' => [ 'head_trainer' => [
'dashboard.view', 'dashboard.view',
...@@ -233,12 +242,12 @@ private function getRolePermissions(array $allPermissions): array ...@@ -233,12 +242,12 @@ private function getRolePermissions(array $allPermissions): array
'participants.list', 'participants.create', 'participants.update', 'participants.show', 'participants.list', 'participants.create', 'participants.update', 'participants.show',
'guardians.list', 'guardians.create', 'guardians.update', 'guardians.list', 'guardians.create', 'guardians.update',
'enrollments.list', 'enrollments.create', 'enrollments.list', 'enrollments.create',
'pos.access', 'pos.access', 'pos.sell', 'pos.list',
'invoices.list', 'invoices.show', 'invoices.create', 'invoices.list', 'invoices.show', 'invoices.create',
'payments.list', 'payments.create', 'payments.list', 'payments.create',
'cash_sessions.open', 'cash_sessions.close', 'cash_sessions.list', 'cash_sessions.open', 'cash_sessions.close', 'cash_sessions.list', 'cash_sessions.manage',
'pos_sessions.open', 'pos_sessions.close', 'pos_sessions.list', 'pos_sessions.open', 'pos_sessions.close', 'pos_sessions.list',
'wallets.list', 'wallets.credit', 'wallets.list', 'wallets.view', 'wallets.credit',
], ],
'accountant' => [ 'accountant' => [
'dashboard.view', 'dashboard.view',
...@@ -247,12 +256,13 @@ private function getRolePermissions(array $allPermissions): array ...@@ -247,12 +256,13 @@ private function getRolePermissions(array $allPermissions): array
'payments.list', 'payments.create', 'payments.refund', 'payments.export', 'payments.list', 'payments.create', 'payments.refund', 'payments.export',
'transactions.list', 'transactions.export', 'transactions.list', 'transactions.export',
'accounts.list', 'accounts.create', 'accounts.update', 'accounts.list', 'accounts.create', 'accounts.update',
'wallets.list', 'wallets.credit', 'wallets.debit', 'wallets.freeze', 'wallets.list', 'wallets.view', 'wallets.credit', 'wallets.debit', 'wallets.freeze',
'payment_plans.list', 'payment_plans.create', 'payment_plans.update', 'payment_plans.list', 'payment_plans.create', 'payment_plans.update',
'cash_sessions.open', 'cash_sessions.close', 'cash_sessions.list', 'cash_sessions.open', 'cash_sessions.close', 'cash_sessions.list', 'cash_sessions.manage',
'refunds.initiate', 'refunds.approve', 'refunds.initiate', 'refunds.approve',
'daily_closing.create', 'daily_closing.view', 'daily_closing.create', 'daily_closing.view',
'reports.financial', 'reports.export_pdf', 'reports.export_excel', 'reports.financial', 'reports.view', 'reports.export_pdf', 'reports.export_excel',
'pricing.list',
], ],
'data_entry' => [ 'data_entry' => [
'dashboard.view', 'dashboard.view',
......
...@@ -22,6 +22,7 @@ ...@@ -22,6 +22,7 @@
]], ]],
['section' => 'المالية', 'items' => [ ['section' => 'المالية', 'items' => [
['label' => 'النظرة المالية', 'route' => 'financial.overview', 'icon' => 'chart-bar', 'permission' => 'invoices.list'],
['label' => 'الفواتير', 'route' => 'invoices.list', 'icon' => 'document', 'permission' => 'invoices.list'], ['label' => 'الفواتير', 'route' => 'invoices.list', 'icon' => 'document', 'permission' => 'invoices.list'],
['label' => 'المحافظ', 'route' => 'wallets.list', 'icon' => 'wallet', 'permission' => 'wallets.list'], ['label' => 'المحافظ', 'route' => 'wallets.list', 'icon' => 'wallet', 'permission' => 'wallets.list'],
['label' => 'جلسات الكاشير', 'route' => 'cash-sessions.list', 'icon' => 'calculator', 'permission' => 'cash_sessions.list'], ['label' => 'جلسات الكاشير', 'route' => 'cash-sessions.list', 'icon' => 'calculator', 'permission' => 'cash_sessions.list'],
......
...@@ -138,13 +138,22 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin ...@@ -138,13 +138,22 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
@error('width_m') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror @error('width_m') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div> </div>
{{-- Rental Cost --}} {{-- Rental Cost per Hour --}}
<div> <div>
<label for="rental_cost_display" class="block text-sm font-medium text-gray-700 mb-1">{{ __('تكلفة الإيجار/ساعة (ج.م)') }}</label> <label for="rental_cost_display" class="block text-sm font-medium text-gray-700 mb-1">{{ __('تكلفة الإيجار/ساعة (ج.م)') }}</label>
<input type="number" id="rental_cost_display" wire:model="rental_cost_display" min="0" step="0.01" dir="ltr" <input type="number" id="rental_cost_display" wire:model="rental_cost_display" min="0" step="0.01" dir="ltr"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('rental_cost_display') border-red-500 @enderror"> class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('rental_cost_display') border-red-500 @enderror">
@error('rental_cost_display') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror @error('rental_cost_display') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div> </div>
{{-- Monthly Rental Cost --}}
<div>
<label for="monthly_rental_cost_display" class="block text-sm font-medium text-gray-700 mb-1">{{ __('الإيجار الشهري (ج.م)') }}</label>
<input type="number" id="monthly_rental_cost_display" wire:model="monthly_rental_cost_display" min="0" step="0.01" dir="ltr"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('monthly_rental_cost_display') border-red-500 @enderror"
placeholder="{{ __('المبلغ الذي تدفعه شهرياً لاستئجار هذه المنشأة') }}">
@error('monthly_rental_cost_display') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
</div> </div>
{{-- Boolean Options --}} {{-- Boolean Options --}}
......
<div>
{{-- Header --}}
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">{{ __('النظرة المالية العامة') }}</h1>
<p class="text-sm text-gray-500 mt-1">{{ __('ملخص الإيرادات والمصروفات والأرباح') }} — {{ $periodLabel }}</p>
</div>
<div class="flex items-center gap-3">
<select wire:model.live="branch_id" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">{{ __('كل الفروع') }}</option>
@foreach($branches as $branch)
<option value="{{ $branch->id }}">{{ $branch->name_ar }}</option>
@endforeach
</select>
<select wire:model.live="period" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="today">{{ __('اليوم') }}</option>
<option value="this_week">{{ __('هذا الأسبوع') }}</option>
<option value="this_month">{{ __('هذا الشهر') }}</option>
<option value="last_month">{{ __('الشهر الماضي') }}</option>
<option value="this_quarter">{{ __('هذا الربع') }}</option>
<option value="this_year">{{ __('هذا العام') }}</option>
</select>
</div>
</div>
{{-- Loading overlay --}}
<div wire:loading.class="opacity-50 pointer-events-none" class="transition-opacity">
{{-- Row 1: Summary Cards --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{{-- Total Revenue --}}
<div class="bg-white rounded-xl shadow-sm border border-emerald-200 p-5">
<div class="flex items-center gap-3">
<div class="w-11 h-11 bg-emerald-50 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-5 h-5 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
</svg>
</div>
<div class="min-w-0">
<p class="text-xs text-gray-500">{{ __('الإيرادات') }}</p>
<p class="text-xl font-bold text-emerald-700 truncate" dir="ltr">{{ number_format($revenue['total'] / 100, 2) }} <span class="text-sm font-normal">{{ __('ج.م') }}</span></p>
</div>
</div>
<p class="text-xs text-gray-400 mt-2" dir="ltr">{{ number_format($revenue['transaction_count']) }} {{ __('عملية') }}</p>
</div>
{{-- Total Expenses --}}
<div class="bg-white rounded-xl shadow-sm border border-red-200 p-5">
<div class="flex items-center gap-3">
<div class="w-11 h-11 bg-red-50 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"/>
</svg>
</div>
<div class="min-w-0">
<p class="text-xs text-gray-500">{{ __('المصروفات') }}</p>
<p class="text-xl font-bold text-red-700 truncate" dir="ltr">{{ number_format($expenses['total'] / 100, 2) }} <span class="text-sm font-normal">{{ __('ج.م') }}</span></p>
</div>
</div>
<p class="text-xs text-gray-400 mt-2">{{ __('إيجارات + مشتريات + مدفوعات') }}</p>
</div>
{{-- Net Profit/Loss --}}
@php $netProfit = $revenue['total'] - $expenses['total']; @endphp
<div class="bg-white rounded-xl shadow-sm border {{ $netProfit >= 0 ? 'border-blue-200' : 'border-orange-200' }} p-5">
<div class="flex items-center gap-3">
<div class="w-11 h-11 {{ $netProfit >= 0 ? 'bg-blue-50' : 'bg-orange-50' }} rounded-lg flex items-center justify-center shrink-0">
<svg class="w-5 h-5 {{ $netProfit >= 0 ? 'text-blue-600' : 'text-orange-600' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
</div>
<div class="min-w-0">
<p class="text-xs text-gray-500">{{ __('صافي الربح/الخسارة') }}</p>
<p class="text-xl font-bold {{ $netProfit >= 0 ? 'text-blue-700' : 'text-orange-700' }} truncate" dir="ltr">
{{ $netProfit >= 0 ? '+' : '' }}{{ number_format($netProfit / 100, 2) }} <span class="text-sm font-normal">{{ __('ج.م') }}</span>
</p>
</div>
</div>
</div>
{{-- Collection Rate --}}
<div class="bg-white rounded-xl shadow-sm border border-purple-200 p-5">
<div class="flex items-center gap-3">
<div class="w-11 h-11 bg-purple-50 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
</div>
<div class="min-w-0">
<p class="text-xs text-gray-500">{{ __('نسبة التحصيل') }}</p>
<p class="text-xl font-bold text-purple-700" dir="ltr">{{ $collectionRate['rate'] }}%</p>
</div>
</div>
@if($collectionRate['overdue_count'] > 0)
<p class="text-xs text-red-500 mt-2">
{{ $collectionRate['overdue_count'] }} {{ __('فاتورة متأخرة') }} ({{ number_format($collectionRate['overdue_amount'] / 100, 0) }} {{ __('ج.م') }})
</p>
@endif
</div>
</div>
{{-- Row 2: Break-Even + Expense Breakdown --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
{{-- Break-Even Gauge --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-base font-semibold text-gray-800 mb-4">{{ __('نقطة التعادل') }}</h3>
@if($breakEven['has_data'])
<div class="relative">
{{-- Progress ring visual --}}
<div class="flex items-center justify-center mb-4">
<div class="relative w-40 h-40">
<svg class="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="42" stroke="#e5e7eb" stroke-width="10" fill="none"/>
<circle cx="50" cy="50" r="42"
stroke="{{ $breakEven['is_profitable'] ? '#10b981' : '#f59e0b' }}"
stroke-width="10" fill="none"
stroke-dasharray="{{ $breakEven['percentage'] * 2.64 }} 264"
stroke-linecap="round"/>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="text-3xl font-bold {{ $breakEven['is_profitable'] ? 'text-emerald-600' : 'text-amber-600' }}" dir="ltr">{{ $breakEven['percentage'] }}%</span>
<span class="text-xs text-gray-500">{{ $breakEven['is_profitable'] ? __('مربح') : __('نحو التعادل') }}</span>
</div>
</div>
</div>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">{{ __('التكاليف الثابتة الشهرية') }}</span>
<span class="font-medium" dir="ltr">{{ number_format($breakEven['fixed_costs'] / 100, 0) }} {{ __('ج.م') }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">{{ __('الإيرادات الحالية') }}</span>
<span class="font-medium text-emerald-600" dir="ltr">{{ number_format($breakEven['current_revenue'] / 100, 0) }} {{ __('ج.م') }}</span>
</div>
@if(!$breakEven['is_profitable'])
<div class="flex justify-between pt-2 border-t border-gray-100">
<span class="text-gray-600 font-medium">{{ __('المتبقي للتعادل') }}</span>
<span class="font-bold text-amber-600" dir="ltr">{{ number_format($breakEven['remaining'] / 100, 0) }} {{ __('ج.م') }}</span>
</div>
@else
<div class="flex justify-between pt-2 border-t border-gray-100">
<span class="text-gray-600 font-medium">{{ __('فائض فوق التعادل') }}</span>
<span class="font-bold text-emerald-600" dir="ltr">+{{ number_format(($breakEven['current_revenue'] - $breakEven['fixed_costs']) / 100, 0) }} {{ __('ج.م') }}</span>
</div>
@endif
</div>
</div>
@else
<div class="text-center py-8">
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="text-gray-500 text-sm">{{ $breakEven['message'] }}</p>
<a href="{{ route('facilities.list') }}" wire:navigate class="inline-block mt-3 text-sm text-blue-600 hover:text-blue-700 font-medium">
{{ __('أضف تكاليف المنشآت') }} &larr;
</a>
</div>
@endif
</div>
{{-- Expense Breakdown --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-base font-semibold text-gray-800 mb-4">{{ __('تفاصيل المصروفات') }}</h3>
@if($expenses['total'] > 0)
<div class="space-y-4">
{{-- Facility Costs --}}
@php $pct = $expenses['total'] > 0 ? round(($expenses['facility_costs'] / $expenses['total']) * 100) : 0; @endphp
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-600">{{ __('إيجارات المنشآت') }}</span>
<span class="font-medium" dir="ltr">{{ number_format($expenses['facility_costs'] / 100, 0) }} {{ __('ج.م') }} <span class="text-gray-400">({{ $pct }}%)</span></span>
</div>
<div class="w-full bg-gray-100 rounded-full h-2.5">
<div class="bg-amber-500 h-2.5 rounded-full" style="width: {{ $pct }}%"></div>
</div>
</div>
{{-- Purchase Orders --}}
@php $pct = $expenses['total'] > 0 ? round(($expenses['purchase_orders'] / $expenses['total']) * 100) : 0; @endphp
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-600">{{ __('مشتريات المخزون') }}</span>
<span class="font-medium" dir="ltr">{{ number_format($expenses['purchase_orders'] / 100, 0) }} {{ __('ج.م') }} <span class="text-gray-400">({{ $pct }}%)</span></span>
</div>
<div class="w-full bg-gray-100 rounded-full h-2.5">
<div class="bg-blue-500 h-2.5 rounded-full" style="width: {{ $pct }}%"></div>
</div>
</div>
{{-- Outbound Payments --}}
@php $pct = $expenses['total'] > 0 ? round(($expenses['outbound_payments'] / $expenses['total']) * 100) : 0; @endphp
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-600">{{ __('مدفوعات أخرى') }}</span>
<span class="font-medium" dir="ltr">{{ number_format($expenses['outbound_payments'] / 100, 0) }} {{ __('ج.م') }} <span class="text-gray-400">({{ $pct }}%)</span></span>
</div>
<div class="w-full bg-gray-100 rounded-full h-2.5">
<div class="bg-purple-500 h-2.5 rounded-full" style="width: {{ $pct }}%"></div>
</div>
</div>
{{-- Total Bar --}}
<div class="pt-3 border-t border-gray-100">
<div class="flex justify-between text-sm">
<span class="font-semibold text-gray-700">{{ __('الإجمالي') }}</span>
<span class="font-bold text-red-600" dir="ltr">{{ number_format($expenses['total'] / 100, 0) }} {{ __('ج.م') }}</span>
</div>
</div>
</div>
@else
<div class="text-center py-8">
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="text-gray-500 text-sm">{{ __('لا توجد مصروفات مسجلة في هذه الفترة') }}</p>
</div>
@endif
</div>
</div>
{{-- Row 3: Monthly P&L Chart --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h3 class="text-base font-semibold text-gray-800 mb-4">{{ __('الأرباح والخسائر — آخر ٦ أشهر') }}</h3>
<div class="overflow-x-auto">
<div class="min-w-[600px]">
{{-- Chart header --}}
<div class="flex items-center gap-4 mb-4 text-xs">
<span class="flex items-center gap-1"><span class="w-3 h-3 bg-emerald-500 rounded-sm"></span> {{ __('الإيرادات') }}</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 bg-red-400 rounded-sm"></span> {{ __('المصروفات') }}</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 bg-blue-500 rounded-sm"></span> {{ __('صافي الربح') }}</span>
</div>
{{-- Bar chart --}}
@php
$maxVal = max(1, collect($monthlyPL)->max('income'), collect($monthlyPL)->max('expenses'));
@endphp
<div class="grid grid-cols-6 gap-3">
@foreach($monthlyPL as $month)
<div class="text-center">
{{-- Bars --}}
<div class="relative h-40 flex items-end justify-center gap-1 mb-2">
<div class="w-5 bg-emerald-400 rounded-t transition-all" style="height: {{ ($month['income'] / $maxVal) * 100 }}%" title="{{ number_format($month['income'] / 100, 0) }} {{ __('ج.م') }}"></div>
<div class="w-5 bg-red-300 rounded-t transition-all" style="height: {{ ($month['expenses'] / $maxVal) * 100 }}%" title="{{ number_format($month['expenses'] / 100, 0) }} {{ __('ج.م') }}"></div>
</div>
{{-- Label --}}
<p class="text-xs text-gray-500 truncate">{{ $month['label'] }}</p>
{{-- Profit/Loss --}}
<p class="text-xs font-bold mt-1 {{ $month['profit'] >= 0 ? 'text-emerald-600' : 'text-red-600' }}" dir="ltr">
{{ $month['profit'] >= 0 ? '+' : '' }}{{ number_format($month['profit'] / 100, 0) }}
</p>
</div>
@endforeach
</div>
</div>
</div>
</div>
{{-- Row 4: Facility Costs Table + Revenue Sources --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
{{-- Facility Costs Detail --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-800">{{ __('تكاليف المنشآت') }}</h3>
<span class="text-sm font-medium text-gray-500" dir="ltr">{{ number_format($facilityCosts['total_monthly'] / 100, 0) }} {{ __('ج.م/شهر') }}</span>
</div>
@if(count($facilityCosts['facilities']) > 0)
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100">
<th class="pb-2 text-start text-xs font-medium text-gray-500">{{ __('المنشأة') }}</th>
<th class="pb-2 text-start text-xs font-medium text-gray-500">{{ __('الفرع') }}</th>
<th class="pb-2 text-end text-xs font-medium text-gray-500">{{ __('شهري') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
@foreach($facilityCosts['facilities'] as $f)
<tr>
<td class="py-2 text-gray-700">{{ $f['name'] }}</td>
<td class="py-2 text-gray-500">{{ $f['branch'] }}</td>
<td class="py-2 text-end font-medium" dir="ltr">{{ number_format($f['monthly_cost'] / 100, 0) }} {{ __('ج.م') }}</td>
</tr>
@endforeach
</tbody>
<tfoot>
<tr class="border-t border-gray-200">
<td colspan="2" class="pt-3 font-semibold text-gray-700">{{ __('الإجمالي') }}</td>
<td class="pt-3 text-end font-bold text-red-600" dir="ltr">{{ number_format($facilityCosts['total_monthly'] / 100, 0) }} {{ __('ج.م') }}</td>
</tr>
<tr>
<td colspan="2" class="pt-1 text-xs text-gray-400">{{ __('سنوياً') }}</td>
<td class="pt-1 text-end text-xs text-gray-500" dir="ltr">{{ number_format($facilityCosts['total_yearly'] / 100, 0) }} {{ __('ج.م') }}</td>
</tr>
</tfoot>
</table>
</div>
@else
<div class="text-center py-6">
<p class="text-sm text-gray-500">{{ __('لم يتم تحديد تكاليف شهرية لأي منشأة') }}</p>
<a href="{{ route('facilities.list') }}" wire:navigate class="inline-block mt-2 text-sm text-blue-600 hover:text-blue-700 font-medium">{{ __('تعديل المنشآت') }}</a>
</div>
@endif
</div>
{{-- Revenue by Source --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-base font-semibold text-gray-800 mb-4">{{ __('مصادر الإيرادات') }}</h3>
@if(count($revenueBySource) > 0)
<div class="space-y-3">
@php $maxSource = max(1, collect($revenueBySource)->max('total')); @endphp
@foreach($revenueBySource as $source)
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-600">{{ $source['source'] }}</span>
<span class="font-medium" dir="ltr">{{ number_format($source['total'] / 100, 0) }} {{ __('ج.م') }}</span>
</div>
<div class="w-full bg-gray-100 rounded-full h-2">
<div class="bg-emerald-500 h-2 rounded-full" style="width: {{ ($source['total'] / $maxSource) * 100 }}%"></div>
</div>
</div>
@endforeach
</div>
@else
{{-- Fallback: show payment method breakdown --}}
@if(count($revenue['by_method']) > 0)
<p class="text-xs text-gray-400 mb-3">{{ __('تفصيل حسب طريقة الدفع') }}</p>
<div class="space-y-3">
@php
$methodLabels = ['cash' => 'نقدي', 'card' => 'بطاقة', 'bank_transfer' => 'تحويل بنكي', 'wallet' => 'محفظة', 'online' => 'أونلاين', 'cheque' => 'شيك', 'other' => 'أخرى'];
$maxMethod = max(1, collect($revenue['by_method'])->max('total'));
@endphp
@foreach($revenue['by_method'] as $pm)
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-600">{{ $methodLabels[$pm['method']] ?? $pm['method'] }}</span>
<span class="font-medium" dir="ltr">{{ number_format($pm['total'] / 100, 0) }} {{ __('ج.م') }} <span class="text-gray-400">({{ $pm['count'] }})</span></span>
</div>
<div class="w-full bg-gray-100 rounded-full h-2">
<div class="bg-blue-500 h-2 rounded-full" style="width: {{ ($pm['total'] / $maxMethod) * 100 }}%"></div>
</div>
</div>
@endforeach
</div>
@else
<div class="text-center py-6">
<p class="text-sm text-gray-500">{{ __('لا توجد إيرادات مسجلة في هذه الفترة') }}</p>
</div>
@endif
@endif
</div>
</div>
{{-- Row 5: Collection Rate Progress --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-base font-semibold text-gray-800 mb-4">{{ __('معدل تحصيل الفواتير') }}</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="text-center">
<p class="text-sm text-gray-500 mb-1">{{ __('إجمالي الفوترة') }}</p>
<p class="text-2xl font-bold text-gray-800" dir="ltr">{{ number_format($collectionRate['invoiced'] / 100, 0) }} <span class="text-sm font-normal text-gray-500">{{ __('ج.م') }}</span></p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 mb-1">{{ __('تم تحصيله') }}</p>
<p class="text-2xl font-bold text-emerald-600" dir="ltr">{{ number_format($collectionRate['collected'] / 100, 0) }} <span class="text-sm font-normal text-gray-500">{{ __('ج.م') }}</span></p>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 mb-1">{{ __('نسبة التحصيل') }}</p>
<p class="text-2xl font-bold {{ $collectionRate['rate'] >= 80 ? 'text-emerald-600' : ($collectionRate['rate'] >= 50 ? 'text-amber-600' : 'text-red-600') }}" dir="ltr">{{ $collectionRate['rate'] }}%</p>
</div>
</div>
{{-- Progress bar --}}
<div class="mt-4 w-full bg-gray-100 rounded-full h-3">
<div class="h-3 rounded-full transition-all {{ $collectionRate['rate'] >= 80 ? 'bg-emerald-500' : ($collectionRate['rate'] >= 50 ? 'bg-amber-500' : 'bg-red-500') }}" style="width: {{ $collectionRate['rate'] }}%"></div>
</div>
</div>
</div>{{-- end loading overlay --}}
</div>
...@@ -189,9 +189,13 @@ class="mt-4 w-full border-2 border-dashed border-gray-300 rounded-xl p-4 text-gr ...@@ -189,9 +189,13 @@ class="mt-4 w-full border-2 border-dashed border-gray-300 rounded-xl p-4 text-gr
<div class="p-8"> <div class="p-8">
<div class="mb-6"> <div class="mb-6">
<h2 class="text-xl font-bold text-gray-800">{{ __('الأنشطة') }}</h2> <h2 class="text-xl font-bold text-gray-800">{{ __('الأنشطة') }}</h2>
<p class="text-sm text-gray-500 mt-1">{{ __('أضف الأنشطة الرياضية أو التعليمية التي تقدمها الأكاديمية. يمكنك تخطي هذه الخطوة وإضافتها لاحقاً.') }}</p> <p class="text-sm text-gray-500 mt-1">{{ __('أضف الأنشطة الرياضية أو التعليمية التي تقدمها الأكاديمية (مطلوب نشاط واحد على الأقل).') }}</p>
</div> </div>
@error('activities')
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{{ $message }}</div>
@enderror
<div class="space-y-4"> <div class="space-y-4">
@foreach($activities as $index => $activity) @foreach($activities as $index => $activity)
<div class="border border-gray-200 rounded-xl p-5 relative group hover:border-blue-200 transition-colors" <div class="border border-gray-200 rounded-xl p-5 relative group hover:border-blue-200 transition-colors"
...@@ -274,9 +278,13 @@ class="mt-4 w-full border-2 border-dashed border-gray-300 rounded-xl p-4 text-gr ...@@ -274,9 +278,13 @@ class="mt-4 w-full border-2 border-dashed border-gray-300 rounded-xl p-4 text-gr
<div class="p-8"> <div class="p-8">
<div class="mb-6"> <div class="mb-6">
<h2 class="text-xl font-bold text-gray-800">{{ __('البرامج التدريبية') }}</h2> <h2 class="text-xl font-bold text-gray-800">{{ __('البرامج التدريبية') }}</h2>
<p class="text-sm text-gray-500 mt-1">{{ __('أضف البرامج المرتبطة بالأنشطة التي أضفتها. يمكنك تخطي هذه الخطوة.') }}</p> <p class="text-sm text-gray-500 mt-1">{{ __('أضف البرامج التدريبية مع أسعارها. مطلوب برنامج واحد على الأقل مع تحديد الرسوم الشهرية.') }}</p>
</div> </div>
@error('programs')
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{{ $message }}</div>
@enderror
@if(empty($activities)) @if(empty($activities))
<div class="text-center py-8 border-2 border-dashed border-gray-200 rounded-xl"> <div class="text-center py-8 border-2 border-dashed border-gray-200 rounded-xl">
<svg class="w-12 h-12 text-amber-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-12 h-12 text-amber-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
...@@ -382,9 +390,16 @@ class="mt-4 w-full border-2 border-dashed border-gray-300 rounded-xl p-4 text-gr ...@@ -382,9 +390,16 @@ class="mt-4 w-full border-2 border-dashed border-gray-300 rounded-xl p-4 text-gr
<div class="p-8"> <div class="p-8">
<div class="mb-6"> <div class="mb-6">
<h2 class="text-xl font-bold text-gray-800">{{ __('المنشآت') }}</h2> <h2 class="text-xl font-bold text-gray-800">{{ __('المنشآت') }}</h2>
<p class="text-sm text-gray-500 mt-1">{{ __('أضف الملاعب والمنشآت الرياضية الخاصة بالأكاديمية. يمكنك تخطي هذه الخطوة.') }}</p> <p class="text-sm text-gray-500 mt-1">{{ __('أضف الملاعب والمنشآت الرياضية. حدد الإيجار الشهري وساعات العمل لحساب نقطة التعادل.') }}</p>
<div class="mt-2 p-3 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-700">
<strong>{{ __('مهم:') }}</strong> {{ __('الإيجار الشهري يستخدم في حساب الأرباح والخسائر. إذا كانت المنشأة ملكك أدخل 0.') }}
</div>
</div> </div>
@error('facilities')
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{{ $message }}</div>
@enderror
<div class="space-y-4"> <div class="space-y-4">
@foreach($facilities as $index => $facility) @foreach($facilities as $index => $facility)
<div class="border border-gray-200 rounded-xl p-5 relative group hover:border-blue-200 transition-colors" <div class="border border-gray-200 rounded-xl p-5 relative group hover:border-blue-200 transition-colors"
...@@ -396,7 +411,7 @@ class="absolute top-3 start-3 w-7 h-7 rounded-full bg-red-50 text-red-500 hover: ...@@ -396,7 +411,7 @@ class="absolute top-3 start-3 w-7 h-7 rounded-full bg-red-50 text-red-500 hover:
</svg> </svg>
</button> </button>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1"> <label class="block text-sm font-medium text-gray-700 mb-1">
{{ __('اسم المنشأة') }} <span class="text-red-500">*</span> {{ __('اسم المنشأة') }} <span class="text-red-500">*</span>
...@@ -422,6 +437,17 @@ class="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:r ...@@ -422,6 +437,17 @@ class="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:r
<p class="mt-1 text-xs text-red-600">{{ $message }}</p> <p class="mt-1 text-xs text-red-600">{{ $message }}</p>
@enderror @enderror
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ __('الإيجار الشهري (ج.م)') }} <span class="text-red-500">*</span>
</label>
<input type="number" wire:model="facilities.{{ $index }}.monthly_cost" dir="ltr"
class="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
min="0" step="0.01" placeholder="5000.00">
@error("facilities.{$index}.monthly_cost")
<p class="mt-1 text-xs text-red-600">{{ $message }}</p>
@enderror
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1"> <label class="block text-sm font-medium text-gray-700 mb-1">
{{ __('السعة') }} {{ __('السعة') }}
...@@ -430,6 +456,18 @@ class="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:r ...@@ -430,6 +456,18 @@ class="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:r
class="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" class="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
min="1" placeholder="50"> min="1" placeholder="50">
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ __('ساعات العمل') }}
</label>
<div class="flex items-center gap-2">
<input type="time" wire:model="facilities.{{ $index }}.operating_start" dir="ltr"
class="flex-1 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm">
<span class="text-gray-400"></span>
<input type="time" wire:model="facilities.{{ $index }}.operating_end" dir="ltr"
class="flex-1 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm">
</div>
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1"> <label class="block text-sm font-medium text-gray-700 mb-1">
{{ __('العنوان') }} {{ __('العنوان') }}
...@@ -448,7 +486,8 @@ class="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:r ...@@ -448,7 +486,8 @@ class="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:r
<svg class="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg> </svg>
<p class="text-gray-400 text-sm">{{ __('لم تضف منشآت بعد. أضف منشأة أو تخطَّ هذه الخطوة.') }}</p> <p class="text-red-500 text-sm font-medium">{{ __('يجب إضافة منشأة واحدة على الأقل') }}</p>
<p class="text-gray-400 text-xs mt-1">{{ __('أضف الملعب أو القاعة التي تعمل فيها') }}</p>
</div> </div>
@endif @endif
......
...@@ -19,6 +19,7 @@ ...@@ -19,6 +19,7 @@
use App\Livewire\Evaluations\EvaluationShow; use App\Livewire\Evaluations\EvaluationShow;
use App\Livewire\Facilities\FacilityForm; use App\Livewire\Facilities\FacilityForm;
use App\Livewire\Facilities\FacilityList; use App\Livewire\Facilities\FacilityList;
use App\Livewire\Financial\FinancialOverview;
use App\Livewire\Auth\Login; use App\Livewire\Auth\Login;
use App\Livewire\CashSessions\CashSessionList; use App\Livewire\CashSessions\CashSessionList;
use App\Livewire\CashSessions\CashSessionManage; use App\Livewire\CashSessions\CashSessionManage;
...@@ -178,6 +179,10 @@ ...@@ -178,6 +179,10 @@
Route::get('/cash-sessions/manage', CashSessionManage::class)->name('cash-sessions.manage') Route::get('/cash-sessions/manage', CashSessionManage::class)->name('cash-sessions.manage')
->middleware('permission:cash_sessions.manage'); ->middleware('permission:cash_sessions.manage');
// Financial Overview
Route::get('/financial-overview', FinancialOverview::class)->name('financial.overview')
->middleware('permission:invoices.list');
// Invoices // Invoices
Route::get('/invoices', InvoiceList::class)->name('invoices.list') Route::get('/invoices', InvoiceList::class)->name('invoices.list')
->middleware('permission:invoices.list'); ->middleware('permission:invoices.list');
......
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