From aeef95339d94718a486fa48be1679430440ac910 Mon Sep 17 00:00:00 2001 From: mehrab esmailnia Date: Wed, 26 Aug 2026 18:53:15 +0330 Subject: [PATCH] feat: add shift management functionality --- app/Enums/DocumentModalityEnum.php | 11 + app/Enums/ShiftDayEnum.php | 44 +++ .../Controllers/Admin/ShiftController.php | 145 +++++++++ .../Client/Doctor/ShiftController.php | 67 +++- .../Client/FastRequestController.php | 2 +- app/Http/Requests/Admin/ShiftRequest.php | 61 ++++ app/Http/Resources/Admin/ShiftResource.php | 32 ++ .../Resources/Client/FastRequestResource.php | 5 + app/Http/Resources/Client/ShiftResource.php | 24 ++ app/Models/Shift.php | 48 +++ app/Models/User.php | 5 + app/Services/Document/FastRequestService.php | 13 +- app/Services/Shift/ShiftScheduleService.php | 200 ++++++++++++ .../2026_08_26_000000_create_shifts_table.php | 38 +++ database/seeders/FastRequestSeeder.php | 5 +- database/seeders/PermissionSeeder.php | 10 + package.json | 4 + resources/css/Shared/shift-calendar.css | 101 ++++++ resources/css/admin.css | 1 + resources/css/client.css | 1 + .../js/Admin/Pages/Shifts/create_and_edit.vue | 101 ++++++ resources/js/Admin/Pages/Shifts/index.vue | 296 ++++++++++++++++++ .../js/Admin/components/SidebarComponent.vue | 12 + .../Admin/components/shifts/ShiftCalendar.vue | 103 ++++++ resources/js/Admin/store/Shift.js | 291 +++++++++++++++++ .../Client/Pages/Dashboard/Shifts/Index.vue | 95 +++++- .../dashboard/fastRequest/InvoiceModal.vue | 56 ++++ .../dashboard/shifts/ShiftCalendar.vue | 96 ++++++ .../components/sidebar/SidebarComponent.vue | 9 +- resources/js/Client/layouts/ClientLayout.vue | 1 - resources/js/Client/store/Shift.js | 37 +++ resources/js/Shared/shiftCalendarEvents.js | 46 +++ routes/admin.php | 6 + routes/web.php | 2 +- 34 files changed, 1953 insertions(+), 15 deletions(-) create mode 100644 app/Enums/ShiftDayEnum.php create mode 100644 app/Http/Controllers/Admin/ShiftController.php create mode 100644 app/Http/Requests/Admin/ShiftRequest.php create mode 100644 app/Http/Resources/Admin/ShiftResource.php create mode 100644 app/Http/Resources/Client/ShiftResource.php create mode 100644 app/Models/Shift.php create mode 100644 app/Services/Shift/ShiftScheduleService.php create mode 100644 database/migrations/2026_08_26_000000_create_shifts_table.php create mode 100644 resources/css/Shared/shift-calendar.css create mode 100644 resources/js/Admin/Pages/Shifts/create_and_edit.vue create mode 100644 resources/js/Admin/Pages/Shifts/index.vue create mode 100644 resources/js/Admin/components/shifts/ShiftCalendar.vue create mode 100644 resources/js/Admin/store/Shift.js create mode 100644 resources/js/Client/components/dashboard/fastRequest/InvoiceModal.vue create mode 100644 resources/js/Client/components/dashboard/shifts/ShiftCalendar.vue create mode 100644 resources/js/Client/store/Shift.js create mode 100644 resources/js/Shared/shiftCalendarEvents.js diff --git a/app/Enums/DocumentModalityEnum.php b/app/Enums/DocumentModalityEnum.php index 9a9cb4b..0cb0658 100644 --- a/app/Enums/DocumentModalityEnum.php +++ b/app/Enums/DocumentModalityEnum.php @@ -11,4 +11,15 @@ enum DocumentModalityEnum: string case Sonography = 'sonography'; case Echo = 'echo'; case Radiology = 'radiology'; + + public function price(): int + { + return match ($this) { + self::CtScan => 1000000, + self::Mri => 1200000, + self::Sonography => 500000, + self::Echo => 700000, + self::Radiology => 400000, + }; + } } diff --git a/app/Enums/ShiftDayEnum.php b/app/Enums/ShiftDayEnum.php new file mode 100644 index 0000000..7fe24dc --- /dev/null +++ b/app/Enums/ShiftDayEnum.php @@ -0,0 +1,44 @@ + Carbon::SATURDAY, + self::Sunday => Carbon::SUNDAY, + self::Monday => Carbon::MONDAY, + self::Tuesday => Carbon::TUESDAY, + self::Wednesday => Carbon::WEDNESDAY, + self::Thursday => Carbon::THURSDAY, + self::Friday => Carbon::FRIDAY, + }; + } + + public static function fromDate(Carbon $date): self + { + return match ($date->dayOfWeek) { + Carbon::SATURDAY => self::Saturday, + Carbon::SUNDAY => self::Sunday, + Carbon::MONDAY => self::Monday, + Carbon::TUESDAY => self::Tuesday, + Carbon::WEDNESDAY => self::Wednesday, + Carbon::THURSDAY => self::Thursday, + Carbon::FRIDAY => self::Friday, + }; + } +} diff --git a/app/Http/Controllers/Admin/ShiftController.php b/app/Http/Controllers/Admin/ShiftController.php new file mode 100644 index 0000000..c2492d1 --- /dev/null +++ b/app/Http/Controllers/Admin/ShiftController.php @@ -0,0 +1,145 @@ +authorize('shifts.index'); + + $lists = Shift::with(['doctor']) + ->when(isset($request->search) , function ($query) use ($request){ + $query->where(function ($q) use ($request) { + $q->whereHas('doctor', function ($doctorQuery) use ($request) { + $doctorQuery->where('fullname', 'LIKE', "%{$request->search}%"); + })->orWhere('id', $request->search); + }); + }) + ->when(isset($request->day_of_week) && $request->day_of_week != 'همه روزها', function ($query) use ($request) { + $query->whereIn('day_of_week' , (array) $request->day_of_week); + }) + ->when(isset($request->status) && $request->status != 'همه وضعیت ها', function ($query) use ($request) { + $query->where('is_active' , $request->status == 'فعال'); + }) + ->when(isset($request->occupancy) && $request->occupancy != 'همه شیفت ها', function ($query) use ($request) { + $request->occupancy == 'آزاد' + ? $query->whereNull('user_id') + : $query->whereNotNull('user_id'); + }) + ->latest() + ->paginate(20) + ->withQueryString() + ->onEachSide(1); + + $shifts = ShiftResource::collection($lists); + + return Inertia::admin('Shifts/index', [ + 'shifts' => $shifts, + 'search' => $request->search, + 'day_of_week' => $request->day_of_week, + 'status' => $request->status, + 'occupancy' => $request->occupancy, + 'permissions_edit' => auth()->user()->can('shifts.edit') ? false : true, + 'permissions_delete' => auth()->user()->can('shifts.delete') ? false : true, + ]); + } + + + public function create() + { + $this->authorize('shifts.create'); + return Inertia::admin('Shifts/create_and_edit'); + } + + + public function store(ShiftRequest $request) + { + $this->authorize('shifts.create'); + Shift::create([ + 'user_id' => $request->user_id, + 'day_of_week' => $request->day_of_week, + 'date' => $request->date, + 'start_time' => $request->start_time, + 'end_time' => $request->end_time, + 'is_active' => $request->is_active, + 'created_by' => auth()->id(), + ]); + + session()->flash('success', 'شیفت با موفقیت ثبت شد'); + return response(null , 200); + } + + + public function show(Shift $shift) + { + // + } + + + public function edit(Shift $shift) + { + $this->authorize('shifts.edit'); + return Inertia::admin('Shifts/create_and_edit', [ + 'shift' => ShiftResource::make($shift->load('doctor'))->resolve(), + 'is_edit' => true, + ]); + } + + + public function update(ShiftRequest $request, Shift $shift) + { + $this->authorize('shifts.edit'); + $shift->update([ + 'user_id' => $request->user_id, + 'day_of_week' => $request->day_of_week, + 'date' => $request->date, + 'start_time' => $request->start_time, + 'end_time' => $request->end_time, + 'is_active' => $request->is_active, + ]); + + session()->flash('success', 'شیفت با موفقیت بروزرسانی شد'); + return response(null , 200); + } + + + public function destroy(Shift $shift) + { + $this->authorize('shifts.delete'); + $shift->delete(); + + session()->flash('success', 'شیفت با موفقیت حذف شد'); + return response(null , 200); + } + + public function calendarData() + { + $this->authorize('shifts.index'); + + $lists = Shift::with('doctor')->get(); + + return response()->json([ + 'success' => true, + 'data' => ShiftResource::collection($lists), + ]); + } + + public function bulkDelete(Request $request) + { + $this->authorize('shifts.delete'); + Shift::destroy($request->ids); + session()->flash('success', 'شیفت های مورد نظر با موفقیت حذف شدند.'); + return response(null , 200); + } +} diff --git a/app/Http/Controllers/Client/Doctor/ShiftController.php b/app/Http/Controllers/Client/Doctor/ShiftController.php index ca55f1b..f240f8c 100644 --- a/app/Http/Controllers/Client/Doctor/ShiftController.php +++ b/app/Http/Controllers/Client/Doctor/ShiftController.php @@ -2,14 +2,77 @@ namespace App\Http\Controllers\Client\Doctor; +use App\Enums\ShiftDayEnum; +use App\Enums\UserEnum; use App\Http\Controllers\Controller; -use Illuminate\Http\Request; +use App\Http\Resources\Client\ShiftResource; +use App\Models\Shift; +use App\Services\Shift\ShiftScheduleService; +use Illuminate\Support\Facades\Auth; use Inertia\Inertia; class ShiftController extends Controller { + protected ShiftScheduleService $shiftScheduleService; + + public function __construct(ShiftScheduleService $shiftScheduleService) + { + $this->shiftScheduleService = $shiftScheduleService; + } + public function index() { - return Inertia::client('Dashboard/Shifts/Index'); + $this->ensureDoctor(); + + $myShifts = Shift::where('user_id', Auth::id())->get(); + + $openShifts = Shift::active() + ->open() + ->where(function ($query) { + $query->whereNull('date') + ->orWhereDate('date', '>=', now()->toDateString()); + }) + ->get(); + + return Inertia::client('Dashboard/Shifts/Index', [ + 'myShifts' => ShiftResource::collection($this->sortSchedule($myShifts)), + 'openShifts' => ShiftResource::collection($this->sortSchedule($openShifts)), + ]); + } + + public function update(Shift $shift) + { + $this->ensureDoctor(); + + $this->shiftScheduleService->claim($shift->id, Auth::user()); + + return response(null, 200); + } + + public function destroy(Shift $shift) + { + $this->ensureDoctor(); + + $this->shiftScheduleService->release($shift, Auth::user()); + + return response(null, 200); + } + + protected function ensureDoctor(): void + { + abort_unless(Auth::user()->level === UserEnum::Doctor->value, 403); + } + + protected function sortSchedule($shifts) + { + $dayOrder = array_flip(ShiftDayEnum::values()); + + return $shifts + ->sortBy([ + fn ($a, $b) => strcmp($a->date?->toDateString() ?? '', $b->date?->toDateString() ?? ''), + fn ($a, $b) => ($dayOrder[$a->day_of_week] ?? 0) <=> ($dayOrder[$b->day_of_week] ?? 0), + fn ($a, $b) => strcmp($a->start_time, $b->start_time), + ]) + ->values(); } } diff --git a/app/Http/Controllers/Client/FastRequestController.php b/app/Http/Controllers/Client/FastRequestController.php index 7487670..e95faba 100644 --- a/app/Http/Controllers/Client/FastRequestController.php +++ b/app/Http/Controllers/Client/FastRequestController.php @@ -45,7 +45,7 @@ class FastRequestController extends Controller ); return response()->json([ - 'document' => new FastRequestResource($document->load('pet')), + 'document' => new FastRequestResource($document->refresh()->load('pet')), ]); } } diff --git a/app/Http/Requests/Admin/ShiftRequest.php b/app/Http/Requests/Admin/ShiftRequest.php new file mode 100644 index 0000000..794527d --- /dev/null +++ b/app/Http/Requests/Admin/ShiftRequest.php @@ -0,0 +1,61 @@ +|string> + */ + public function rules(): array + { + return [ + 'user_id' => ['nullable', Rule::exists('users', 'id')->where('level', UserEnum::Doctor->value)], + 'day_of_week' => ['nullable', 'required_without:date', 'string', Rule::in(ShiftDayEnum::values())], + 'date' => ['nullable', 'required_without:day_of_week', 'date'], + 'start_time' => ['required', 'date_format:H:i'], + 'end_time' => ['required', 'date_format:H:i', 'different:start_time'], + 'is_active' => ['required', 'boolean'], + ]; + } + + public function prepareForValidation() + { + if ($this->filled('date')) { + $this->merge(['day_of_week' => null]); + } + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator) { + if ($validator->errors()->isNotEmpty()) { + return; + } + + $scheduleService = app(ShiftScheduleService::class); + $ignoreId = $this->route('shift')?->id; + + if ($scheduleService->hasConflict($this->validated(), $ignoreId)) { + $validator->errors()->add('start_time', 'این شیفت با شیفت دیگری از این پزشک تداخل دارد.'); + } + }); + } +} diff --git a/app/Http/Resources/Admin/ShiftResource.php b/app/Http/Resources/Admin/ShiftResource.php new file mode 100644 index 0000000..207ff46 --- /dev/null +++ b/app/Http/Resources/Admin/ShiftResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'user_id' => $this->user_id, + 'doctor' => $this->relationLoaded('doctor') && $this->doctor ? $this->doctor->only(['id', 'fullname']) : null, + 'day_of_week' => $this->day_of_week, + 'day_index' => $this->day_of_week ? ShiftDayEnum::from($this->day_of_week)->carbonDay() : null, + 'date' => $this->date ? verta($this->date)->format('%Y/%m/%d') : null, + 'date_raw' => $this->date?->format('Y-m-d'), + 'start_time' => substr($this->start_time, 0, 5), + 'end_time' => substr($this->end_time, 0, 5), + 'is_active' => $this->is_active, + 'created_at' => verta($this->created_at)->format('%Y/%m/%d - H:i'), + ]; + } +} diff --git a/app/Http/Resources/Client/FastRequestResource.php b/app/Http/Resources/Client/FastRequestResource.php index 024fa17..c1de4ea 100644 --- a/app/Http/Resources/Client/FastRequestResource.php +++ b/app/Http/Resources/Client/FastRequestResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources\Client; +use App\Enums\DocumentModalityEnum; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -16,6 +17,10 @@ class FastRequestResource extends JsonResource 'status' => $this->status, 'priority' => $this->priority, 'pet' => new PetArchiveResource($this->whenLoaded('pet')), + 'invoice' => [ + 'number' => str_pad((string) $this->id, 5, '0', STR_PAD_LEFT), + 'amount' => DocumentModalityEnum::from($this->modality)->price(), + ], ]; } } diff --git a/app/Http/Resources/Client/ShiftResource.php b/app/Http/Resources/Client/ShiftResource.php new file mode 100644 index 0000000..13570e9 --- /dev/null +++ b/app/Http/Resources/Client/ShiftResource.php @@ -0,0 +1,24 @@ + $this->id, + 'day_of_week' => $this->day_of_week, + 'day_index' => $this->day_of_week ? ShiftDayEnum::from($this->day_of_week)->carbonDay() : null, + 'date' => $this->date ? verta($this->date)->format('%Y/%m/%d') : null, + 'date_raw' => $this->date?->format('Y-m-d'), + 'start_time' => substr($this->start_time, 0, 5), + 'end_time' => substr($this->end_time, 0, 5), + 'is_active' => $this->is_active, + ]; + } +} diff --git a/app/Models/Shift.php b/app/Models/Shift.php new file mode 100644 index 0000000..c6b012c --- /dev/null +++ b/app/Models/Shift.php @@ -0,0 +1,48 @@ + 'date', + 'is_active' => 'boolean', + 'user_id' => 'integer', + 'created_by' => 'integer', + ]; + + public function doctor() + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeOpen($query) + { + return $query->whereNull('user_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 5fa3bdc..ccbbfb1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -71,4 +71,9 @@ class User extends Authenticatable { return $this->hasMany(Pet::class, 'owner_id'); } + + public function shifts() + { + return $this->hasMany(Shift::class); + } } diff --git a/app/Services/Document/FastRequestService.php b/app/Services/Document/FastRequestService.php index fe1786a..4a16d6a 100644 --- a/app/Services/Document/FastRequestService.php +++ b/app/Services/Document/FastRequestService.php @@ -10,15 +10,19 @@ use App\Models\Document; use App\Models\Pet; use App\Models\User; use App\Services\FileStorageService; +use App\Services\Shift\ShiftScheduleService; use Illuminate\Support\Facades\DB; class FastRequestService { protected FileStorageService $fileStorageService; - public function __construct(FileStorageService $fileStorageService) + protected ShiftScheduleService $shiftScheduleService; + + public function __construct(FileStorageService $fileStorageService, ShiftScheduleService $shiftScheduleService) { $this->fileStorageService = $fileStorageService; + $this->shiftScheduleService = $shiftScheduleService; } public function create(User $requester, array $data, array $historyFiles = [], array $scanFiles = []): Document @@ -38,9 +42,12 @@ class FastRequestService $this->attachFiles($document, $historyFiles, MediaCollectionEnum::History); $this->attachFiles($document, $scanFiles, MediaCollectionEnum::Primary); - if (!empty($data['doctor_id'])) { + $doctorId = $data['doctor_id'] + ?? $this->shiftScheduleService->autoSelectDoctor(now())?->id; + + if (!empty($doctorId)) { $document->assignments()->create([ - 'user_id' => $data['doctor_id'], + 'user_id' => $doctorId, 'role' => 'رادیولوژیست', ]); } diff --git a/app/Services/Shift/ShiftScheduleService.php b/app/Services/Shift/ShiftScheduleService.php new file mode 100644 index 0000000..4cd9a79 --- /dev/null +++ b/app/Services/Shift/ShiftScheduleService.php @@ -0,0 +1,200 @@ +hour * 60 + $moment->minute; + $today = ShiftDayEnum::fromDate($moment)->value; + $yesterday = ShiftDayEnum::fromDate($moment->copy()->subDay())->value; + + return Shift::active() + ->where(function ($query) use ($moment, $today, $yesterday) { + $query->whereDate('date', $moment->toDateString()) + ->orWhereDate('date', $moment->copy()->subDay()->toDateString()) + ->orWhere(function ($subQuery) use ($today, $yesterday) { + $subQuery->whereNull('date')->whereIn('day_of_week', [$today, $yesterday]); + }); + }) + ->get() + ->filter(function (Shift $shift) use ($moment, $minutes) { + $start = $this->toMinutes($shift->start_time); + $end = $this->toMinutes($shift->end_time); + + if ($end > $start) { + return $this->coversDate($shift, $moment) && $minutes >= $start && $minutes < $end; + } + + // overnight shift: covers [start, 24:00) on its day and [00:00, end) on the next day + if ($this->coversDate($shift, $moment) && $minutes >= $start) { + return true; + } + + return $this->coversDate($shift, $moment->copy()->subDay()) && $minutes < $end; + }) + ->values(); + } + + public function availableDoctorsAt(Carbon $moment): Collection + { + $doctorIds = $this->shiftsCoveringMoment($moment)->pluck('user_id')->filter()->unique(); + + if ($doctorIds->isEmpty()) { + return collect(); + } + + return User::where('level', UserEnum::Doctor->value) + ->whereIn('id', $doctorIds) + ->get(); + } + + public function autoSelectDoctor(Carbon $moment): ?User + { + $doctorIds = $this->shiftsCoveringMoment($moment)->pluck('user_id')->filter()->unique(); + + if ($doctorIds->isEmpty()) { + return null; + } + + return User::where('level', UserEnum::Doctor->value) + ->whereIn('id', $doctorIds) + ->withCount(['documents as open_documents_count' => function ($query) { + $query->whereIn('status', [ + DocumentStatusEnum::Registered->value, + DocumentStatusEnum::InProgress->value, + ]); + }]) + ->orderBy('open_documents_count') + ->orderBy('id') + ->first(); + } + + public function hasConflict(array $data, ?int $ignoreId = null): bool + { + if (empty($data['user_id'])) { + return false; + } + + $candidate = new Shift($data); + + return Shift::active() + ->where('user_id', $data['user_id']) + ->when($ignoreId, fn ($query) => $query->where('id', '!=', $ignoreId)) + ->get() + ->contains(fn (Shift $existing) => $this->overlaps($existing, $candidate)); + } + + public function claim(int $shiftId, User $doctor): Shift + { + return DB::transaction(function () use ($shiftId, $doctor) { + $shift = Shift::whereKey($shiftId)->lockForUpdate()->firstOrFail(); + + if (!$shift->is_active || $shift->user_id !== null) { + throw ValidationException::withMessages([ + 'shift' => 'این شیفت قابل انتخاب نیست.', + ]); + } + + $data = array_merge( + $shift->only(['day_of_week', 'date', 'start_time', 'end_time']), + ['user_id' => $doctor->id] + ); + + if ($this->hasConflict($data, $shift->id)) { + throw ValidationException::withMessages([ + 'shift' => 'این شیفت با برنامه فعلی شما تداخل دارد.', + ]); + } + + $shift->update(['user_id' => $doctor->id]); + + return $shift; + }); + } + + public function release(Shift $shift, User $doctor): Shift + { + if ($shift->user_id !== $doctor->id) { + throw ValidationException::withMessages([ + 'shift' => 'شما مجاز به تغییر این شیفت نیستید.', + ]); + } + + $shift->update(['user_id' => null]); + + return $shift; + } + + public function overlaps(Shift $first, Shift $second): bool + { + $byWeekday = !$first->date || !$second->date; + + foreach ($this->daySegments($first, $byWeekday) as [$dayA, $startA, $endA]) { + foreach ($this->daySegments($second, $byWeekday) as [$dayB, $startB, $endB]) { + if ($dayA === $dayB && $startA < $endB && $startB < $endA) { + return true; + } + } + } + + return false; + } + + protected function coversDate(Shift $shift, Carbon $date): bool + { + if ($shift->date) { + return $shift->date->isSameDay($date); + } + + return $shift->day_of_week === ShiftDayEnum::fromDate($date)->value; + } + + protected function daySegments(Shift $shift, bool $byWeekday): array + { + $start = $this->toMinutes($shift->start_time); + $end = $this->toMinutes($shift->end_time); + + if ($shift->date) { + $day = $byWeekday ? ShiftDayEnum::fromDate($shift->date)->value : $shift->date->toDateString(); + $nextDay = $byWeekday + ? ShiftDayEnum::fromDate($shift->date->copy()->addDay())->value + : $shift->date->copy()->addDay()->toDateString(); + } else { + $day = $shift->day_of_week; + $nextDay = $this->nextDay($shift->day_of_week); + } + + if ($end > $start) { + return [[$day, $start, $end]]; + } + + return [[$day, $start, 1440], [$nextDay, 0, $end]]; + } + + protected function nextDay(string $day): string + { + $days = ShiftDayEnum::values(); + $index = array_search($day, $days); + + return $days[($index + 1) % count($days)]; + } + + protected function toMinutes(string $time): int + { + [$hours, $minutes] = explode(':', $time); + + return ((int) $hours) * 60 + ((int) $minutes); + } +} diff --git a/database/migrations/2026_08_26_000000_create_shifts_table.php b/database/migrations/2026_08_26_000000_create_shifts_table.php new file mode 100644 index 0000000..1574a0d --- /dev/null +++ b/database/migrations/2026_08_26_000000_create_shifts_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('day_of_week')->nullable(); + $table->date('date')->nullable(); + $table->time('start_time'); + $table->time('end_time'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + + $table->index(['day_of_week', 'is_active']); + $table->index(['date', 'is_active']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shifts'); + } +}; diff --git a/database/seeders/FastRequestSeeder.php b/database/seeders/FastRequestSeeder.php index 3a09c33..f4c2e89 100644 --- a/database/seeders/FastRequestSeeder.php +++ b/database/seeders/FastRequestSeeder.php @@ -35,9 +35,10 @@ class FastRequestSeeder extends Seeder return collect($doctors)->map(function ($doctor) { return User::firstOrCreate( - ['username' => explode('@', $doctor['email'])[0]], - User::factory()->raw([ + ['email' => $doctor['email']], + array_merge(User::factory()->raw(), [ 'email' => $doctor['email'], + 'username' => explode('@', $doctor['email'])[0], 'level' => UserEnum::Doctor->value, 'fullname' => $doctor['fullname'], 'specialty' => $doctor['specialty'], diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index 785c31b..b757fec 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -99,6 +99,16 @@ class PermissionSeeder extends Seeder 'own' => 'دسترسی به گزارش خود', ], ], + 'shifts' => [ + 'label' => 'دسترسی به شیفت ها', + 'children' => [ + 'index' => 'دسترسی به لیست شیفت ها', + 'create' => 'دسترسی به ایجاد شیفت', + 'edit' => 'دسترسی به ویرایش شیفت', + 'delete' => 'دسترسی به حذف شیفت', + 'own' => 'دسترسی به شیفت های خود', + ], + ], ]; foreach ($permissions as $key => $data) { $parent = Permission::create([ diff --git a/package.json b/package.json index 2451747..6ede199 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,10 @@ "vite-svg-loader": "^5.1.0" }, "dependencies": { + "@fullcalendar/core": "^6.1.21", + "@fullcalendar/interaction": "^6.1.21", + "@fullcalendar/timegrid": "^6.1.21", + "@fullcalendar/vue3": "^7.0.2", "@inertiajs/inertia": "^0.11.1", "@inertiajs/inertia-vue3": "^0.6.0", "@inertiajs/progress": "^0.2.7", diff --git a/resources/css/Shared/shift-calendar.css b/resources/css/Shared/shift-calendar.css new file mode 100644 index 0000000..6936a50 --- /dev/null +++ b/resources/css/Shared/shift-calendar.css @@ -0,0 +1,101 @@ +.shift-calendar { + --fc-border-color: #DEE3EB; + --fc-page-bg-color: #FFFFFF; + --fc-today-bg-color: #F6F7F9; + --fc-now-indicator-color: #800E13; + direction: rtl; +} + +.shift-calendar .fc { + font-family: inherit; +} + +.shift-calendar .fc-timegrid-slot { + height: 2.25rem; +} + +.shift-calendar .fc-col-header-cell { + background: #F6F7F9; + padding: 8px 0; +} + +.shift-calendar .fc-col-header-cell-cushion { + font-size: 11px; + font-weight: 600; + color: #576989; + padding: 2px 4px; +} + +.shift-calendar .shift-calendar-day-date { + display: block; + font-size: 10px; + font-weight: 400; + color: #7188AD; + margin-top: 2px; +} + +.shift-calendar .fc-timegrid-slot-label-cushion { + font-size: 10px; + font-weight: 400; + color: #7188AD; +} + +.shift-calendar .fc-timegrid-axis { + background: #F6F7F9; +} + +.shift-calendar .fc-scrollgrid { + border-radius: 12px; + overflow: hidden; +} + +.shift-calendar .fc-event { + border-radius: 8px; + padding: 2px 6px; + font-size: 10px; + font-weight: 600; + cursor: pointer; + box-shadow: none; +} + +.shift-calendar .fc-event .fc-event-main { + color: inherit; +} + +.shift-calendar .fc-event-time { + font-size: 9px; + font-weight: 400; + direction: ltr; +} + +.shift-calendar .shift-event--mine { + background: #576989; + border: 1px solid #576989; + color: #FFFFFF; +} + +.shift-calendar .shift-event--mine-inactive { + background: #C8D2E2; + border: 1px solid #C8D2E2; + color: #2F343D; +} + +.shift-calendar .shift-event--open { + background: #F6F7F9; + border: 1px dashed #7188AD; + color: #576989; +} + +.shift-calendar .shift-event--open:hover { + background: #EDF1F7; +} + +.shift-calendar .shift-event--assigned { + border-width: 1px; + border-style: solid; + color: #FFFFFF; +} + +.shift-calendar .shift-event--inactive { + opacity: 0.55; +} diff --git a/resources/css/admin.css b/resources/css/admin.css index fb95190..9caf8f6 100644 --- a/resources/css/admin.css +++ b/resources/css/admin.css @@ -2,6 +2,7 @@ @import "./Shared/uicons-brands/css/uicons-brands.css"; @import "./Admin/input.css"; @import "./Shared/font.css"; +@import "./Shared/shift-calendar.css"; @import "tailwindcss"; @config "../../Admin-tailwind.config.js"; diff --git a/resources/css/client.css b/resources/css/client.css index c0597ee..e0c000d 100644 --- a/resources/css/client.css +++ b/resources/css/client.css @@ -9,6 +9,7 @@ @import "./Client/ui/select.css"; @import "./Client/ui/fast-request.css"; @import "./Client/ui/table.css"; +@import "./Shared/shift-calendar.css"; @import "tailwindcss"; @import "./Client/sidebar/sidebar.css"; @import "./Client/page-transitions.css"; diff --git a/resources/js/Admin/Pages/Shifts/create_and_edit.vue b/resources/js/Admin/Pages/Shifts/create_and_edit.vue new file mode 100644 index 0000000..ad90874 --- /dev/null +++ b/resources/js/Admin/Pages/Shifts/create_and_edit.vue @@ -0,0 +1,101 @@ + + + diff --git a/resources/js/Admin/Pages/Shifts/index.vue b/resources/js/Admin/Pages/Shifts/index.vue new file mode 100644 index 0000000..534c87e --- /dev/null +++ b/resources/js/Admin/Pages/Shifts/index.vue @@ -0,0 +1,296 @@ + + + diff --git a/resources/js/Admin/components/SidebarComponent.vue b/resources/js/Admin/components/SidebarComponent.vue index c845ec3..fc6d9af 100644 --- a/resources/js/Admin/components/SidebarComponent.vue +++ b/resources/js/Admin/components/SidebarComponent.vue @@ -86,6 +86,18 @@ const items: Ref = ref([ permission: "reports.index", expanded: false, }, + { + label: "مدیریت شیفت ها", + icon: "fi fi-rr-calendar", + to: "", + key: 'shifts', + permission: null, + expanded: false, + items: [ + { label: "همه شیفت ها", icon: "fi fi-rr-wifi-1", to: "/admin/shifts" , permission: "shifts.index" }, + { label: "افزودن شیفت جدید", icon: "fi fi-rr-wifi-1", to: "/admin/shifts/create" , permission: "shifts.create" }, + ], + }, { label: "مدیریت رسانه", icon: "fi fi-rr-gallery", diff --git a/resources/js/Admin/components/shifts/ShiftCalendar.vue b/resources/js/Admin/components/shifts/ShiftCalendar.vue new file mode 100644 index 0000000..07aacb0 --- /dev/null +++ b/resources/js/Admin/components/shifts/ShiftCalendar.vue @@ -0,0 +1,103 @@ + + + diff --git a/resources/js/Admin/store/Shift.js b/resources/js/Admin/store/Shift.js new file mode 100644 index 0000000..f2588d3 --- /dev/null +++ b/resources/js/Admin/store/Shift.js @@ -0,0 +1,291 @@ +import { defineStore } from 'pinia' +import axios from "axios"; +import { Inertia } from '@inertiajs/inertia' + +export const useShiftStore = defineStore('ShiftStore', { + state: () => ({ + createForm: { + user_id: '', + schedule_type: 'weekly', + day_of_week: '', + date: '', + start_time: '', + end_time: '', + is_active: true, + }, + + dayOptions: [ + { label: 'شنبه', value: 'شنبه' }, + { label: 'یکشنبه', value: 'یکشنبه' }, + { label: 'دوشنبه', value: 'دوشنبه' }, + { label: 'سه‌شنبه', value: 'سه‌شنبه' }, + { label: 'چهارشنبه', value: 'چهارشنبه' }, + { label: 'پنج‌شنبه', value: 'پنج‌شنبه' }, + { label: 'جمعه', value: 'جمعه' }, + ], + scheduleTypeOptions: [ + { label: 'هفتگی', value: 'weekly' }, + { label: 'تاریخ مشخص', value: 'date' }, + ], + statusOptions: [ + { label: 'فعال', value: true }, + { label: 'غیرفعال', value: false }, + ], + doctorOptions: [], + calendarShifts: [], + loaderCalendar: false, + loaderCreateForm: false, + is_edit_form: false, + errorsCreateForm: [], + is_edit_id: null, + filters: {}, + + columns: [ + { + key: 'doctor', + title: 'پزشک', + slot: 'doctor', + }, + { + key: 'day_of_week', + title: 'روز / تاریخ', + render: (data) => data.date ?? data.day_of_week ?? 'ثبت نشده', + filter: { + type: 'multiselect', + options: [ + { label: 'شنبه', value: 'شنبه' }, + { label: 'یکشنبه', value: 'یکشنبه' }, + { label: 'دوشنبه', value: 'دوشنبه' }, + { label: 'سه‌شنبه', value: 'سه‌شنبه' }, + { label: 'چهارشنبه', value: 'چهارشنبه' }, + { label: 'پنج‌شنبه', value: 'پنج‌شنبه' }, + { label: 'جمعه', value: 'جمعه' }, + ], + placeholder: 'انتخاب روز...' + } + }, + { + key: 'start_time', + title: 'ساعت شروع', + }, + { + key: 'end_time', + title: 'ساعت پایان', + }, + { + key: 'occupancy', + title: 'وضعیت اختصاص', + slot: 'occupancy', + filter: { + type: 'dropdown', + options: [ + { label: 'آزاد', value: 'آزاد' }, + { label: 'اختصاص یافته', value: 'اختصاص یافته' }, + ], + placeholder: 'انتخاب وضعیت اختصاص...' + } + }, + { + key: 'status', + title: 'وضعیت', + slot: 'status', + filter: { + type: 'dropdown', + options: [ + { label: 'فعال', value: 'فعال' }, + { label: 'غیرفعال', value: 'غیرفعال' }, + ], + placeholder: 'انتخاب وضعیت...' + } + }, + { + key: 'created_at', + title: 'تاریخ ثبت', + }, + { + key: 'actions', + title: 'عملیات', + hasSort: false, + slot: true, + }, + ], + endpoints: { + index: '/admin/shifts', + create: '/admin/shifts/create', + store: '/admin/shifts', + edit: (id) => `/admin/shifts/${id}/edit`, + update: (id) => `/admin/shifts/${id}`, + delete: (id) => `/admin/shifts/${id}`, + bulkDelete: '/admin/shifts-bulk-delete', + calendarData: '/admin/shifts-calendar-data', + doctorsOptions: '/admin/doctors-options', + } + }), + + getters: { + buttonLabel: (state) => { + return state.is_edit_id ? 'ویرایش شیفت' : 'ثبت و ایجاد شیفت جدید'; + }, + pageTitle: (state) => { + return state.is_edit_id ? 'ویرایش شیفت' : 'افزودن شیفت جدید'; + }, + indexPageTitle: (state) => { + return 'نمایش همه شیفت ها'; + } + }, + + actions: { + update_and_store() { + const url = this.is_edit_id ? + this.endpoints.update(this.is_edit_id) : + this.endpoints.store; + const method = this.is_edit_id ? 'patch' : 'post'; + this.loaderCreateForm = true; + axios({ + method: method, + url: url, + data: { + user_id: this.createForm.user_id || null, + day_of_week: this.createForm.schedule_type === 'weekly' ? this.createForm.day_of_week : null, + date: this.createForm.schedule_type === 'date' ? this.formatDate(this.createForm.date) : null, + start_time: this.formatTime(this.createForm.start_time), + end_time: this.formatTime(this.createForm.end_time), + is_active: this.createForm.is_active, + } + }).then(res => { + this.errorsCreateForm = [] + this.loaderCreateForm = false; + if (this.is_edit_id) { + Inertia.get(this.endpoints.edit(this.is_edit_id)); + } else { + this.resetCreateForm(); + Inertia.get(this.endpoints.create); + } + }).catch(error => { + this.loaderCreateForm = false; + this.errorsCreateForm = error.response.data?.errors ?? []; + }); + }, + + resetCreateForm() { + this.createForm = { + user_id: '', + schedule_type: 'weekly', + day_of_week: '', + date: '', + start_time: '', + end_time: '', + is_active: true, + } + this.is_edit_id = null; + this.errorsCreateForm = []; + }, + + setEditData(data) { + this.is_edit_id = data.id; + this.createForm = { + user_id: data.user_id ?? '', + schedule_type: data.date_raw ? 'date' : 'weekly', + day_of_week: data.day_of_week ?? '', + date: data.date_raw ?? '', + start_time: data.start_time ?? '', + end_time: data.end_time ?? '', + is_active: data.is_active, + }; + }, + + formatDate(value) { + if (!value) return null; + return String(value).split(' ')[0]; + }, + + formatTime(value) { + if (!value) return null; + const parts = String(value).split(' '); + const time = parts.length > 1 ? parts[1] : parts[0]; + return time.substring(0, 5); + }, + + fetchCalendarData() { + this.loaderCalendar = true; + axios.get(this.endpoints.calendarData).then(res => { + this.calendarShifts = res.data?.data ?? []; + }).finally(() => { + this.loaderCalendar = false; + }); + }, + + fetchDoctorOptions() { + axios.get(this.endpoints.doctorsOptions).then(res => { + this.doctorOptions = res.data?.data ?? []; + }); + }, + + deleteRecord(id) { + return new Promise((resolve, reject) => { + axios.delete(this.endpoints.delete(id)) + .then(response => { + Inertia.get(this.endpoints.index); + resolve(response); + }) + .catch(error => { + reject(error); + }); + }); + }, + + bulkDelete(ids) { + return new Promise((resolve, reject) => { + axios.post(this.endpoints.bulkDelete, { ids }) + .then(response => { + Inertia.get(this.endpoints.index); + resolve(response); + }) + .catch(error => { + reject(error); + }); + }); + }, + + search(params) { + return Inertia.get(this.endpoints.index, params, { + preserveState: false + }); + }, + + navigateToPage(page) { + return Inertia.get(this.endpoints.index, { page }, { preserveState: true }); + }, + + getStatusSeverity(isActive) { + return isActive ? 'info' : 'secondary'; + }, + + getOccupancySeverity(userId) { + return userId ? 'info' : 'success'; + }, + + convertFiltersToArrayParams(filters) { + const params = {}; + Object.keys(filters).forEach(key => { + if (Array.isArray(filters[key]) && filters[key].length > 0) { + filters[key].forEach((value, index) => { + params[`${key}[${index}]`] = value; + }); + } else if (!Array.isArray(filters[key]) && filters[key]) { + params[key] = filters[key]; + } + }); + return params; + }, + parseStringToArray(value) { + if (Array.isArray(value)) { + return value.filter(item => item !== '' && item != null); + } + if (value && typeof value === 'string' && value.trim() !== '') { + return value.split(',').map(item => item.trim()).filter(item => item !== ''); + } + return []; + }, + } +}) diff --git a/resources/js/Client/Pages/Dashboard/Shifts/Index.vue b/resources/js/Client/Pages/Dashboard/Shifts/Index.vue index 4076952..c7dc9f6 100644 --- a/resources/js/Client/Pages/Dashboard/Shifts/Index.vue +++ b/resources/js/Client/Pages/Dashboard/Shifts/Index.vue @@ -1,11 +1,98 @@ diff --git a/resources/js/Client/components/dashboard/fastRequest/InvoiceModal.vue b/resources/js/Client/components/dashboard/fastRequest/InvoiceModal.vue new file mode 100644 index 0000000..d23a666 --- /dev/null +++ b/resources/js/Client/components/dashboard/fastRequest/InvoiceModal.vue @@ -0,0 +1,56 @@ + + + diff --git a/resources/js/Client/components/dashboard/shifts/ShiftCalendar.vue b/resources/js/Client/components/dashboard/shifts/ShiftCalendar.vue new file mode 100644 index 0000000..d692cf9 --- /dev/null +++ b/resources/js/Client/components/dashboard/shifts/ShiftCalendar.vue @@ -0,0 +1,96 @@ + + + diff --git a/resources/js/Client/components/sidebar/SidebarComponent.vue b/resources/js/Client/components/sidebar/SidebarComponent.vue index 2f481da..12e48cb 100644 --- a/resources/js/Client/components/sidebar/SidebarComponent.vue +++ b/resources/js/Client/components/sidebar/SidebarComponent.vue @@ -18,6 +18,7 @@ interface SidebarItem extends MenuItem { expanded?: boolean; items?: SidebarItem[]; permission?: string | null; + level?: string | null; } const globalStore = useGlobalStore(); @@ -31,9 +32,14 @@ function hasPermission(permission) { return userPermissions.value.includes(permission); } +function hasLevel(level) { + if (!level) return true; + return currentUser.value?.level === level; +} + function filterSidebarItems(items) { return items - .filter(item => hasPermission(item.permission)) + .filter(item => hasPermission(item.permission) && hasLevel(item.level)) .map(item => { if (item.items) { const filteredChildren = filterSidebarItems(item.items); @@ -87,6 +93,7 @@ const items: Ref = ref([ to: "/dashboard/shifts", key: 'shifts', permission: null, + level: "پزشک", }, // { // label: "خروج از سامانه", diff --git a/resources/js/Client/layouts/ClientLayout.vue b/resources/js/Client/layouts/ClientLayout.vue index 36b9090..6aa1aa4 100644 --- a/resources/js/Client/layouts/ClientLayout.vue +++ b/resources/js/Client/layouts/ClientLayout.vue @@ -27,7 +27,6 @@ onMounted(() => {
diff --git a/resources/js/Client/store/Shift.js b/resources/js/Client/store/Shift.js new file mode 100644 index 0000000..5ebd4f0 --- /dev/null +++ b/resources/js/Client/store/Shift.js @@ -0,0 +1,37 @@ +import { defineStore } from 'pinia' +import { Inertia } from '@inertiajs/inertia' + +export const useShiftStore = defineStore('ShiftStore', { + state: () => ({ + endpoints: { + index: '/dashboard/shifts', + claim: (id) => `/dashboard/shifts/${id}`, + release: (id) => `/dashboard/shifts/${id}`, + }, + processingId: null, + }), + actions: { + async claim(id) { + this.processingId = id + try { + await window.axios.patch(this.endpoints.claim(id)) + Inertia.get(this.endpoints.index) + } catch (error) { + throw new Error(error.response?.data?.errors?.shift?.[0] ?? 'خطا در انتخاب شیفت') + } finally { + this.processingId = null + } + }, + async release(id) { + this.processingId = id + try { + await window.axios.delete(this.endpoints.release(id)) + Inertia.get(this.endpoints.index) + } catch (error) { + throw new Error(error.response?.data?.errors?.shift?.[0] ?? 'خطا در لغو شیفت') + } finally { + this.processingId = null + } + }, + }, +}) diff --git a/resources/js/Shared/shiftCalendarEvents.js b/resources/js/Shared/shiftCalendarEvents.js new file mode 100644 index 0000000..220e153 --- /dev/null +++ b/resources/js/Shared/shiftCalendarEvents.js @@ -0,0 +1,46 @@ +const isOvernight = (shift) => shift.end_time <= shift.start_time + +const nextDay = (dateStr) => { + const date = new Date(`${dateStr}T00:00:00`) + date.setDate(date.getDate() + 1) + return date.toISOString().slice(0, 10) +} + +const extendPastMidnight = (time) => { + const [hours, minutes] = time.split(':') + return `${parseInt(hours, 10) + 24}:${minutes}` +} + +export function buildShiftEvent(shift, overrides = {}) { + const base = { + id: String(shift.id), + extendedProps: { shift }, + ...overrides, + } + + if (shift.date_raw) { + const endDate = isOvernight(shift) ? nextDay(shift.date_raw) : shift.date_raw + return { + ...base, + start: `${shift.date_raw}T${shift.start_time}`, + end: `${endDate}T${shift.end_time}`, + } + } + + return { + ...base, + daysOfWeek: [shift.day_index], + startTime: shift.start_time, + endTime: isOvernight(shift) ? extendPastMidnight(shift.end_time) : shift.end_time, + } +} + +export function formatJalaliDay(date) { + return new Intl.DateTimeFormat('fa-IR', { day: 'numeric', month: 'short' }).format(date) +} + +export function formatJalaliRange(start, end) { + const formatter = new Intl.DateTimeFormat('fa-IR', { day: 'numeric', month: 'long', year: 'numeric' }) + const inclusiveEnd = new Date(end.getTime() - 1) + return `${formatter.format(start)} تا ${formatter.format(inclusiveEnd)}` +} diff --git a/routes/admin.php b/routes/admin.php index 8fb49dc..a3ae652 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Admin\UserController; use App\Http\Controllers\Admin\DocumentController; use App\Http\Controllers\Admin\ReportController; use App\Http\Controllers\Admin\PetController; +use App\Http\Controllers\Admin\ShiftController; use Illuminate\Support\Facades\Route; Route::group(['middleware' => ['auth', 'admin.access']], function () { @@ -24,6 +25,11 @@ Route::group(['middleware' => ['auth', 'admin.access']], function () { //Reports' routes Route::resource('/reports', ReportController::class); + //Shifts' routes + Route::resource('/shifts', ShiftController::class); + Route::post('/shifts-bulk-delete', [ShiftController::class , 'bulkDelete']); + Route::get('/shifts-calendar-data', [ShiftController::class , 'calendarData']); + //Pets' routes Route::resource('/pets', PetController::class); Route::post('/pets-bulk-delete', [PetController::class , 'bulkDelete']); diff --git a/routes/web.php b/routes/web.php index b546c1c..75e1a5b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -46,7 +46,7 @@ Route::group(['prefix' => 'dashboard' , 'middleware' => 'auth' , 'as' => 'dashbo Route::resource('cases', CaseController::class); Route::resource('reports', ReportController::class); - Route::resource('shifts', ShiftController::class); + Route::resource('shifts', ShiftController::class)->only(['index', 'update', 'destroy']); // Auth Logout Route::get('logout', [AuthController::class, 'logout'])->name('logout');