Commit 0c8f5bdb authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add document management system + 10 feature wizards

Document system: polymorphic documents table, upload/approve/reject workflow,
medical certificate tracking with expiry, system settings toggles, daily expire job.

Feature wizards: Employee, Trainer, Program, Group, Facility, Invoice, Product,
PricingRule, TransferParticipant, StockAdjustment — all with Arabic UI, step-by-step
guided creation, and delegation to existing services.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent aeca9efa
<?php
namespace App\Console\Commands;
use App\Domain\Document\Services\DocumentService;
use Illuminate\Console\Command;
class ExpireDocuments extends Command
{
protected $signature = 'documents:expire';
protected $description = 'Mark approved documents past their expiry date as expired';
public function handle(DocumentService $service): int
{
$count = $service->checkAndExpireDocuments();
$this->info("Expired {$count} document(s).");
return self::SUCCESS;
}
}
<?php
namespace App\Domain\Document;
final class DocumentSettings
{
public const REQUIRE_DOCUMENTS_UPLOAD = 'require_documents_upload';
public const REQUIRE_MEDICAL_CERTIFICATE = 'require_medical_certificate';
public const MEDICAL_CERTIFICATE_EXPIRY_REQUIRED = 'medical_certificate_expiry_required';
public const MEDICAL_CERTIFICATE_MAX_AGE_MONTHS = 'medical_certificate_max_age_months';
public const BLOCK_ATTENDANCE_WITHOUT_MEDICAL = 'block_attendance_without_medical';
public const ALLOWED_DOCUMENT_TYPES = 'allowed_document_types';
public const MAX_DOCUMENT_SIZE_MB = 'max_document_size_mb';
public const GROUP = 'enrollment';
public const DEFAULTS = [
self::REQUIRE_DOCUMENTS_UPLOAD => '0',
self::REQUIRE_MEDICAL_CERTIFICATE => '0',
self::MEDICAL_CERTIFICATE_EXPIRY_REQUIRED => '1',
self::MEDICAL_CERTIFICATE_MAX_AGE_MONTHS => '12',
self::BLOCK_ATTENDANCE_WITHOUT_MEDICAL => '0',
self::ALLOWED_DOCUMENT_TYPES => '["birth_certificate","national_id","medical_certificate","passport","photo","contract","qualification","insurance","other"]',
self::MAX_DOCUMENT_SIZE_MB => '10',
];
}
<?php
namespace App\Domain\Document\Enums;
enum DocumentStatus: string
{
case Pending = 'pending';
case Approved = 'approved';
case Rejected = 'rejected';
case Expired = 'expired';
public function label(): string
{
return match ($this) {
self::Pending => 'في الانتظار',
self::Approved => 'معتمد',
self::Rejected => 'مرفوض',
self::Expired => 'منتهي الصلاحية',
};
}
public function badgeColor(): string
{
return match ($this) {
self::Pending => 'yellow',
self::Approved => 'green',
self::Rejected => 'red',
self::Expired => 'gray',
};
}
}
<?php
namespace App\Domain\Document\Enums;
enum DocumentType: string
{
case BirthCertificate = 'birth_certificate';
case NationalId = 'national_id';
case MedicalCertificate = 'medical_certificate';
case Passport = 'passport';
case Photo = 'photo';
case Contract = 'contract';
case Qualification = 'qualification';
case Insurance = 'insurance';
case Other = 'other';
public function label(): string
{
return match ($this) {
self::BirthCertificate => 'شهادة الميلاد',
self::NationalId => 'بطاقة الرقم القومي',
self::MedicalCertificate => 'الشهادة الطبية',
self::Passport => 'جواز السفر',
self::Photo => 'صورة شخصية',
self::Contract => 'عقد',
self::Qualification => 'مؤهل / شهادة',
self::Insurance => 'تأمين',
self::Other => 'أخرى',
};
}
public function requiresExpiry(): bool
{
return match ($this) {
self::MedicalCertificate, self::Insurance => true,
default => false,
};
}
public function requiresApproval(): bool
{
return match ($this) {
self::MedicalCertificate => true,
default => false,
};
}
}
<?php
namespace App\Domain\Document\Models;
use App\Domain\Document\Enums\DocumentStatus;
use App\Domain\Document\Enums\DocumentType;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Document extends Model
{
use BelongsToAcademy, HasUuid, SoftDeletes;
protected $fillable = [
'academy_id',
'documentable_type',
'documentable_id',
'document_type',
'file_path',
'original_filename',
'mime_type',
'file_size',
'status',
'expires_at',
'approved_by',
'approved_at',
'rejection_reason',
'notes',
'uploaded_by',
'created_by',
];
protected function casts(): array
{
return [
'document_type' => DocumentType::class,
'status' => DocumentStatus::class,
'file_size' => 'integer',
'expires_at' => 'date',
'approved_at' => 'datetime',
];
}
public function documentable(): MorphTo
{
return $this->morphTo();
}
public function approver(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by');
}
public function uploader(): BelongsTo
{
return $this->belongsTo(User::class, 'uploaded_by');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function scopePending(Builder $query): Builder
{
return $query->where('status', DocumentStatus::Pending);
}
public function scopeApproved(Builder $query): Builder
{
return $query->where('status', DocumentStatus::Approved);
}
public function scopeExpired(Builder $query): Builder
{
return $query->where('status', DocumentStatus::Expired);
}
public function scopeMedicalCertificates(Builder $query): Builder
{
return $query->where('document_type', DocumentType::MedicalCertificate);
}
public function scopeOfType(Builder $query, DocumentType $type): Builder
{
return $query->where('document_type', $type);
}
public function isExpired(): bool
{
return $this->expires_at && $this->expires_at->isPast();
}
public function isPending(): bool
{
return $this->status === DocumentStatus::Pending;
}
public function isApproved(): bool
{
return $this->status === DocumentStatus::Approved;
}
}
<?php
namespace App\Domain\Document\Services;
use App\Domain\Document\DocumentSettings;
use App\Domain\Document\Enums\DocumentStatus;
use App\Domain\Document\Enums\DocumentType;
use App\Domain\Document\Models\Document;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Models\SystemSetting;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class DocumentService
{
public function upload(
Model $documentable,
UploadedFile $file,
DocumentType $type,
?string $notes,
User $actor
): Document {
return DB::transaction(function () use ($documentable, $file, $type, $notes, $actor) {
$uuid = (string) Str::uuid();
$extension = $file->getClientOriginalExtension();
$path = $this->buildStoragePath($documentable, $uuid, $extension);
Storage::disk('local')->put($path, file_get_contents($file->getRealPath()));
$status = $type->requiresApproval()
? DocumentStatus::Pending
: DocumentStatus::Approved;
return Document::create([
'academy_id' => app('current_academy')->id,
'documentable_type' => get_class($documentable),
'documentable_id' => $documentable->id,
'document_type' => $type->value,
'file_path' => $path,
'original_filename' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'file_size' => $file->getSize(),
'status' => $status->value,
'notes' => $notes,
'uploaded_by' => $actor->id,
'created_by' => $actor->id,
]);
});
}
public function approve(Document $document, ?string $expiresAt, User $approver): Document
{
return DB::transaction(function () use ($document, $expiresAt, $approver) {
$document->update([
'status' => DocumentStatus::Approved->value,
'expires_at' => $expiresAt,
'approved_by' => $approver->id,
'approved_at' => now(),
'rejection_reason' => null,
]);
return $document->fresh();
});
}
public function reject(Document $document, string $reason, User $actor): Document
{
return DB::transaction(function () use ($document, $reason, $actor) {
$document->update([
'status' => DocumentStatus::Rejected->value,
'rejection_reason' => $reason,
'approved_by' => $actor->id,
'approved_at' => now(),
]);
return $document->fresh();
});
}
public function markExpired(Document $document): Document
{
$document->update(['status' => DocumentStatus::Expired->value]);
return $document->fresh();
}
public function checkAndExpireDocuments(): int
{
return Document::where('status', DocumentStatus::Approved)
->whereNotNull('expires_at')
->where('expires_at', '<', now()->toDateString())
->update(['status' => DocumentStatus::Expired->value]);
}
public function participantHasValidMedical(Participant $participant): bool
{
return Document::where('documentable_type', Participant::class)
->where('documentable_id', $participant->id)
->where('document_type', DocumentType::MedicalCertificate)
->where('status', DocumentStatus::Approved)
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>=', now()->toDateString());
})
->exists();
}
public function getMissingRequiredDocuments(Participant $participant): array
{
$missing = [];
if (SystemSetting::get(DocumentSettings::REQUIRE_MEDICAL_CERTIFICATE, false)) {
if (!$this->participantHasValidMedical($participant)) {
$missing[] = DocumentType::MedicalCertificate;
}
}
return $missing;
}
public function getMaxFileSizeBytes(): int
{
$mb = (int) SystemSetting::get(DocumentSettings::MAX_DOCUMENT_SIZE_MB, 10);
return $mb * 1024 * 1024;
}
public function getAllowedTypes(): array
{
$json = SystemSetting::get(DocumentSettings::ALLOWED_DOCUMENT_TYPES, DocumentSettings::DEFAULTS[DocumentSettings::ALLOWED_DOCUMENT_TYPES]);
if (is_string($json)) {
$json = json_decode($json, true) ?? [];
}
return array_filter(
DocumentType::cases(),
fn (DocumentType $type) => in_array($type->value, $json)
);
}
private function buildStoragePath(Model $documentable, string $uuid, string $extension): string
{
$academyId = app('current_academy')->id;
$type = class_basename($documentable);
$entityId = $documentable->id;
return "documents/{$academyId}/{$type}/{$entityId}/{$uuid}.{$extension}";
}
}
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
class Employee extends Model class Employee extends Model
...@@ -94,6 +95,11 @@ public function updater(): BelongsTo ...@@ -94,6 +95,11 @@ public function updater(): BelongsTo
return $this->belongsTo(\App\Models\User::class, 'updated_by'); return $this->belongsTo(\App\Models\User::class, 'updated_by');
} }
public function documents(): MorphMany
{
return $this->morphMany(\App\Domain\Document\Models\Document::class, 'documentable');
}
// --- Scopes --- // --- Scopes ---
public function scopeActive(Builder $query): Builder public function scopeActive(Builder $query): Builder
......
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Trainer extends Model class Trainer extends Model
{ {
...@@ -81,6 +82,11 @@ public function assignments() ...@@ -81,6 +82,11 @@ public function assignments()
); );
} }
public function documents(): MorphMany
{
return $this->morphMany(\App\Domain\Document\Models\Document::class, 'documentable');
}
// --- Scopes --- // --- Scopes ---
public function scopeActive(Builder $query): Builder public function scopeActive(Builder $query): Builder
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Person extends Model class Person extends Model
{ {
...@@ -51,6 +52,11 @@ public function participant(): HasOne ...@@ -51,6 +52,11 @@ public function participant(): HasOne
return $this->hasOne(\App\Domain\Participant\Models\Participant::class); return $this->hasOne(\App\Domain\Participant\Models\Participant::class);
} }
public function documents(): MorphMany
{
return $this->morphMany(\App\Domain\Document\Models\Document::class, 'documentable');
}
public function getAgeAttribute(): ?int public function getAgeAttribute(): ?int
{ {
return $this->date_of_birth?->age; return $this->date_of_birth?->age;
......
...@@ -157,6 +157,11 @@ public function attendanceRecords(): MorphMany ...@@ -157,6 +157,11 @@ public function attendanceRecords(): MorphMany
return $this->morphMany(\App\Domain\Attendance\Models\AttendanceRecord::class, 'subject'); return $this->morphMany(\App\Domain\Attendance\Models\AttendanceRecord::class, 'subject');
} }
public function documents(): MorphMany
{
return $this->morphMany(\App\Domain\Document\Models\Document::class, 'documentable');
}
public function posTransactions(): HasMany public function posTransactions(): HasMany
{ {
return $this->hasMany(\App\Domain\POS\Models\POSTransaction::class); return $this->hasMany(\App\Domain\POS\Models\POSTransaction::class);
......
<?php
namespace App\Http\Controllers;
use App\Domain\Document\Models\Document;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DocumentController extends Controller
{
public function show(Document $document): StreamedResponse
{
$this->authorize('documents.view');
$this->ensureSameAcademy($document);
return $this->streamFile($document, 'inline');
}
public function download(Document $document): StreamedResponse
{
$this->authorize('documents.view');
$this->ensureSameAcademy($document);
return $this->streamFile($document, 'attachment');
}
public function preview(Document $document): StreamedResponse
{
$this->authorize('documents.view');
$this->ensureSameAcademy($document);
return $this->streamFile($document, 'inline');
}
private function streamFile(Document $document, string $disposition): StreamedResponse
{
$disk = Storage::disk('local');
abort_unless($disk->exists($document->file_path), 404);
return $disk->download(
$document->file_path,
$document->original_filename,
[
'Content-Type' => $document->mime_type,
'Content-Disposition' => "{$disposition}; filename=\"{$document->original_filename}\"",
]
);
}
private function ensureSameAcademy(Document $document): void
{
abort_unless(
$document->academy_id === app('current_academy')?->id,
403
);
}
}
...@@ -85,6 +85,12 @@ class SystemSettings extends Component ...@@ -85,6 +85,12 @@ class SystemSettings extends Component
'freeze_max_days' => ['type' => 'number', 'label' => 'أقصى مدة تجميد (أيام)', 'step' => '1', 'min' => 1], 'freeze_max_days' => ['type' => 'number', 'label' => 'أقصى مدة تجميد (أيام)', 'step' => '1', 'min' => 1],
'freeze_max_times' => ['type' => 'number', 'label' => 'أقصى عدد مرات التجميد', 'step' => '1', 'min' => 1], 'freeze_max_times' => ['type' => 'number', 'label' => 'أقصى عدد مرات التجميد', 'step' => '1', 'min' => 1],
'enrollment_expiry_days' => ['type' => 'number', 'label' => 'مدة صلاحية الاشتراك (أيام)', 'step' => '1', 'min' => 1], 'enrollment_expiry_days' => ['type' => 'number', 'label' => 'مدة صلاحية الاشتراك (أيام)', 'step' => '1', 'min' => 1],
'require_documents_upload' => ['type' => 'boolean', 'label' => 'تفعيل نظام رفع المستندات'],
'require_medical_certificate' => ['type' => 'boolean', 'label' => 'إلزام رفع شهادة طبية سارية'],
'medical_certificate_expiry_required' => ['type' => 'boolean', 'label' => 'إلزام تاريخ انتهاء الشهادة عند الموافقة'],
'medical_certificate_max_age_months' => ['type' => 'number', 'label' => 'أقصى مدة صلاحية الشهادة الطبية (شهور)', 'step' => '1', 'min' => 1, 'max' => 60],
'block_attendance_without_medical' => ['type' => 'boolean', 'label' => 'منع الحضور بدون شهادة طبية سارية'],
'max_document_size_mb' => ['type' => 'number', 'label' => 'الحد الأقصى لحجم الملف (ميجابايت)', 'step' => '1', 'min' => 1, 'max' => 50],
], ],
]; ];
...@@ -152,6 +158,13 @@ class SystemSettings extends Component ...@@ -152,6 +158,13 @@ class SystemSettings extends Component
'freeze_max_days' => '30', 'freeze_max_days' => '30',
'freeze_max_times' => '2', 'freeze_max_times' => '2',
'enrollment_expiry_days' => '30', 'enrollment_expiry_days' => '30',
// documents
'require_documents_upload' => '0',
'require_medical_certificate' => '0',
'medical_certificate_expiry_required' => '1',
'medical_certificate_max_age_months' => '12',
'block_attendance_without_medical' => '0',
'max_document_size_mb' => '10',
]; ];
public function mount(): void public function mount(): void
......
<?php
namespace App\Livewire\Documents;
use App\Domain\Document\DocumentSettings;
use App\Domain\Document\Enums\DocumentStatus;
use App\Domain\Document\Models\Document;
use App\Domain\Document\Services\DocumentService;
use App\Domain\Shared\Models\SystemSetting;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('components.layouts.app')]
#[Title('مراجعة مستند')]
class DocumentApproval extends Component
{
public Document $document;
public string $expiresAt = '';
public string $rejectionReason = '';
public function mount(Document $document): void
{
$this->authorize('documents.approve');
$this->document = $document;
}
public function approve(DocumentService $service): void
{
$expiryRequired = $this->document->document_type->requiresExpiry()
&& SystemSetting::get(DocumentSettings::MEDICAL_CERTIFICATE_EXPIRY_REQUIRED, true);
$rules = [];
if ($expiryRequired) {
$rules['expiresAt'] = 'required|date|after:today';
} else {
$rules['expiresAt'] = 'nullable|date|after:today';
}
$this->validate($rules);
$service->approve(
$this->document,
$this->expiresAt ?: null,
auth()->user()
);
session()->flash('success', __('تم اعتماد المستند بنجاح'));
$this->redirectRoute('documents.approvals', navigate: true);
}
public function reject(DocumentService $service): void
{
$this->validate([
'rejectionReason' => 'required|string|min:5|max:500',
]);
$service->reject(
$this->document,
$this->rejectionReason,
auth()->user()
);
session()->flash('success', __('تم رفض المستند'));
$this->redirectRoute('documents.approvals', navigate: true);
}
public function render()
{
return view('livewire.documents.document-approval', [
'expiryRequired' => $this->document->document_type->requiresExpiry()
&& SystemSetting::get(DocumentSettings::MEDICAL_CERTIFICATE_EXPIRY_REQUIRED, true),
]);
}
}
<?php
namespace App\Livewire\Documents;
use App\Domain\Document\Enums\DocumentStatus;
use App\Domain\Document\Enums\DocumentType;
use App\Domain\Document\Models\Document;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('components.layouts.app')]
#[Title('اعتماد المستندات')]
class DocumentApprovalList extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $status = 'pending';
#[Url]
public string $documentType = '';
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedStatus(): void
{
$this->resetPage();
}
public function updatedDocumentType(): void
{
$this->resetPage();
}
public function render()
{
$this->authorize('documents.approve');
$query = Document::with(['documentable', 'uploader'])
->latest();
if ($this->status) {
$query->where('status', $this->status);
}
if ($this->documentType) {
$query->where('document_type', $this->documentType);
}
if ($this->search) {
$search = $this->search;
$query->where(function ($q) use ($search) {
$q->where('original_filename', 'ilike', "%{$search}%")
->orWhereHasMorph('documentable', '*', function ($mq) use ($search) {
$mq->where('name_ar', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%");
});
});
}
return view('livewire.documents.document-approval-list', [
'documents' => $query->paginate(20),
'statuses' => DocumentStatus::cases(),
'documentTypes' => DocumentType::cases(),
]);
}
}
<?php
namespace App\Livewire\Documents;
use App\Domain\Document\DocumentSettings;
use App\Domain\Document\Enums\DocumentType;
use App\Domain\Document\Services\DocumentService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Models\SystemSetting;
use Illuminate\Database\Eloquent\Model;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithFileUploads;
#[Layout('components.layouts.app')]
#[Title('رفع مستند')]
class DocumentUploadWizard extends Component
{
use WithFileUploads;
public int $currentStep = 1;
public int $totalSteps = 4;
public bool $completed = false;
public string $documentableType = '';
public int $documentableId = 0;
public string $documentableLabel = '';
public string $documentType = '';
public $file = null;
public string $notes = '';
public ?array $createdDocument = null;
public function mount(string $documentableType, int $documentableId): void
{
$this->authorize('documents.upload');
$this->documentableType = $documentableType;
$this->documentableId = $documentableId;
$model = $this->resolveDocumentable();
abort_unless($model, 404);
$this->documentableLabel = $model->name_ar ?? $model->name ?? "#{$model->id}";
}
public function nextStep(): void
{
$this->validate($this->rulesForStep($this->currentStep));
$this->currentStep = min($this->currentStep + 1, $this->totalSteps);
}
public function previousStep(): void
{
$this->currentStep = max($this->currentStep - 1, 1);
}
public function goToStep(int $step): void
{
if ($step < $this->currentStep) {
$this->currentStep = $step;
}
}
public function confirm(DocumentService $service): void
{
try {
$model = $this->resolveDocumentable();
abort_unless($model, 404);
$type = DocumentType::from($this->documentType);
$document = $service->upload(
$model,
$this->file,
$type,
$this->notes ?: null,
auth()->user()
);
$this->createdDocument = [
'uuid' => $document->uuid,
'type_label' => $type->label(),
'status_label' => $document->status->label(),
'original_filename' => $document->original_filename,
];
$this->completed = true;
$this->currentStep = $this->totalSteps;
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function render()
{
return view('livewire.documents.document-upload-wizard', [
'allowedTypes' => app(DocumentService::class)->getAllowedTypes(),
'maxSizeMb' => (int) SystemSetting::get(DocumentSettings::MAX_DOCUMENT_SIZE_MB, 10),
'stepLabels' => $this->getStepLabels(),
]);
}
private function rulesForStep(int $step): array
{
$maxSize = (int) SystemSetting::get(DocumentSettings::MAX_DOCUMENT_SIZE_MB, 10);
return match ($step) {
1 => [
'documentType' => 'required|in:' . implode(',', array_column(DocumentType::cases(), 'value')),
],
2 => [
'file' => "required|file|max:" . ($maxSize * 1024),
],
3 => [
'notes' => 'nullable|string|max:1000',
],
default => [],
};
}
private function getStepLabels(): array
{
return [
1 => 'نوع المستند',
2 => 'رفع الملف',
3 => 'ملاحظات',
4 => 'تأكيد',
];
}
private function resolveDocumentable(): ?Model
{
$map = [
'participant' => \App\Domain\Participant\Models\Participant::class,
'person' => \App\Domain\Identity\Models\Person::class,
'employee' => \App\Domain\HR\Models\Employee::class,
'trainer' => \App\Domain\HR\Models\Trainer::class,
];
$class = $map[$this->documentableType] ?? null;
if (!$class) {
return null;
}
return $class::find($this->documentableId);
}
}
<?php
namespace App\Livewire\Documents;
use App\Domain\Document\DocumentSettings;
use App\Domain\Document\Services\DocumentService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Models\SystemSetting;
use Livewire\Component;
class MedicalCertificateAlert extends Component
{
public Participant $participant;
public bool $show = false;
public bool $isBlocking = false;
public bool $hasValid = false;
public function mount(Participant $participant): void
{
$this->participant = $participant;
$requireMedical = SystemSetting::get(DocumentSettings::REQUIRE_MEDICAL_CERTIFICATE, false);
if (!$requireMedical) {
return;
}
$service = app(DocumentService::class);
$this->hasValid = $service->participantHasValidMedical($participant);
$this->isBlocking = (bool) SystemSetting::get(DocumentSettings::BLOCK_ATTENDANCE_WITHOUT_MEDICAL, false);
$this->show = !$this->hasValid;
}
public function render()
{
return view('livewire.documents.medical-certificate-alert');
}
}
<?php
namespace App\Livewire\Documents;
use App\Domain\Document\Enums\DocumentType;
use App\Domain\Document\Models\Document;
use App\Domain\Document\Services\DocumentService;
use App\Domain\Participant\Models\Participant;
use Livewire\Component;
class ParticipantDocuments extends Component
{
public Participant $participant;
public function mount(Participant $participant): void
{
$this->participant = $participant;
}
public function delete(string $uuid, DocumentService $service): void
{
$this->authorize('documents.delete');
$document = Document::where('uuid', $uuid)->firstOrFail();
$document->delete();
session()->flash('success', __('تم حذف المستند'));
}
public function render()
{
$documents = Document::where('documentable_type', Participant::class)
->where('documentable_id', $this->participant->id)
->with('uploader')
->latest()
->get();
return view('livewire.documents.participant-documents', [
'documents' => $documents,
'documentTypes' => DocumentType::cases(),
'hasValidMedical' => app(DocumentService::class)->participantHasValidMedical($this->participant),
]);
}
}
<?php
namespace App\Livewire\Enrollments;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Services\EnrollmentService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('نقل مشترك')]
class TransferParticipantWizard extends Component
{
public int $currentStep = 1;
public int $totalSteps = 4;
public bool $completed = false;
// Step 1: Select Participant & Enrollment
public string $participantSearch = '';
public ?int $participantId = null;
public ?int $enrollmentId = null;
// Step 2: Destination
public ?int $destinationGroupId = null;
// Step 3: Impact & Reason
public string $reason = '';
// Cached data for display
public ?array $selectedParticipant = null;
public array $activeEnrollments = [];
public ?array $selectedEnrollment = null;
public ?array $sourceGroup = null;
public ?array $destinationGroup = null;
public function mount(): void
{
$this->authorize('enrollments.transfer');
}
public function getStepLabels(): array
{
return [
1 => 'اختيار المشترك',
2 => 'الوجهة',
3 => 'التأثير',
4 => 'تأكيد النقل',
];
}
public function selectParticipant(int $id): void
{
$participant = Participant::with('person')->findOrFail($id);
$this->participantId = $id;
$this->selectedParticipant = [
'id' => $participant->id,
'name' => $participant->person?->name_ar ?? $participant->person?->name ?? '-',
'participant_number' => $participant->participant_number,
];
$this->loadActiveEnrollments();
}
public function updatedParticipantId(): void
{
if ($this->participantId) {
$this->loadActiveEnrollments();
} else {
$this->activeEnrollments = [];
}
$this->enrollmentId = null;
$this->selectedEnrollment = null;
$this->sourceGroup = null;
}
private function loadActiveEnrollments(): void
{
$enrollments = Enrollment::where('participant_id', $this->participantId)
->where('status', 'active')
->with(['group', 'program'])
->get();
$this->activeEnrollments = $enrollments->map(fn ($e) => [
'id' => $e->id,
'group_name' => $e->group?->name_ar ?? '-',
'program_name' => $e->program?->name_ar ?? '-',
'enrollment_date' => $e->enrollment_date?->format('Y-m-d'),
'group_id' => $e->training_group_id,
'program_id' => $e->training_program_id,
])->toArray();
}
public function selectEnrollment(int $id): void
{
$enrollment = Enrollment::with(['group', 'program'])->findOrFail($id);
$this->enrollmentId = $id;
$this->selectedEnrollment = [
'id' => $enrollment->id,
'group_name' => $enrollment->group?->name_ar ?? '-',
'program_name' => $enrollment->program?->name_ar ?? '-',
'group_id' => $enrollment->training_group_id,
'program_id' => $enrollment->training_program_id,
];
$this->sourceGroup = [
'id' => $enrollment->group?->id,
'name_ar' => $enrollment->group?->name_ar,
'current_count' => $enrollment->group?->current_count,
'max_capacity' => $enrollment->group?->max_capacity,
];
}
public function selectDestinationGroup(int $id): void
{
$group = TrainingGroup::findOrFail($id);
$this->destinationGroupId = $id;
$this->destinationGroup = [
'id' => $group->id,
'name_ar' => $group->name_ar,
'current_count' => $group->current_count,
'max_capacity' => $group->max_capacity,
];
}
public function nextStep(): void
{
$this->validate($this->rulesForStep($this->currentStep));
if ($this->currentStep < $this->totalSteps) {
$this->currentStep++;
}
}
public function previousStep(): void
{
if ($this->currentStep > 1) {
$this->currentStep--;
}
}
public function goToStep(int $step): void
{
if ($step < $this->currentStep) {
$this->currentStep = $step;
}
}
public function confirm(): void
{
try {
$enrollment = Enrollment::findOrFail($this->enrollmentId);
$toGroup = TrainingGroup::findOrFail($this->destinationGroupId);
app(EnrollmentService::class)->transfer($enrollment, $toGroup, auth()->user());
$this->completed = true;
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
private function rulesForStep(int $step): array
{
return match ($step) {
1 => [
'participantId' => 'required|integer|exists:participants,id',
'enrollmentId' => 'required|integer|exists:enrollments,id',
],
2 => [
'destinationGroupId' => 'required|integer|exists:training_groups,id',
],
3 => [
'reason' => 'required|string|min:5|max:500',
],
default => [],
};
}
public function render()
{
$searchResults = collect();
if (strlen($this->participantSearch) >= 2) {
$searchResults = Participant::with('person')
->whereHas('person', function ($q) {
$q->where('name_ar', 'ilike', "%{$this->participantSearch}%")
->orWhere('name', 'ilike', "%{$this->participantSearch}%")
->orWhere('phone', 'ilike', "%{$this->participantSearch}%")
->orWhere('national_id', 'ilike', "%{$this->participantSearch}%");
})
->orWhere('participant_number', 'ilike', "%{$this->participantSearch}%")
->where('status', 'active')
->limit(10)
->get();
}
$availableGroups = collect();
if ($this->selectedEnrollment && $this->currentStep >= 2) {
$availableGroups = TrainingGroup::where('training_program_id', $this->selectedEnrollment['program_id'])
->where('id', '!=', $this->selectedEnrollment['group_id'])
->whereIn('status', ['forming', 'active'])
->orderBy('name_ar')
->get();
}
return view('livewire.enrollments.transfer-participant-wizard', [
'searchResults' => $searchResults,
'availableGroups' => $availableGroups,
]);
}
}
<?php
namespace App\Livewire\Facilities;
use App\Domain\Facility\Services\FacilityService;
use App\Domain\Identity\Models\Branch;
use App\Domain\Shared\Exceptions\DomainException;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('إنشاء منشأة جديدة')]
class CreateFacilityWizard extends Component
{
public int $currentStep = 1;
public int $totalSteps = 4;
public bool $completed = false;
// Step 1: Basic Info
public string $nameAr = '';
public string $name = '';
public string $type = '';
public ?int $branchId = null;
public ?int $capacity = null;
public string $descriptionAr = '';
// Step 2: Specs
public string $surfaceType = '';
public ?string $areaSqm = null;
public ?string $lengthM = null;
public ?string $widthM = null;
public bool $isIndoor = false;
public bool $hasLighting = false;
public bool $hasAc = false;
// Step 3: Operating Hours
public string $operatingStart = '';
public string $operatingEnd = '';
public string $rentalCostPerHour = '';
public string $address = '';
// Data
public array $branches = [];
public function mount(): void
{
$this->authorize('facilities.create');
$this->branches = Branch::orderBy('name_ar')->get(['id', 'name_ar'])->toArray();
}
public function getStepLabels(): array
{
return [
1 => 'المعلومات الأساسية',
2 => 'المواصفات',
3 => 'ساعات التشغيل',
4 => 'مراجعة',
];
}
public function rulesForStep(): array
{
return match ($this->currentStep) {
1 => [
'nameAr' => 'required|string|max:255',
'name' => 'nullable|string|max:255',
'type' => 'required|in:field,court,pool,gym,track,hall,room,outdoor,other',
'branchId' => 'required|exists:branches,id',
'capacity' => 'nullable|integer|min:1',
'descriptionAr' => 'nullable|string|max:1000',
],
2 => [
'surfaceType' => 'nullable|string|max:100',
'areaSqm' => 'nullable|numeric|min:0',
'lengthM' => 'nullable|numeric|min:0',
'widthM' => 'nullable|numeric|min:0',
'isIndoor' => 'boolean',
'hasLighting' => 'boolean',
'hasAc' => 'boolean',
],
3 => [
'operatingStart' => 'nullable|date_format:H:i',
'operatingEnd' => 'nullable|date_format:H:i|after:operatingStart',
'rentalCostPerHour' => 'nullable|numeric|min:0',
'address' => 'nullable|string|max:500',
],
default => [],
};
}
public function messages(): array
{
return [
'nameAr.required' => 'اسم المنشأة بالعربية مطلوب',
'type.required' => 'نوع المنشأة مطلوب',
'type.in' => 'نوع المنشأة غير صالح',
'branchId.required' => 'الفرع مطلوب',
'branchId.exists' => 'الفرع المختار غير موجود',
'capacity.integer' => 'السعة يجب أن تكون رقمًا صحيحًا',
'capacity.min' => 'السعة يجب أن تكون 1 على الأقل',
'areaSqm.numeric' => 'المساحة يجب أن تكون رقمًا',
'lengthM.numeric' => 'الطول يجب أن يكون رقمًا',
'widthM.numeric' => 'العرض يجب أن يكون رقمًا',
'operatingEnd.after' => 'وقت الإغلاق يجب أن يكون بعد وقت الافتتاح',
'rentalCostPerHour.numeric' => 'تكلفة الإيجار يجب أن تكون رقمًا',
];
}
public function nextStep(): void
{
$this->validate($this->rulesForStep(), $this->messages());
$this->currentStep = min($this->currentStep + 1, $this->totalSteps);
}
public function previousStep(): void
{
$this->currentStep = max($this->currentStep - 1, 1);
}
public function goToStep(int $step): void
{
if ($step < $this->currentStep) {
$this->currentStep = $step;
}
}
public function confirm(): void
{
try {
$data = [
'name_ar' => $this->nameAr,
'name' => $this->name ?: null,
'type' => $this->type,
'branch_id' => $this->branchId,
'capacity' => $this->capacity,
'description_ar' => $this->descriptionAr ?: null,
'surface_type' => $this->surfaceType ?: null,
'area_sqm' => $this->areaSqm ? (float) $this->areaSqm : null,
'length_m' => $this->lengthM ? (float) $this->lengthM : null,
'width_m' => $this->widthM ? (float) $this->widthM : null,
'is_indoor' => $this->isIndoor,
'has_lighting' => $this->hasLighting,
'has_ac' => $this->hasAc,
'operating_start' => $this->operatingStart ?: null,
'operating_end' => $this->operatingEnd ?: null,
'rental_cost_per_hour' => $this->rentalCostPerHour ? (int) round((float) $this->rentalCostPerHour * 100) : null,
'address' => $this->address ?: null,
'status' => 'active',
];
app(FacilityService::class)->create($data, auth()->user());
$this->completed = true;
session()->flash('success', 'تم إنشاء المنشأة بنجاح');
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function render()
{
return view('livewire.facilities.create-facility-wizard');
}
}
<?php
namespace App\Livewire\Groups;
use App\Domain\HR\Models\Trainer;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Activity;
use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Training\Services\TrainingGroupService;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('إنشاء مجموعة تدريبية')]
class CreateGroupWizard extends Component
{
public int $currentStep = 1;
public int $totalSteps = 4;
public bool $completed = false;
// Step 1: Program Selection
public ?int $programId = null;
public ?int $activityFilter = null;
// Step 2: Group Info
public string $nameAr = '';
public string $name = '';
public string $code = '';
public string $season = '';
public string $startDate = '';
public string $endDate = '';
public string $notes = '';
// Step 3: Capacity & Trainer
public string $maxCapacity = '';
public ?int $headTrainerId = null;
public function mount(): void
{
$this->authorize('groups.create');
$this->startDate = now()->toDateString();
}
public function getStepLabels(): array
{
return [
1 => 'البرنامج',
2 => 'بيانات المجموعة',
3 => 'السعة والمدرب',
4 => 'مراجعة',
];
}
public function updatedActivityFilter(): void
{
$this->programId = null;
}
public function rulesForStep(int $step): array
{
return match ($step) {
1 => [
'programId' => 'required|exists:training_programs,id',
],
2 => [
'nameAr' => 'required|string|max:255',
'code' => 'required|string|max:30',
'startDate' => 'required|date',
'endDate' => 'nullable|date|after:startDate',
],
3 => [
'maxCapacity' => 'required|integer|min:1|max:500',
],
default => [],
};
}
public function messagesForStep(int $step): array
{
return match ($step) {
1 => [
'programId.required' => 'يجب اختيار البرنامج التدريبي',
'programId.exists' => 'البرنامج المختار غير موجود',
],
2 => [
'nameAr.required' => 'اسم المجموعة بالعربي مطلوب',
'code.required' => 'كود المجموعة مطلوب',
'code.max' => 'كود المجموعة لا يتجاوز 30 حرف',
'startDate.required' => 'تاريخ البدء مطلوب',
'startDate.date' => 'تاريخ البدء غير صالح',
'endDate.after' => 'تاريخ الانتهاء يجب أن يكون بعد تاريخ البدء',
],
3 => [
'maxCapacity.required' => 'السعة القصوى مطلوبة',
'maxCapacity.integer' => 'السعة القصوى يجب أن تكون رقم صحيح',
'maxCapacity.min' => 'السعة القصوى يجب أن تكون 1 على الأقل',
],
default => [],
};
}
public function nextStep(): void
{
$this->validate(
$this->rulesForStep($this->currentStep),
$this->messagesForStep($this->currentStep)
);
if ($this->currentStep < $this->totalSteps) {
$this->currentStep++;
}
}
public function previousStep(): void
{
if ($this->currentStep > 1) {
$this->currentStep--;
}
}
public function goToStep(int $step): void
{
if ($step < $this->currentStep) {
$this->currentStep = $step;
}
}
public function confirm(): void
{
try {
DB::transaction(function () {
$program = TrainingProgram::findOrFail($this->programId);
$data = [
'academy_id' => app('current_academy')->id,
'training_program_id' => $this->programId,
'branch_id' => $program->branch_id,
'name_ar' => $this->nameAr,
'name' => $this->name ?: null,
'code' => $this->code,
'season' => $this->season ?: null,
'start_date' => $this->startDate,
'end_date' => $this->endDate ?: null,
'notes' => $this->notes ?: null,
'max_capacity' => (int) $this->maxCapacity,
'head_trainer_id' => $this->headTrainerId,
];
app(TrainingGroupService::class)->create($data, auth()->user());
});
$this->completed = true;
session()->flash('success', 'تم إنشاء المجموعة التدريبية بنجاح');
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function render()
{
$programs = TrainingProgram::query()
->when($this->activityFilter, fn ($q) => $q->where('activity_id', $this->activityFilter))
->whereIn('status', ['draft', 'active'])
->orderBy('name_ar')
->get(['id', 'name_ar', 'name', 'activity_id', 'max_participants']);
$trainers = Trainer::with('employee.person')
->where('status', 'active')
->get();
return view('livewire.groups.create-group-wizard', [
'stepLabels' => $this->getStepLabels(),
'activities' => Activity::orderBy('name_ar')->get(['id', 'name_ar']),
'programs' => $programs,
'trainers' => $trainers,
'selectedProgram' => $this->programId ? TrainingProgram::with('activity', 'branch')->find($this->programId) : null,
]);
}
}
<?php
namespace App\Livewire\HR;
use App\Domain\HR\Enums\EmploymentType;
use App\Domain\HR\Enums\SalaryFrequency;
use App\Domain\HR\Services\EmployeeService;
use App\Domain\Identity\Models\Branch;
use App\Domain\Identity\Models\Person;
use App\Domain\Shared\Exceptions\DomainException;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('إضافة موظف جديد')]
class CreateEmployeeWizard extends Component
{
public int $currentStep = 1;
public int $totalSteps = 5;
public bool $completed = false;
// Step 1: Person
public ?int $personId = null;
public string $personSearch = '';
public bool $createNewPerson = false;
public string $personNameAr = '';
public string $personName = '';
public string $personPhone = '';
public string $personNationalId = '';
public string $personDateOfBirth = '';
public string $personGender = '';
// Step 2: Employment
public string $department = '';
public string $position = '';
public string $employmentType = '';
public string $startDate = '';
public string $endDate = '';
// Step 3: Branch & Manager
public ?int $branchId = null;
public ?int $managerId = null;
// Step 4: Salary
public string $salaryAmount = '';
public string $salaryFrequency = '';
public string $workingHoursPerWeek = '';
public function mount(): void
{
$this->authorize('employees.create');
$this->startDate = now()->toDateString();
}
public function getStepLabels(): array
{
return [
1 => 'بيانات الشخص',
2 => 'بيانات التوظيف',
3 => 'الفرع والمسؤول',
4 => 'الراتب',
5 => 'مراجعة',
];
}
public function getPersonResultsProperty(): \Illuminate\Support\Collection
{
if (strlen($this->personSearch) < 2) {
return collect();
}
return Person::where(function ($q) {
$q->where('name_ar', 'ilike', "%{$this->personSearch}%")
->orWhere('name', 'ilike', "%{$this->personSearch}%")
->orWhere('phone', 'like', "%{$this->personSearch}%")
->orWhere('national_id', 'like', "%{$this->personSearch}%");
})->limit(10)->get(['id', 'name', 'name_ar', 'phone', 'national_id']);
}
public function selectPerson(int $id): void
{
$person = Person::findOrFail($id);
$this->personId = $person->id;
$this->personNameAr = $person->name_ar ?? '';
$this->personName = $person->name ?? '';
$this->personPhone = $person->phone ?? '';
$this->personNationalId = $person->national_id ?? '';
$this->personDateOfBirth = $person->date_of_birth?->toDateString() ?? '';
$this->personGender = $person->gender ?? '';
$this->createNewPerson = false;
$this->personSearch = '';
}
public function toggleCreateNew(): void
{
$this->createNewPerson = !$this->createNewPerson;
if ($this->createNewPerson) {
$this->personId = null;
}
}
public function rulesForStep(int $step): array
{
return match ($step) {
1 => $this->createNewPerson ? [
'personNameAr' => 'required|string|max:255',
'personPhone' => 'required|string|max:20',
'personGender' => 'required|in:male,female',
] : [
'personId' => 'required|exists:people,id',
],
2 => [
'department' => 'required|string|max:100',
'position' => 'required|string|max:100',
'employmentType' => 'required|in:' . implode(',', array_column(EmploymentType::cases(), 'value')),
'startDate' => 'required|date',
'endDate' => 'nullable|date|after:startDate',
],
3 => [
'branchId' => 'required|exists:branches,id',
],
4 => [
'salaryAmount' => 'required|numeric|min:0',
'salaryFrequency' => 'required|in:' . implode(',', array_column(SalaryFrequency::cases(), 'value')),
'workingHoursPerWeek' => 'nullable|numeric|min:1|max:168',
],
default => [],
};
}
public function messagesForStep(int $step): array
{
return match ($step) {
1 => [
'personId.required' => 'يجب اختيار شخص من القائمة',
'personId.exists' => 'الشخص المختار غير موجود',
'personNameAr.required' => 'الاسم بالعربي مطلوب',
'personPhone.required' => 'رقم الهاتف مطلوب',
'personGender.required' => 'الجنس مطلوب',
'personGender.in' => 'قيمة الجنس غير صالحة',
],
2 => [
'department.required' => 'القسم مطلوب',
'position.required' => 'المسمى الوظيفي مطلوب',
'employmentType.required' => 'نوع التوظيف مطلوب',
'employmentType.in' => 'نوع التوظيف غير صالح',
'startDate.required' => 'تاريخ البدء مطلوب',
'startDate.date' => 'تاريخ البدء غير صالح',
'endDate.after' => 'تاريخ الانتهاء يجب أن يكون بعد تاريخ البدء',
],
3 => [
'branchId.required' => 'يجب اختيار الفرع',
'branchId.exists' => 'الفرع غير موجود',
],
4 => [
'salaryAmount.required' => 'مبلغ الراتب مطلوب',
'salaryAmount.numeric' => 'مبلغ الراتب يجب أن يكون رقم',
'salaryAmount.min' => 'مبلغ الراتب لا يمكن أن يكون سالب',
'salaryFrequency.required' => 'دورية الراتب مطلوبة',
'salaryFrequency.in' => 'دورية الراتب غير صالحة',
'workingHoursPerWeek.numeric' => 'ساعات العمل يجب أن تكون رقم',
'workingHoursPerWeek.min' => 'ساعات العمل يجب أن تكون أكثر من صفر',
'workingHoursPerWeek.max' => 'ساعات العمل لا يمكن أن تتجاوز 168 ساعة',
],
default => [],
};
}
public function nextStep(): void
{
$this->validate(
$this->rulesForStep($this->currentStep),
$this->messagesForStep($this->currentStep)
);
if ($this->currentStep < $this->totalSteps) {
$this->currentStep++;
}
}
public function previousStep(): void
{
if ($this->currentStep > 1) {
$this->currentStep--;
}
}
public function goToStep(int $step): void
{
if ($step < $this->currentStep) {
$this->currentStep = $step;
}
}
public function confirm(): void
{
try {
DB::transaction(function () {
$person = $this->resolveOrCreatePerson();
$data = [
'academy_id' => app('current_academy')->id,
'person_id' => $person->id,
'department' => $this->department,
'position' => $this->position,
'employment_type' => $this->employmentType,
'start_date' => $this->startDate,
'end_date' => $this->endDate ?: null,
'branch_id' => $this->branchId,
'manager_id' => $this->managerId,
'salary_amount' => (int) round((float) $this->salaryAmount * 100),
'salary_frequency' => $this->salaryFrequency,
'working_hours_per_week' => $this->workingHoursPerWeek ?: null,
];
app(EmployeeService::class)->create($data, auth()->user());
});
$this->completed = true;
session()->flash('success', 'تم إضافة الموظف بنجاح');
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
private function resolveOrCreatePerson(): Person
{
if ($this->personId) {
return Person::findOrFail($this->personId);
}
return Person::create([
'academy_id' => app('current_academy')->id,
'name_ar' => $this->personNameAr,
'name' => $this->personName ?: null,
'phone' => $this->personPhone,
'national_id' => $this->personNationalId ?: null,
'date_of_birth' => $this->personDateOfBirth ?: null,
'gender' => $this->personGender,
'created_by' => auth()->id(),
]);
}
public function render()
{
return view('livewire.hr.create-employee-wizard', [
'stepLabels' => $this->getStepLabels(),
'branches' => Branch::where('is_active', true)->orderBy('name_ar')->get(['id', 'name_ar']),
'employees' => $this->branchId
? \App\Domain\HR\Models\Employee::where('branch_id', $this->branchId)->with('person')->get()
: collect(),
'employmentTypes' => EmploymentType::cases(),
'salaryFrequencies' => SalaryFrequency::cases(),
]);
}
}
This diff is collapsed.
<?php
namespace App\Livewire\Inventory;
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\ProductCategory;
use App\Domain\Shared\Exceptions\DomainException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('إنشاء منتج جديد')]
class CreateProductWizard extends Component
{
public int $currentStep = 1;
public int $totalSteps = 4;
public bool $completed = false;
// Step 1: Basic Info
public string $nameAr = '';
public string $name = '';
public string $sku = '';
public string $barcode = '';
public ?int $categoryId = null;
public string $type = 'physical';
public string $descriptionAr = '';
// Step 2: Pricing
public string $sellingPrice = '';
public string $costPrice = '';
public string $taxRate = '0';
// Step 3: Inventory Settings
public bool $trackInventory = true;
public ?int $minStockLevel = null;
public ?int $maxStockLevel = null;
public bool $isActive = true;
// Data
public array $categories = [];
public function mount(): void
{
$this->authorize('products.create');
$this->categories = ProductCategory::active()
->orderBy('name_ar')
->get(['id', 'name_ar', 'name'])
->toArray();
}
public function getStepLabels(): array
{
return [
1 => 'المعلومات الأساسية',
2 => 'التسعير',
3 => 'إعدادات المخزون',
4 => 'مراجعة',
];
}
public function rulesForStep(): array
{
return match ($this->currentStep) {
1 => [
'nameAr' => 'required|string|max:255',
'name' => 'nullable|string|max:255',
'sku' => 'nullable|string|max:50',
'barcode' => 'nullable|string|max:50',
'categoryId' => 'nullable|exists:product_categories,id',
'type' => 'required|in:physical,digital,service',
'descriptionAr' => 'nullable|string|max:1000',
],
2 => [
'sellingPrice' => 'required|numeric|min:0.01',
'costPrice' => 'nullable|numeric|min:0',
'taxRate' => 'nullable|numeric|min:0|max:100',
],
3 => [
'trackInventory' => 'boolean',
'minStockLevel' => 'nullable|integer|min:0',
'maxStockLevel' => 'nullable|integer|min:0',
'isActive' => 'boolean',
],
default => [],
};
}
public function messages(): array
{
return [
'nameAr.required' => 'اسم المنتج بالعربية مطلوب',
'type.required' => 'نوع المنتج مطلوب',
'type.in' => 'نوع المنتج غير صالح',
'categoryId.exists' => 'التصنيف المختار غير موجود',
'sellingPrice.required' => 'سعر البيع مطلوب',
'sellingPrice.min' => 'سعر البيع يجب أن يكون أكبر من صفر',
'costPrice.numeric' => 'سعر التكلفة يجب أن يكون رقمًا',
'taxRate.numeric' => 'نسبة الضريبة يجب أن تكون رقمًا',
'taxRate.max' => 'نسبة الضريبة لا يمكن أن تتجاوز 100%',
'minStockLevel.integer' => 'الحد الأدنى يجب أن يكون رقمًا صحيحًا',
'maxStockLevel.integer' => 'الحد الأقصى يجب أن يكون رقمًا صحيحًا',
];
}
public function nextStep(): void
{
$this->validate($this->rulesForStep(), $this->messages());
$this->currentStep = min($this->currentStep + 1, $this->totalSteps);
}
public function previousStep(): void
{
$this->currentStep = max($this->currentStep - 1, 1);
}
public function goToStep(int $step): void
{
if ($step < $this->currentStep) {
$this->currentStep = $step;
}
}
public function confirm(): void
{
try {
Product::create([
'name_ar' => $this->nameAr,
'name' => $this->name ?: null,
'sku' => $this->sku ?: null,
'barcode' => $this->barcode ?: null,
'category_id' => $this->categoryId,
'type' => $this->type,
'description_ar' => $this->descriptionAr ?: null,
'selling_price' => (int) round((float) $this->sellingPrice * 100),
'cost_price' => $this->costPrice ? (int) round((float) $this->costPrice * 100) : null,
'tax_rate' => $this->taxRate ? (int) $this->taxRate : 0,
'track_inventory' => $this->trackInventory,
'min_stock_level' => $this->minStockLevel,
'max_stock_level' => $this->maxStockLevel,
'is_active' => $this->isActive,
'created_by' => auth()->id(),
]);
$this->completed = true;
session()->flash('success', 'تم إنشاء المنتج بنجاح');
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function render()
{
return view('livewire.inventory.create-product-wizard');
}
}
<?php
namespace App\Livewire\Inventory;
use App\Domain\Inventory\Enums\MovementType;
use App\Domain\Inventory\Models\InventoryLevel;
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\InventoryService;
use App\Domain\Shared\Exceptions\DomainException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('تسوية المخزون')]
class StockAdjustmentWizard extends Component
{
public int $currentStep = 1;
public int $totalSteps = 5;
public bool $completed = false;
// Step 1: Warehouse
public ?int $warehouseId = null;
// Step 2: Product
public ?int $productId = null;
// Step 3: Quantity & Reason
public string $adjustmentDirection = 'up';
public ?int $quantity = null;
public string $reason = '';
public ?string $referenceNumber = null;
// Cached display data
public ?array $selectedWarehouse = null;
public ?array $selectedProduct = null;
public ?int $currentStock = null;
public function mount(): void
{
$this->authorize('inventory.adjust');
}
public function getStepLabels(): array
{
return [
1 => 'المستودع',
2 => 'المنتج',
3 => 'الكمية والسبب',
4 => 'مراجعة',
5 => 'تأكيد',
];
}
public function selectWarehouse(int $id): void
{
$warehouse = Warehouse::findOrFail($id);
$this->warehouseId = $id;
$this->selectedWarehouse = [
'id' => $warehouse->id,
'name_ar' => $warehouse->name_ar,
'name' => $warehouse->name,
];
// Reset product selection when warehouse changes
$this->productId = null;
$this->selectedProduct = null;
$this->currentStock = null;
}
public function selectProduct(int $id): void
{
$product = Product::findOrFail($id);
$this->productId = $id;
$this->selectedProduct = [
'id' => $product->id,
'name_ar' => $product->name_ar,
'name' => $product->name,
'sku' => $product->sku,
];
$this->loadCurrentStock();
}
public function updatedWarehouseId(): void
{
if ($this->warehouseId && $this->productId) {
$this->loadCurrentStock();
}
}
public function updatedProductId(): void
{
if ($this->warehouseId && $this->productId) {
$this->loadCurrentStock();
}
}
private function loadCurrentStock(): void
{
if (!$this->warehouseId || !$this->productId) {
$this->currentStock = null;
return;
}
$level = InventoryLevel::where('product_id', $this->productId)
->where('warehouse_id', $this->warehouseId)
->first();
$this->currentStock = $level?->quantity_on_hand ?? 0;
}
public function getExpectedAfterProperty(): ?int
{
if ($this->currentStock === null || !$this->quantity) {
return null;
}
if ($this->adjustmentDirection === 'up') {
return $this->currentStock + $this->quantity;
}
return $this->currentStock - $this->quantity;
}
public function nextStep(): void
{
$this->validate($this->rulesForStep($this->currentStep));
if ($this->currentStep < $this->totalSteps) {
$this->currentStep++;
}
}
public function previousStep(): void
{
if ($this->currentStep > 1) {
$this->currentStep--;
}
}
public function goToStep(int $step): void
{
if ($step < $this->currentStep) {
$this->currentStep = $step;
}
}
public function confirm(): void
{
try {
$product = Product::findOrFail($this->productId);
$warehouse = Warehouse::findOrFail($this->warehouseId);
$movementType = $this->adjustmentDirection === 'up'
? MovementType::CountAdjustmentUp
: MovementType::CountAdjustmentDown;
app(InventoryService::class)->createMovement(
product: $product,
warehouse: $warehouse,
type: $movementType,
quantity: $this->quantity,
actor: auth()->user(),
reason: $this->reason,
);
$this->completed = true;
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
private function rulesForStep(int $step): array
{
return match ($step) {
1 => [
'warehouseId' => 'required|integer|exists:warehouses,id',
],
2 => [
'productId' => 'required|integer|exists:products,id',
],
3 => [
'adjustmentDirection' => 'required|in:up,down',
'quantity' => 'required|integer|min:1',
'reason' => 'required|string|min:5|max:500',
'referenceNumber' => 'nullable|string|max:100',
],
default => [],
};
}
public function render()
{
$warehouses = Warehouse::active()->orderBy('name_ar')->get(['id', 'name_ar', 'name', 'code']);
$products = collect();
if ($this->warehouseId) {
$products = Product::active()->tracked()->orderBy('name_ar')->get(['id', 'name_ar', 'name', 'sku']);
}
return view('livewire.inventory.stock-adjustment-wizard', [
'warehouses' => $warehouses,
'products' => $products,
]);
}
}
<?php
namespace App\Livewire\Invoices;
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('إنشاء فاتورة جديدة')]
class CreateInvoiceWizard extends Component
{
public int $currentStep = 1;
public int $totalSteps = 5;
public bool $completed = false;
// Step 1: Client
public string $participantSearch = '';
public ?int $participantId = null;
public ?string $participantName = null;
public string $contactName = '';
public string $contactPhone = '';
// Step 2: Items (repeatable)
public array $items = [];
// Step 3: Discount & Tax
public string $discountAmount = '0';
public string $taxAmount = '0';
// Step 4: Dates
public string $issueDate = '';
public string $dueDate = '';
public string $notes = '';
public function mount(): void
{
$this->authorize('invoices.create');
$this->issueDate = now()->format('Y-m-d');
$this->dueDate = now()->addDays(30)->format('Y-m-d');
$this->addItem();
}
public function getStepLabels(): array
{
return [
1 => 'العميل',
2 => 'البنود',
3 => 'الخصم والضريبة',
4 => 'تاريخ الاستحقاق',
5 => 'مراجعة',
];
}
public function rulesForStep(): array
{
return match ($this->currentStep) {
1 => [
'participantId' => 'required|exists:participants,id',
'contactName' => 'nullable|string|max:255',
'contactPhone' => 'nullable|string|max:20',
],
2 => [
'items' => 'required|array|min:1',
'items.*.description' => 'required|string|max:255',
'items.*.quantity' => 'required|integer|min:1',
'items.*.unit_price' => 'required|numeric|min:0.01',
],
3 => [
'discountAmount' => 'nullable|numeric|min:0',
'taxAmount' => 'nullable|numeric|min:0',
],
4 => [
'issueDate' => 'required|date',
'dueDate' => 'required|date|after_or_equal:issueDate',
'notes' => 'nullable|string|max:1000',
],
default => [],
};
}
public function messages(): array
{
return [
'participantId.required' => 'يرجى اختيار العميل',
'participantId.exists' => 'العميل المختار غير موجود',
'items.required' => 'يجب إضافة بند واحد على الأقل',
'items.min' => 'يجب إضافة بند واحد على الأقل',
'items.*.description.required' => 'وصف البند مطلوب',
'items.*.quantity.required' => 'الكمية مطلوبة',
'items.*.quantity.min' => 'الكمية يجب أن تكون 1 على الأقل',
'items.*.unit_price.required' => 'سعر الوحدة مطلوب',
'items.*.unit_price.min' => 'سعر الوحدة يجب أن يكون أكبر من صفر',
'issueDate.required' => 'تاريخ الإصدار مطلوب',
'dueDate.required' => 'تاريخ الاستحقاق مطلوب',
'dueDate.after_or_equal' => 'تاريخ الاستحقاق يجب أن يكون بعد أو يساوي تاريخ الإصدار',
];
}
public function addItem(): void
{
$this->items[] = [
'description' => '',
'quantity' => 1,
'unit_price' => '',
];
}
public function removeItem(int $index): void
{
if (count($this->items) > 1) {
unset($this->items[$index]);
$this->items = array_values($this->items);
}
}
public function selectParticipant(int $id): void
{
$participant = Participant::with('person')->find($id);
if ($participant) {
$this->participantId = $participant->id;
$this->participantName = $participant->person?->name_ar ?? $participant->person?->name;
$this->contactName = $participant->person?->name_ar ?? '';
$this->contactPhone = $participant->person?->phone ?? '';
}
}
public function getSubtotalProperty(): int
{
$subtotal = 0;
foreach ($this->items as $item) {
$price = (float) ($item['unit_price'] ?? 0);
$qty = (int) ($item['quantity'] ?? 0);
$subtotal += (int) round($price * 100) * $qty;
}
return $subtotal;
}
public function getTotalProperty(): int
{
$subtotal = $this->getSubtotalProperty();
$discount = (int) round((float) ($this->discountAmount ?? 0) * 100);
$tax = (int) round((float) ($this->taxAmount ?? 0) * 100);
return $subtotal - $discount + $tax;
}
public function nextStep(): void
{
$this->validate($this->rulesForStep(), $this->messages());
$this->currentStep = min($this->currentStep + 1, $this->totalSteps);
}
public function previousStep(): void
{
$this->currentStep = max($this->currentStep - 1, 1);
}
public function goToStep(int $step): void
{
if ($step < $this->currentStep) {
$this->currentStep = $step;
}
}
public function confirm(): void
{
try {
$subtotal = $this->getSubtotalProperty();
$discount = (int) round((float) ($this->discountAmount ?? 0) * 100);
$tax = (int) round((float) ($this->taxAmount ?? 0) * 100);
$total = $subtotal - $discount + $tax;
$data = [
'billable_type' => \App\Domain\Participant\Models\Participant::class,
'billable_id' => $this->participantId,
'contact_name' => $this->contactName ?: null,
'contact_phone' => $this->contactPhone ?: null,
'subtotal_amount' => $subtotal,
'discount_amount' => $discount,
'tax_amount' => $tax,
'total_amount' => $total,
'issue_date' => $this->issueDate,
'due_date' => $this->dueDate,
'notes' => $this->notes ?: null,
'currency' => 'EGP',
];
$items = collect($this->items)->map(function ($item) {
$unitPrice = (int) round((float) $item['unit_price'] * 100);
$quantity = (int) $item['quantity'];
return [
'description' => $item['description'],
'quantity' => $quantity,
'unit_price' => $unitPrice,
'discount_amount' => 0,
'tax_amount' => 0,
];
})->toArray();
app(InvoiceService::class)->create($data, $items, auth()->user());
$this->completed = true;
session()->flash('success', 'تم إنشاء الفاتورة بنجاح');
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function render()
{
$searchResults = collect();
if (strlen($this->participantSearch) >= 2 && !$this->participantId) {
$searchResults = Participant::query()
->with('person')
->where(function ($q) {
$search = $this->participantSearch;
$q->where('participant_number', 'ilike', "%{$search}%")
->orWhereHas('person', function ($pq) use ($search) {
$pq->where('name_ar', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%");
});
})
->limit(10)
->get();
}
return view('livewire.invoices.create-invoice-wizard', [
'searchResults' => $searchResults,
]);
}
}
<?php
namespace App\Livewire\Pricing;
use App\Domain\Pricing\Enums\AdjustmentType;
use App\Domain\Pricing\Enums\PricingRuleType;
use App\Domain\Pricing\Models\PricingRule;
use App\Domain\Shared\Exceptions\DomainException;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('إنشاء قاعدة تسعير')]
class CreatePricingRuleWizard extends Component
{
public int $currentStep = 1;
public int $totalSteps = 5;
public bool $completed = false;
// Step 1: Rule Type
public string $ruleType = '';
// Step 2: Conditions (dynamic based on rule_type)
public ?int $minAge = null;
public ?int $maxAge = null;
public ?int $minMonths = null;
public ?int $minChildren = null;
public ?int $orderNumber = null;
public string $targetGender = '';
public ?int $targetBranchId = null;
public string $targetClassification = '';
public string $genericConditions = '';
// Step 3: Adjustment
public string $adjustmentType = '';
public ?int $adjustmentValue = null;
public ?int $maxDiscountPercent = null;
// Step 4: Scope & Priority
public string $nameAr = '';
public string $name = '';
public ?string $targetType = null;
public ?int $targetId = null;
public ?int $branchId = null;
public int $priority = 10;
public bool $isStackable = true;
public string $effectiveFrom = '';
public ?string $effectiveTo = null;
public bool $isActive = true;
public ?int $usageLimit = null;
public function mount(): void
{
$this->authorize('pricing.create');
$this->effectiveFrom = now()->toDateString();
}
public function getStepLabels(): array
{
return [
1 => 'نوع القاعدة',
2 => 'الشروط',
3 => 'التعديل',
4 => 'النطاق والأولوية',
5 => 'مراجعة',
];
}
public function updatedRuleType(): void
{
$this->minAge = null;
$this->maxAge = null;
$this->minMonths = null;
$this->minChildren = null;
$this->orderNumber = null;
$this->targetGender = '';
$this->targetBranchId = null;
$this->targetClassification = '';
$this->genericConditions = '';
}
public function nextStep(): void
{
$this->validate($this->rulesForStep($this->currentStep));
if ($this->currentStep < $this->totalSteps) {
$this->currentStep++;
}
}
public function previousStep(): void
{
if ($this->currentStep > 1) {
$this->currentStep--;
}
}
public function goToStep(int $step): void
{
if ($step < $this->currentStep) {
$this->currentStep = $step;
}
}
public function confirm(): void
{
try {
DB::transaction(function () {
PricingRule::create([
'academy_id' => app('current_academy')->id,
'name_ar' => $this->nameAr,
'name' => $this->name ?: null,
'rule_type' => $this->ruleType,
'adjustment_type' => $this->adjustmentType,
'adjustment_value' => $this->adjustmentValue,
'conditions' => $this->buildConditions(),
'target_type' => $this->targetType ?: null,
'target_id' => $this->targetId ?: null,
'branch_id' => $this->branchId ?: null,
'priority' => $this->priority,
'is_stackable' => $this->isStackable,
'max_discount_percent' => $this->maxDiscountPercent,
'effective_from' => $this->effectiveFrom,
'effective_to' => $this->effectiveTo ?: null,
'is_active' => $this->isActive,
'usage_limit' => $this->usageLimit,
'usage_count' => 0,
'created_by' => auth()->id(),
]);
});
$this->completed = true;
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
private function buildConditions(): array
{
return match ($this->ruleType) {
'age' => ['min_age' => $this->minAge, 'max_age' => $this->maxAge],
'membership_duration' => ['min_months' => $this->minMonths],
'family_size' => ['min_children' => $this->minChildren],
'sibling_order' => ['order_number' => $this->orderNumber],
'gender' => ['target_gender' => $this->targetGender],
'branch' => ['target_branch_id' => $this->targetBranchId],
'classification' => ['target_classification' => $this->targetClassification],
default => json_decode($this->genericConditions ?: '{}', true) ?: [],
};
}
private function rulesForStep(int $step): array
{
return match ($step) {
1 => [
'ruleType' => 'required|in:' . implode(',', array_column(PricingRuleType::cases(), 'value')),
],
2 => $this->conditionRulesForType(),
3 => [
'adjustmentType' => 'required|in:' . implode(',', array_column(AdjustmentType::cases(), 'value')),
'adjustmentValue' => 'required|integer|min:1',
'maxDiscountPercent' => 'nullable|integer|min:1|max:100',
],
4 => [
'nameAr' => 'required|string|min:3|max:255',
'name' => 'nullable|string|max:255',
'priority' => 'required|integer|min:1|max:100',
'effectiveFrom' => 'required|date',
'effectiveTo' => 'nullable|date|after:effectiveFrom',
'usageLimit' => 'nullable|integer|min:1',
],
default => [],
};
}
private function conditionRulesForType(): array
{
return match ($this->ruleType) {
'age' => [
'minAge' => 'nullable|integer|min:1|max:100',
'maxAge' => 'nullable|integer|min:1|max:100',
],
'membership_duration' => [
'minMonths' => 'required|integer|min:1',
],
'family_size' => [
'minChildren' => 'required|integer|min:2',
],
'sibling_order' => [
'orderNumber' => 'required|integer|min:1',
],
'gender' => [
'targetGender' => 'required|in:male,female',
],
'branch' => [
'targetBranchId' => 'required|integer|exists:branches,id',
],
'classification' => [
'targetClassification' => 'required|in:regular,vip,scholarship,staff_child,trial',
],
default => [],
};
}
public function getRuleTypeLabelProperty(): string
{
if (!$this->ruleType) {
return '';
}
return PricingRuleType::from($this->ruleType)->label();
}
public function getAdjustmentTypeLabelProperty(): string
{
if (!$this->adjustmentType) {
return '';
}
return AdjustmentType::from($this->adjustmentType)->label();
}
public function render()
{
return view('livewire.pricing.create-pricing-rule-wizard', [
'ruleTypes' => PricingRuleType::cases(),
'adjustmentTypes' => AdjustmentType::cases(),
'branches' => \App\Domain\Identity\Models\Branch::orderBy('name_ar')->get(['id', 'name_ar', 'name']),
]);
}
}
<?php
namespace App\Livewire\Programs;
use App\Domain\Identity\Models\Branch;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Activity;
use App\Domain\Training\Services\TrainingProgramService;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('إنشاء برنامج تدريبي')]
class CreateProgramWizard extends Component
{
public int $currentStep = 1;
public int $totalSteps = 4;
public bool $completed = false;
// Step 1: Basic Info
public string $nameAr = '';
public string $name = '';
public ?int $activityId = null;
public ?int $branchId = null;
public string $skillLevel = '';
public string $ageMin = '';
public string $ageMax = '';
public string $gender = '';
public string $descriptionAr = '';
// Step 2: Schedule & Duration
public string $sessionsPerWeek = '';
public string $sessionDurationMinutes = '';
public string $programDurationWeeks = '';
public string $minParticipants = '';
public string $maxParticipants = '';
// Step 3: Pricing
public string $basePrice = '';
public function mount(): void
{
$this->authorize('programs.create');
}
public function getStepLabels(): array
{
return [
1 => 'المعلومات الأساسية',
2 => 'الجدول والمدة',
3 => 'التسعير',
4 => 'مراجعة',
];
}
public function rulesForStep(int $step): array
{
return match ($step) {
1 => [
'nameAr' => 'required|string|max:255',
'activityId' => 'required|exists:activities,id',
'branchId' => 'required|exists:branches,id',
'skillLevel' => 'nullable|string|max:50',
'ageMin' => 'nullable|integer|min:2|max:99',
'ageMax' => 'nullable|integer|min:2|max:99|gte:ageMin',
'gender' => 'nullable|in:male,female',
],
2 => [
'sessionsPerWeek' => 'required|integer|min:1|max:14',
'sessionDurationMinutes' => 'required|integer|min:15|max:300',
'programDurationWeeks' => 'required|integer|min:1|max:104',
'minParticipants' => 'required|integer|min:1',
'maxParticipants' => 'required|integer|min:1|gte:minParticipants',
],
3 => [
'basePrice' => 'required|numeric|min:0',
],
default => [],
};
}
public function messagesForStep(int $step): array
{
return match ($step) {
1 => [
'nameAr.required' => 'اسم البرنامج بالعربي مطلوب',
'activityId.required' => 'يجب اختيار النشاط',
'activityId.exists' => 'النشاط المختار غير موجود',
'branchId.required' => 'يجب اختيار الفرع',
'branchId.exists' => 'الفرع غير موجود',
'ageMin.integer' => 'الحد الأدنى للعمر يجب أن يكون رقم صحيح',
'ageMax.gte' => 'الحد الأقصى للعمر يجب أن يكون أكبر من أو يساوي الحد الأدنى',
'gender.in' => 'قيمة الجنس غير صالحة',
],
2 => [
'sessionsPerWeek.required' => 'عدد الحصص في الأسبوع مطلوب',
'sessionsPerWeek.integer' => 'عدد الحصص يجب أن يكون رقم صحيح',
'sessionsPerWeek.min' => 'عدد الحصص يجب أن يكون حصة واحدة على الأقل',
'sessionDurationMinutes.required' => 'مدة الحصة مطلوبة',
'sessionDurationMinutes.integer' => 'مدة الحصة يجب أن تكون رقم صحيح',
'sessionDurationMinutes.min' => 'مدة الحصة يجب أن تكون 15 دقيقة على الأقل',
'programDurationWeeks.required' => 'مدة البرنامج مطلوبة',
'programDurationWeeks.integer' => 'مدة البرنامج يجب أن تكون رقم صحيح',
'minParticipants.required' => 'الحد الأدنى للمشتركين مطلوب',
'maxParticipants.required' => 'الحد الأقصى للمشتركين مطلوب',
'maxParticipants.gte' => 'الحد الأقصى يجب أن يكون أكبر من أو يساوي الحد الأدنى',
],
3 => [
'basePrice.required' => 'السعر الأساسي مطلوب',
'basePrice.numeric' => 'السعر يجب أن يكون رقم',
'basePrice.min' => 'السعر لا يمكن أن يكون سالب',
],
default => [],
};
}
public function nextStep(): void
{
$this->validate(
$this->rulesForStep($this->currentStep),
$this->messagesForStep($this->currentStep)
);
if ($this->currentStep < $this->totalSteps) {
$this->currentStep++;
}
}
public function previousStep(): void
{
if ($this->currentStep > 1) {
$this->currentStep--;
}
}
public function goToStep(int $step): void
{
if ($step < $this->currentStep) {
$this->currentStep = $step;
}
}
public function confirm(): void
{
try {
DB::transaction(function () {
$data = [
'academy_id' => app('current_academy')->id,
'name_ar' => $this->nameAr,
'name' => $this->name ?: null,
'activity_id' => $this->activityId,
'branch_id' => $this->branchId,
'skill_level' => $this->skillLevel ?: null,
'age_min' => $this->ageMin ?: null,
'age_max' => $this->ageMax ?: null,
'gender' => $this->gender ?: null,
'description_ar' => $this->descriptionAr ?: null,
'sessions_per_week' => (int) $this->sessionsPerWeek,
'session_duration_minutes' => (int) $this->sessionDurationMinutes,
'program_duration_weeks' => (int) $this->programDurationWeeks,
'min_participants' => (int) $this->minParticipants,
'max_participants' => (int) $this->maxParticipants,
'status' => 'draft',
];
app(TrainingProgramService::class)->create($data, auth()->user());
});
$this->completed = true;
session()->flash('success', 'تم إنشاء البرنامج التدريبي بنجاح');
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function render()
{
return view('livewire.programs.create-program-wizard', [
'stepLabels' => $this->getStepLabels(),
'activities' => Activity::orderBy('name_ar')->get(['id', 'name_ar', 'name']),
'branches' => Branch::where('is_active', true)->orderBy('name_ar')->get(['id', 'name_ar']),
]);
}
}
<?php
use App\Domain\Document\Enums\DocumentStatus;
use App\Domain\Document\Enums\DocumentType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('documents', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained('academies');
$table->string('documentable_type', 100);
$table->unsignedBigInteger('documentable_id');
$table->string('document_type', 30);
$table->string('file_path', 500);
$table->string('original_filename', 255);
$table->string('mime_type', 100);
$table->bigInteger('file_size');
$table->string('status', 20)->default('pending');
$table->date('expires_at')->nullable();
$table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete();
$table->dateTime('approved_at')->nullable();
$table->text('rejection_reason')->nullable();
$table->text('notes')->nullable();
$table->foreignId('uploaded_by')->constrained('users');
$table->foreignId('created_by')->constrained('users');
$table->timestamps();
$table->softDeletes();
$table->index(['academy_id', 'documentable_type', 'documentable_id'], 'documents_polymorphic_idx');
$table->index(['academy_id', 'status']);
$table->index(['academy_id', 'document_type', 'status']);
$table->index('expires_at');
});
$types = implode("','", array_column(DocumentType::cases(), 'value'));
DB::statement("ALTER TABLE documents ADD CONSTRAINT documents_document_type_check CHECK (document_type IN ('{$types}'))");
$statuses = implode("','", array_column(DocumentStatus::cases(), 'value'));
DB::statement("ALTER TABLE documents ADD CONSTRAINT documents_status_check CHECK (status IN ('{$statuses}'))");
}
public function down(): void
{
Schema::dropIfExists('documents');
}
};
...@@ -183,6 +183,9 @@ private function getPermissionsList(): array ...@@ -183,6 +183,9 @@ private function getPermissionsList(): array
'evaluations.list', 'evaluations.create', 'evaluations.update', 'evaluations.manage', 'evaluations.list', 'evaluations.create', 'evaluations.update', 'evaluations.manage',
'evaluations.approve', 'evaluations.share', 'evaluations.approve', 'evaluations.share',
// Documents
'documents.list', 'documents.view', 'documents.upload', 'documents.approve', 'documents.delete',
// Audit // Audit
'audit.list', 'audit.view', 'audit.export', 'audit.list', 'audit.view', 'audit.export',
......
...@@ -86,6 +86,15 @@ public function run(): void ...@@ -86,6 +86,15 @@ public function run(): void
['group' => 'enrollment', 'key' => 'freeze_max_days', 'value' => '30', 'type' => 'integer', 'description_ar' => 'أقصى مدة تجميد الاشتراك (بالأيام)'], ['group' => 'enrollment', 'key' => 'freeze_max_days', 'value' => '30', 'type' => 'integer', 'description_ar' => 'أقصى مدة تجميد الاشتراك (بالأيام)'],
['group' => 'enrollment', 'key' => 'freeze_max_times', 'value' => '2', 'type' => 'integer', 'description_ar' => 'أقصى عدد مرات التجميد في الاشتراك الواحد'], ['group' => 'enrollment', 'key' => 'freeze_max_times', 'value' => '2', 'type' => 'integer', 'description_ar' => 'أقصى عدد مرات التجميد في الاشتراك الواحد'],
['group' => 'enrollment', 'key' => 'enrollment_expiry_days', 'value' => '30', 'type' => 'integer', 'description_ar' => 'مدة صلاحية الاشتراك الافتراضية (بالأيام)'], ['group' => 'enrollment', 'key' => 'enrollment_expiry_days', 'value' => '30', 'type' => 'integer', 'description_ar' => 'مدة صلاحية الاشتراك الافتراضية (بالأيام)'],
// Documents
['group' => 'enrollment', 'key' => 'require_documents_upload', 'value' => '0', 'type' => 'boolean', 'description_ar' => 'تفعيل نظام رفع المستندات (يسمح للمشتركين والموظفين برفع مستندات)'],
['group' => 'enrollment', 'key' => 'require_medical_certificate', 'value' => '0', 'type' => 'boolean', 'description_ar' => 'إلزام رفع شهادة طبية سارية'],
['group' => 'enrollment', 'key' => 'medical_certificate_expiry_required', 'value' => '1', 'type' => 'boolean', 'description_ar' => 'إلزام إدخال تاريخ انتهاء الشهادة الطبية عند الموافقة'],
['group' => 'enrollment', 'key' => 'medical_certificate_max_age_months', 'value' => '12', 'type' => 'integer', 'description_ar' => 'أقصى مدة صلاحية الشهادة الطبية بالأشهر'],
['group' => 'enrollment', 'key' => 'block_attendance_without_medical', 'value' => '0', 'type' => 'boolean', 'description_ar' => 'منع تسجيل الحضور بدون شهادة طبية سارية'],
['group' => 'enrollment', 'key' => 'allowed_document_types', 'value' => '["birth_certificate","national_id","medical_certificate","passport","photo","contract","qualification","insurance","other"]', 'type' => 'json', 'description_ar' => 'أنواع المستندات المسموح رفعها'],
['group' => 'enrollment', 'key' => 'max_document_size_mb', 'value' => '10', 'type' => 'integer', 'description_ar' => 'الحد الأقصى لحجم الملف المرفوع (ميجابايت)'],
]; ];
foreach ($settings as $setting) { foreach ($settings as $setting) {
......
<div>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
<h1 class="text-xl sm:text-2xl font-bold text-gray-800">{{ __('اعتماد المستندات') }}</h1>
</div>
{{-- Filters --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<input type="text" wire:model.live.debounce.300ms="search" placeholder="{{ __('بحث بالاسم أو الملف...') }}"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
<select wire:model.live="status" class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
<option value="">{{ __('كل الحالات') }}</option>
@foreach($statuses as $s)
<option value="{{ $s->value }}">{{ $s->label() }}</option>
@endforeach
</select>
<select wire:model.live="documentType" class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
<option value="">{{ __('كل الأنواع') }}</option>
@foreach($documentTypes as $dt)
<option value="{{ $dt->value }}">{{ $dt->label() }}</option>
@endforeach
</select>
</div>
</div>
{{-- Table --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div wire:loading.class="opacity-50 pointer-events-none">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('المستند') }}</th>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('النوع') }}</th>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('صاحب المستند') }}</th>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('رافع المستند') }}</th>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('الحالة') }}</th>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('التاريخ') }}</th>
<th class="px-4 py-3 text-end font-medium text-gray-600">{{ __('إجراء') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($documents as $doc)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-gray-800" dir="ltr">{{ Str::limit($doc->original_filename, 30) }}</td>
<td class="px-4 py-3 text-gray-600">{{ $doc->document_type->label() }}</td>
<td class="px-4 py-3 text-gray-600">{{ $doc->documentable?->name_ar ?? '-' }}</td>
<td class="px-4 py-3 text-gray-600">{{ $doc->uploader?->name_ar ?? '-' }}</td>
<td class="px-4 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
{{ match($doc->status->badgeColor()) { 'yellow' => 'bg-yellow-100 text-yellow-700', 'green' => 'bg-green-100 text-green-700', 'red' => 'bg-red-100 text-red-700', default => 'bg-gray-100 text-gray-700' } }}">
{{ $doc->status->label() }}
</span>
</td>
<td class="px-4 py-3 text-gray-500 text-xs" dir="ltr">{{ $doc->created_at->format('Y-m-d') }}</td>
<td class="px-4 py-3 text-end">
<a href="{{ route('documents.review', $doc) }}" wire:navigate
class="text-blue-600 hover:text-blue-800 text-xs font-medium">{{ __('مراجعة') }}</a>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-12 text-center text-gray-400">{{ __('لا توجد مستندات') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($documents->hasPages())
<div class="px-4 py-3 border-t border-gray-200">{{ $documents->links() }}</div>
@endif
</div>
</div>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl sm:text-2xl font-bold text-gray-800">{{ __('مراجعة مستند') }}</h1>
<a href="{{ route('documents.approvals') }}" wire:navigate
class="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/></svg>
{{ __('رجوع') }}
</a>
</div>
@if(session('success'))
<div class="mb-4 p-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm">{{ session('success') }}</div>
@endif
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Document Info --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">{{ __('معلومات المستند') }}</h2>
<dl class="space-y-3">
<div class="flex justify-between py-2 border-b border-gray-100">
<dt class="text-sm text-gray-500">{{ __('النوع') }}</dt>
<dd class="text-sm font-medium text-gray-800">{{ $document->document_type->label() }}</dd>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<dt class="text-sm text-gray-500">{{ __('اسم الملف') }}</dt>
<dd class="text-sm font-medium text-gray-800" dir="ltr">{{ $document->original_filename }}</dd>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<dt class="text-sm text-gray-500">{{ __('الحجم') }}</dt>
<dd class="text-sm font-medium text-gray-800" dir="ltr">{{ number_format($document->file_size / 1024, 0) }} KB</dd>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<dt class="text-sm text-gray-500">{{ __('صاحب المستند') }}</dt>
<dd class="text-sm font-medium text-gray-800">{{ $document->documentable?->name_ar ?? '-' }}</dd>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<dt class="text-sm text-gray-500">{{ __('رافع المستند') }}</dt>
<dd class="text-sm font-medium text-gray-800">{{ $document->uploader?->name_ar ?? '-' }}</dd>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<dt class="text-sm text-gray-500">{{ __('تاريخ الرفع') }}</dt>
<dd class="text-sm font-medium text-gray-800" dir="ltr">{{ $document->created_at->format('Y-m-d H:i') }}</dd>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<dt class="text-sm text-gray-500">{{ __('الحالة') }}</dt>
<dd>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
{{ match($document->status->badgeColor()) { 'yellow' => 'bg-yellow-100 text-yellow-700', 'green' => 'bg-green-100 text-green-700', 'red' => 'bg-red-100 text-red-700', default => 'bg-gray-100 text-gray-700' } }}">
{{ $document->status->label() }}
</span>
</dd>
</div>
@if($document->notes)
<div class="flex justify-between py-2 border-b border-gray-100">
<dt class="text-sm text-gray-500">{{ __('ملاحظات') }}</dt>
<dd class="text-sm text-gray-800">{{ $document->notes }}</dd>
</div>
@endif
</dl>
{{-- Preview/Download --}}
<div class="mt-4 flex gap-3">
<a href="{{ route('documents.view', $document) }}" target="_blank"
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 text-sm font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
{{ __('معاينة') }}
</a>
<a href="{{ route('documents.download', $document) }}"
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-50 text-gray-700 rounded-lg hover:bg-gray-100 text-sm font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
{{ __('تحميل') }}
</a>
</div>
</div>
{{-- Actions --}}
@if($document->isPending())
<div class="space-y-4">
{{-- Approve --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-green-700 mb-4">{{ __('اعتماد المستند') }}</h2>
@if($expiryRequired)
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('تاريخ الانتهاء') }} <span class="text-red-500">*</span></label>
<input type="date" wire:model="expiresAt" class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-sm" dir="ltr">
@error('expiresAt') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
@else
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('تاريخ الانتهاء (اختياري)') }}</label>
<input type="date" wire:model="expiresAt" class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-sm" dir="ltr">
</div>
@endif
<button wire:click="approve" wire:loading.attr="disabled" wire:target="approve"
class="w-full px-4 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium disabled:opacity-50">
<span wire:loading.remove wire:target="approve">{{ __('اعتماد') }}</span>
<span wire:loading wire:target="approve">{{ __('جارٍ الاعتماد...') }}</span>
</button>
</div>
{{-- Reject --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-red-700 mb-4">{{ __('رفض المستند') }}</h2>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('سبب الرفض') }} <span class="text-red-500">*</span></label>
<textarea wire:model="rejectionReason" rows="3" placeholder="{{ __('اكتب سبب رفض هذا المستند...') }}"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 text-sm"></textarea>
@error('rejectionReason') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<button wire:click="reject" wire:loading.attr="disabled" wire:target="reject"
class="w-full px-4 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium disabled:opacity-50">
<span wire:loading.remove wire:target="reject">{{ __('رفض') }}</span>
<span wire:loading wire:target="reject">{{ __('جارٍ الرفض...') }}</span>
</button>
</div>
</div>
@endif
</div>
</div>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl sm:text-2xl font-bold text-gray-800">{{ __('رفع مستند') }} — {{ $documentableLabel }}</h1>
</div>
{{-- Step Indicator --}}
<div class="flex items-center justify-center gap-2 mb-8">
@foreach($stepLabels as $num => $label)
<button wire:click="goToStep({{ $num }})"
class="flex items-center gap-2 {{ $num < $currentStep ? 'cursor-pointer' : 'cursor-default' }}">
<span class="flex items-center justify-center w-8 h-8 rounded-full text-sm font-bold
{{ $num < $currentStep ? 'bg-green-500 text-white' : ($num === $currentStep ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-500') }}">
@if($num < $currentStep)
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
@else
{{ $num }}
@endif
</span>
<span class="hidden sm:inline text-sm {{ $num === $currentStep ? 'text-blue-700 font-medium' : 'text-gray-500' }}">{{ $label }}</span>
</button>
@if(!$loop->last)
<div class="w-8 h-0.5 {{ $num < $currentStep ? 'bg-green-400' : 'bg-gray-200' }}"></div>
@endif
@endforeach
</div>
@if(session('error'))
<div class="mb-4 p-3 rounded-lg bg-red-50 border border-red-200 text-red-700 text-sm">{{ session('error') }}</div>
@endif
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
{{-- Step 1: Document Type --}}
@if($currentStep === 1)
<h2 class="text-lg font-semibold text-gray-800 mb-4">{{ __('اختر نوع المستند') }}</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
@foreach($allowedTypes as $type)
<label class="relative flex flex-col items-center p-4 rounded-lg border-2 cursor-pointer transition-all
{{ $documentType === $type->value ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300' }}">
<input type="radio" wire:model="documentType" value="{{ $type->value }}" class="sr-only">
<span class="text-sm font-medium text-center {{ $documentType === $type->value ? 'text-blue-700' : 'text-gray-700' }}">{{ $type->label() }}</span>
</label>
@endforeach
</div>
@error('documentType') <p class="mt-2 text-xs text-red-600">{{ $message }}</p> @enderror
@endif
{{-- Step 2: File Upload --}}
@if($currentStep === 2)
<h2 class="text-lg font-semibold text-gray-800 mb-4">{{ __('رفع الملف') }}</h2>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
<input type="file" wire:model="file" class="hidden" id="file-upload">
<label for="file-upload" class="cursor-pointer">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<p class="mt-2 text-sm text-gray-600">{{ __('اضغط لاختيار ملف أو اسحبه هنا') }}</p>
<p class="mt-1 text-xs text-gray-400">{{ __('الحد الأقصى:') }} {{ $maxSizeMb }} MB</p>
</label>
</div>
@if($file)
<div class="mt-3 p-3 bg-green-50 border border-green-200 rounded-lg flex items-center gap-3">
<svg class="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
<span class="text-sm text-green-700">{{ $file->getClientOriginalName() }} ({{ number_format($file->getSize() / 1024, 0) }} KB)</span>
</div>
@endif
<div wire:loading wire:target="file" class="mt-3 text-sm text-blue-600">{{ __('جارٍ رفع الملف...') }}</div>
@error('file') <p class="mt-2 text-xs text-red-600">{{ $message }}</p> @enderror
@endif
{{-- Step 3: Notes --}}
@if($currentStep === 3)
<h2 class="text-lg font-semibold text-gray-800 mb-4">{{ __('ملاحظات (اختياري)') }}</h2>
<textarea wire:model="notes" rows="4" placeholder="{{ __('أي ملاحظات إضافية حول هذا المستند...') }}"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"></textarea>
@error('notes') <p class="mt-2 text-xs text-red-600">{{ $message }}</p> @enderror
@endif
{{-- Step 4: Review/Confirm --}}
@if($currentStep === 4 && !$completed)
<h2 class="text-lg font-semibold text-gray-800 mb-4">{{ __('مراجعة وتأكيد') }}</h2>
<dl class="space-y-3">
<div class="flex justify-between py-2 border-b border-gray-100">
<dt class="text-sm text-gray-500">{{ __('نوع المستند') }}</dt>
<dd class="text-sm font-medium text-gray-800">{{ \App\Domain\Document\Enums\DocumentType::from($documentType)->label() }}</dd>
</div>
@if($file)
<div class="flex justify-between py-2 border-b border-gray-100">
<dt class="text-sm text-gray-500">{{ __('اسم الملف') }}</dt>
<dd class="text-sm font-medium text-gray-800" dir="ltr">{{ $file->getClientOriginalName() }}</dd>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<dt class="text-sm text-gray-500">{{ __('حجم الملف') }}</dt>
<dd class="text-sm font-medium text-gray-800" dir="ltr">{{ number_format($file->getSize() / 1024, 0) }} KB</dd>
</div>
@endif
@if($notes)
<div class="flex justify-between py-2 border-b border-gray-100">
<dt class="text-sm text-gray-500">{{ __('ملاحظات') }}</dt>
<dd class="text-sm text-gray-800">{{ $notes }}</dd>
</div>
@endif
</dl>
@endif
{{-- Success --}}
@if($completed && $createdDocument)
<div class="text-center py-8">
<div class="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-green-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</div>
<h2 class="text-xl font-bold text-gray-800 mb-2">{{ __('تم رفع المستند بنجاح') }}</h2>
<p class="text-sm text-gray-500 mb-1">{{ $createdDocument['type_label'] }} — {{ $createdDocument['original_filename'] }}</p>
<p class="text-sm text-gray-500">{{ __('الحالة:') }} {{ $createdDocument['status_label'] }}</p>
</div>
@endif
</div>
{{-- Navigation Buttons --}}
@if(!$completed)
<div class="flex items-center justify-between mt-6">
<button wire:click="previousStep" @if($currentStep === 1) disabled @endif
class="px-5 py-2.5 text-sm font-medium rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
{{ __('السابق') }}
</button>
@if($currentStep < $totalSteps)
<button wire:click="nextStep"
class="px-5 py-2.5 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700">
{{ __('التالي') }}
</button>
@else
<button wire:click="confirm" wire:loading.attr="disabled" wire:target="confirm"
class="px-5 py-2.5 text-sm font-medium rounded-lg bg-green-600 text-white hover:bg-green-700 disabled:opacity-50">
<span wire:loading.remove wire:target="confirm">{{ __('تأكيد الرفع') }}</span>
<span wire:loading wire:target="confirm">{{ __('جارٍ الرفع...') }}</span>
</button>
@endif
</div>
@endif
</div>
<div>
@if($show)
<div class="rounded-lg p-3 flex items-center gap-3 {{ $isBlocking ? 'bg-red-50 border border-red-200' : 'bg-yellow-50 border border-yellow-200' }}">
<svg class="w-5 h-5 flex-shrink-0 {{ $isBlocking ? 'text-red-500' : 'text-yellow-500' }}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
<div>
<p class="text-sm font-medium {{ $isBlocking ? 'text-red-800' : 'text-yellow-800' }}">
{{ $isBlocking ? __('مطلوب شهادة طبية سارية — لا يمكن تسجيل الحضور') : __('لم يتم رفع شهادة طبية سارية') }}
</p>
<p class="text-xs {{ $isBlocking ? 'text-red-600' : 'text-yellow-600' }} mt-0.5">
{{ __('يرجى رفع شهادة طبية صالحة للمشترك') }}
</p>
</div>
</div>
@endif
</div>
<div>
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-800">{{ __('المستندات') }}</h3>
@can('documents.upload')
<a href="{{ route('documents.upload', ['documentableType' => 'participant', 'documentableId' => $participant->id]) }}" wire:navigate
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-xs font-medium">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
{{ __('رفع مستند') }}
</a>
@endcan
</div>
@if($documents->isEmpty())
<div class="text-center py-8 text-gray-400 text-sm">{{ __('لا توجد مستندات مرفوعة') }}</div>
@else
<div class="space-y-2">
@foreach($documents as $doc)
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-100">
<div class="flex items-center gap-3 min-w-0">
<div class="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</div>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-800 truncate">{{ $doc->document_type->label() }}</p>
<p class="text-xs text-gray-500 truncate" dir="ltr">{{ $doc->original_filename }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium
{{ match($doc->status->badgeColor()) { 'yellow' => 'bg-yellow-100 text-yellow-700', 'green' => 'bg-green-100 text-green-700', 'red' => 'bg-red-100 text-red-700', default => 'bg-gray-100 text-gray-700' } }}">
{{ $doc->status->label() }}
</span>
@if($doc->expires_at)
<span class="text-[10px] text-gray-400" dir="ltr">{{ $doc->expires_at->format('Y-m-d') }}</span>
@endif
<a href="{{ route('documents.view', $doc) }}" target="_blank" class="text-blue-600 hover:text-blue-800">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>
@can('documents.delete')
<button wire:click="delete('{{ $doc->uuid }}')" wire:confirm="{{ __('هل أنت متأكد من حذف هذا المستند؟') }}"
class="text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
@endcan
</div>
</div>
@endforeach
</div>
@endif
</div>
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -18,6 +18,7 @@ ...@@ -18,6 +18,7 @@
Schedule::command('reminders:overdue-invoices')->weeklyOn(4, '09:00'); Schedule::command('reminders:overdue-invoices')->weeklyOn(4, '09:00');
Schedule::command('groups:reconcile-counts')->dailyAt('03:00'); Schedule::command('groups:reconcile-counts')->dailyAt('03:00');
Schedule::command('enrollments:deactivate-expired')->dailyAt('00:30'); Schedule::command('enrollments:deactivate-expired')->dailyAt('00:30');
Schedule::command('documents:expire')->dailyAt('00:15');
Schedule::command('financials:reconcile')->weeklyOn(0, '04:00'); Schedule::command('financials:reconcile')->weeklyOn(0, '04:00');
Schedule::command('reports:parent-weekly')->weeklyOn(6, '12:00'); Schedule::command('reports:parent-weekly')->weeklyOn(6, '12:00');
Schedule::command('groups:alert-capacity --threshold=90')->dailyAt('08:00'); Schedule::command('groups:alert-capacity --threshold=90')->dailyAt('08:00');
...@@ -167,6 +167,8 @@ ...@@ -167,6 +167,8 @@
// Training Programs // Training Programs
Route::get('/programs', ProgramList::class)->name('programs.list') Route::get('/programs', ProgramList::class)->name('programs.list')
->middleware('permission:programs.list'); ->middleware('permission:programs.list');
Route::get('/programs/wizard', \App\Livewire\Programs\CreateProgramWizard::class)->name('programs.wizard')
->middleware('permission:programs.create');
Route::get('/programs/create', ProgramForm::class)->name('programs.create') Route::get('/programs/create', ProgramForm::class)->name('programs.create')
->middleware('permission:programs.create'); ->middleware('permission:programs.create');
Route::get('/programs/{program}/edit', ProgramForm::class)->name('programs.edit') Route::get('/programs/{program}/edit', ProgramForm::class)->name('programs.edit')
...@@ -175,6 +177,8 @@ ...@@ -175,6 +177,8 @@
// Training Groups // Training Groups
Route::get('/groups', GroupList::class)->name('groups.list') Route::get('/groups', GroupList::class)->name('groups.list')
->middleware('permission:groups.list'); ->middleware('permission:groups.list');
Route::get('/groups/wizard', \App\Livewire\Groups\CreateGroupWizard::class)->name('groups.wizard')
->middleware('permission:groups.create');
Route::get('/groups/create', GroupForm::class)->name('groups.create') Route::get('/groups/create', GroupForm::class)->name('groups.create')
->middleware('permission:groups.create'); ->middleware('permission:groups.create');
Route::get('/groups/{group}/edit', GroupForm::class)->name('groups.edit') Route::get('/groups/{group}/edit', GroupForm::class)->name('groups.edit')
...@@ -189,6 +193,8 @@ ...@@ -189,6 +193,8 @@
->middleware('permission:enrollments.create'); ->middleware('permission:enrollments.create');
Route::get('/enrollments/transfer', TransferGroup::class)->name('enrollments.transfer') Route::get('/enrollments/transfer', TransferGroup::class)->name('enrollments.transfer')
->middleware('permission:enrollments.create'); ->middleware('permission:enrollments.create');
Route::get('/enrollments/transfer-wizard', \App\Livewire\Enrollments\TransferParticipantWizard::class)->name('enrollments.transfer-wizard')
->middleware('permission:enrollments.create');
// Wallets // Wallets
Route::get('/wallets', WalletList::class)->name('wallets.list') Route::get('/wallets', WalletList::class)->name('wallets.list')
...@@ -209,6 +215,8 @@ ...@@ -209,6 +215,8 @@
// 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');
Route::get('/invoices/wizard', \App\Livewire\Invoices\CreateInvoiceWizard::class)->name('invoices.wizard')
->middleware('permission:invoices.create');
Route::get('/invoices/create', InvoiceCreate::class)->name('invoices.create') Route::get('/invoices/create', InvoiceCreate::class)->name('invoices.create')
->middleware('permission:invoices.create'); ->middleware('permission:invoices.create');
Route::get('/invoices/{invoice}', InvoiceShow::class)->name('invoices.show') Route::get('/invoices/{invoice}', InvoiceShow::class)->name('invoices.show')
...@@ -217,6 +225,8 @@ ...@@ -217,6 +225,8 @@
// Facilities // Facilities
Route::get('/facilities', FacilityList::class)->name('facilities.list') Route::get('/facilities', FacilityList::class)->name('facilities.list')
->middleware('permission:facilities.list'); ->middleware('permission:facilities.list');
Route::get('/facilities/wizard', \App\Livewire\Facilities\CreateFacilityWizard::class)->name('facilities.wizard')
->middleware('permission:facilities.create');
Route::get('/facilities/create', FacilityForm::class)->name('facilities.create') Route::get('/facilities/create', FacilityForm::class)->name('facilities.create')
->middleware('permission:facilities.create'); ->middleware('permission:facilities.create');
Route::get('/facilities/{facility}/edit', FacilityForm::class)->name('facilities.edit') Route::get('/facilities/{facility}/edit', FacilityForm::class)->name('facilities.edit')
...@@ -243,6 +253,8 @@ ...@@ -243,6 +253,8 @@
// HR - Employees // HR - Employees
Route::get('/hr/employees', \App\Livewire\HR\EmployeeList::class)->name('employees.list') Route::get('/hr/employees', \App\Livewire\HR\EmployeeList::class)->name('employees.list')
->middleware('permission:employees.list'); ->middleware('permission:employees.list');
Route::get('/hr/employees/wizard', \App\Livewire\HR\CreateEmployeeWizard::class)->name('employees.wizard')
->middleware('permission:employees.create');
Route::get('/hr/employees/create', \App\Livewire\HR\EmployeeForm::class)->name('employees.create') Route::get('/hr/employees/create', \App\Livewire\HR\EmployeeForm::class)->name('employees.create')
->middleware('permission:employees.create'); ->middleware('permission:employees.create');
Route::get('/hr/employees/{employee}/edit', \App\Livewire\HR\EmployeeForm::class)->name('employees.edit') Route::get('/hr/employees/{employee}/edit', \App\Livewire\HR\EmployeeForm::class)->name('employees.edit')
...@@ -251,6 +263,8 @@ ...@@ -251,6 +263,8 @@
// HR - Trainers // HR - Trainers
Route::get('/hr/trainers', \App\Livewire\HR\TrainerList::class)->name('trainers.list') Route::get('/hr/trainers', \App\Livewire\HR\TrainerList::class)->name('trainers.list')
->middleware('permission:trainers.list'); ->middleware('permission:trainers.list');
Route::get('/hr/trainers/wizard', \App\Livewire\HR\CreateTrainerWizard::class)->name('trainers.wizard')
->middleware('permission:trainers.create');
Route::get('/hr/trainers/create', \App\Livewire\HR\TrainerForm::class)->name('trainers.create') Route::get('/hr/trainers/create', \App\Livewire\HR\TrainerForm::class)->name('trainers.create')
->middleware('permission:trainers.create'); ->middleware('permission:trainers.create');
Route::get('/hr/trainers/{trainer}/edit', \App\Livewire\HR\TrainerForm::class)->name('trainers.edit') Route::get('/hr/trainers/{trainer}/edit', \App\Livewire\HR\TrainerForm::class)->name('trainers.edit')
...@@ -273,6 +287,8 @@ ...@@ -273,6 +287,8 @@
// Pricing — Rules // Pricing — Rules
Route::get('/pricing/rules', PricingRuleList::class)->name('pricing.rules') Route::get('/pricing/rules', PricingRuleList::class)->name('pricing.rules')
->middleware('permission:pricing.list'); ->middleware('permission:pricing.list');
Route::get('/pricing/rules/wizard', \App\Livewire\Pricing\CreatePricingRuleWizard::class)->name('pricing.rules.wizard')
->middleware('permission:pricing.create');
Route::get('/pricing/rules/create', PricingRuleForm::class)->name('pricing.rules.create') Route::get('/pricing/rules/create', PricingRuleForm::class)->name('pricing.rules.create')
->middleware('permission:pricing.create'); ->middleware('permission:pricing.create');
Route::get('/pricing/rules/{pricingRule}/edit', PricingRuleForm::class)->name('pricing.rules.edit') Route::get('/pricing/rules/{pricingRule}/edit', PricingRuleForm::class)->name('pricing.rules.edit')
...@@ -295,6 +311,8 @@ ...@@ -295,6 +311,8 @@
// Inventory // Inventory
Route::get('/inventory/products', InventoryProductList::class)->name('inventory.products') Route::get('/inventory/products', InventoryProductList::class)->name('inventory.products')
->middleware('permission:inventory.list'); ->middleware('permission:inventory.list');
Route::get('/inventory/products/wizard', \App\Livewire\Inventory\CreateProductWizard::class)->name('inventory.products.wizard')
->middleware('permission:inventory.create');
Route::get('/inventory/products/create', InventoryProductForm::class)->name('inventory.products.create') Route::get('/inventory/products/create', InventoryProductForm::class)->name('inventory.products.create')
->middleware('permission:inventory.create'); ->middleware('permission:inventory.create');
Route::get('/inventory/products/{product}/edit', InventoryProductForm::class)->name('inventory.products.edit') Route::get('/inventory/products/{product}/edit', InventoryProductForm::class)->name('inventory.products.edit')
...@@ -309,6 +327,8 @@ ...@@ -309,6 +327,8 @@
->middleware('permission:inventory.list'); ->middleware('permission:inventory.list');
Route::get('/inventory/adjustments', StockAdjustment::class)->name('inventory.adjustments') Route::get('/inventory/adjustments', StockAdjustment::class)->name('inventory.adjustments')
->middleware('permission:inventory.adjust'); ->middleware('permission:inventory.adjust');
Route::get('/inventory/adjustments/wizard', \App\Livewire\Inventory\StockAdjustmentWizard::class)->name('inventory.adjustments.wizard')
->middleware('permission:inventory.adjust');
// Settings // Settings
Route::get('/settings', \App\Livewire\Settings\AcademySettings::class)->name('settings.academy') Route::get('/settings', \App\Livewire\Settings\AcademySettings::class)->name('settings.academy')
...@@ -426,6 +446,18 @@ ...@@ -426,6 +446,18 @@
Route::get('/admin/system-settings', \App\Livewire\Admin\SystemSettings::class)->name('admin.system-settings') Route::get('/admin/system-settings', \App\Livewire\Admin\SystemSettings::class)->name('admin.system-settings')
->middleware('permission:settings.manage'); ->middleware('permission:settings.manage');
// Documents
Route::get('/documents/upload/{documentableType}/{documentableId}', \App\Livewire\Documents\DocumentUploadWizard::class)
->name('documents.upload')->middleware('permission:documents.upload');
Route::get('/documents/approvals', \App\Livewire\Documents\DocumentApprovalList::class)
->name('documents.approvals')->middleware('permission:documents.approve');
Route::get('/documents/{document}/review', \App\Livewire\Documents\DocumentApproval::class)
->name('documents.review')->middleware('permission:documents.approve');
Route::get('/documents/{document}/view', [\App\Http\Controllers\DocumentController::class, 'show'])
->name('documents.view')->middleware('permission:documents.view');
Route::get('/documents/{document}/download', [\App\Http\Controllers\DocumentController::class, 'download'])
->name('documents.download')->middleware('permission:documents.view');
// Guardian Portal // Guardian Portal
Route::get('/guardian', GuardianDashboard::class)->name('guardian.dashboard') Route::get('/guardian', GuardianDashboard::class)->name('guardian.dashboard')
->middleware('permission:dashboard.view'); ->middleware('permission:dashboard.view');
......
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