feat: add shift management functionality
This commit is contained in:
@@ -11,4 +11,15 @@ enum DocumentModalityEnum: string
|
|||||||
case Sonography = 'sonography';
|
case Sonography = 'sonography';
|
||||||
case Echo = 'echo';
|
case Echo = 'echo';
|
||||||
case Radiology = 'radiology';
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
enum ShiftDayEnum: string
|
||||||
|
{
|
||||||
|
use EnumHasOptions;
|
||||||
|
|
||||||
|
case Saturday = 'شنبه';
|
||||||
|
case Sunday = 'یکشنبه';
|
||||||
|
case Monday = 'دوشنبه';
|
||||||
|
case Tuesday = 'سهشنبه';
|
||||||
|
case Wednesday = 'چهارشنبه';
|
||||||
|
case Thursday = 'پنجشنبه';
|
||||||
|
case Friday = 'جمعه';
|
||||||
|
|
||||||
|
public function carbonDay(): int
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Saturday => 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Shift;
|
||||||
|
use App\Http\Resources\Admin\ShiftResource;
|
||||||
|
use App\Http\Requests\Admin\ShiftRequest;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class ShiftController extends Controller
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,77 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Client\Doctor;
|
namespace App\Http\Controllers\Client\Doctor;
|
||||||
|
|
||||||
|
use App\Enums\ShiftDayEnum;
|
||||||
|
use App\Enums\UserEnum;
|
||||||
use App\Http\Controllers\Controller;
|
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;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
class ShiftController extends Controller
|
class ShiftController extends Controller
|
||||||
{
|
{
|
||||||
|
protected ShiftScheduleService $shiftScheduleService;
|
||||||
|
|
||||||
|
public function __construct(ShiftScheduleService $shiftScheduleService)
|
||||||
|
{
|
||||||
|
$this->shiftScheduleService = $shiftScheduleService;
|
||||||
|
}
|
||||||
|
|
||||||
public function index()
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class FastRequestController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'document' => new FastRequestResource($document->load('pet')),
|
'document' => new FastRequestResource($document->refresh()->load('pet')),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use App\Enums\ShiftDayEnum;
|
||||||
|
use App\Enums\UserEnum;
|
||||||
|
use App\Services\Shift\ShiftScheduleService;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
class ShiftRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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', 'این شیفت با شیفت دیگری از این پزشک تداخل دارد.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources\Admin;
|
||||||
|
|
||||||
|
use App\Enums\ShiftDayEnum;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class ShiftResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Resources\Client;
|
namespace App\Http\Resources\Client;
|
||||||
|
|
||||||
|
use App\Enums\DocumentModalityEnum;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -16,6 +17,10 @@ class FastRequestResource extends JsonResource
|
|||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
'priority' => $this->priority,
|
'priority' => $this->priority,
|
||||||
'pet' => new PetArchiveResource($this->whenLoaded('pet')),
|
'pet' => new PetArchiveResource($this->whenLoaded('pet')),
|
||||||
|
'invoice' => [
|
||||||
|
'number' => str_pad((string) $this->id, 5, '0', STR_PAD_LEFT),
|
||||||
|
'amount' => DocumentModalityEnum::from($this->modality)->price(),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources\Client;
|
||||||
|
|
||||||
|
use App\Enums\ShiftDayEnum;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class ShiftResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Shift extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'day_of_week',
|
||||||
|
'date',
|
||||||
|
'start_time',
|
||||||
|
'end_time',
|
||||||
|
'is_active',
|
||||||
|
'user_id',
|
||||||
|
'created_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'date' => '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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,4 +71,9 @@ class User extends Authenticatable
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Pet::class, 'owner_id');
|
return $this->hasMany(Pet::class, 'owner_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function shifts()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Shift::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,19 @@ use App\Models\Document;
|
|||||||
use App\Models\Pet;
|
use App\Models\Pet;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\FileStorageService;
|
use App\Services\FileStorageService;
|
||||||
|
use App\Services\Shift\ShiftScheduleService;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class FastRequestService
|
class FastRequestService
|
||||||
{
|
{
|
||||||
protected FileStorageService $fileStorageService;
|
protected FileStorageService $fileStorageService;
|
||||||
|
|
||||||
public function __construct(FileStorageService $fileStorageService)
|
protected ShiftScheduleService $shiftScheduleService;
|
||||||
|
|
||||||
|
public function __construct(FileStorageService $fileStorageService, ShiftScheduleService $shiftScheduleService)
|
||||||
{
|
{
|
||||||
$this->fileStorageService = $fileStorageService;
|
$this->fileStorageService = $fileStorageService;
|
||||||
|
$this->shiftScheduleService = $shiftScheduleService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(User $requester, array $data, array $historyFiles = [], array $scanFiles = []): Document
|
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, $historyFiles, MediaCollectionEnum::History);
|
||||||
$this->attachFiles($document, $scanFiles, MediaCollectionEnum::Primary);
|
$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([
|
$document->assignments()->create([
|
||||||
'user_id' => $data['doctor_id'],
|
'user_id' => $doctorId,
|
||||||
'role' => 'رادیولوژیست',
|
'role' => 'رادیولوژیست',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Shift;
|
||||||
|
|
||||||
|
use App\Enums\DocumentStatusEnum;
|
||||||
|
use App\Enums\ShiftDayEnum;
|
||||||
|
use App\Enums\UserEnum;
|
||||||
|
use App\Models\Shift;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class ShiftScheduleService
|
||||||
|
{
|
||||||
|
public function shiftsCoveringMoment(Carbon $moment): Collection
|
||||||
|
{
|
||||||
|
$minutes = $moment->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('shifts', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -35,9 +35,10 @@ class FastRequestSeeder extends Seeder
|
|||||||
|
|
||||||
return collect($doctors)->map(function ($doctor) {
|
return collect($doctors)->map(function ($doctor) {
|
||||||
return User::firstOrCreate(
|
return User::firstOrCreate(
|
||||||
['username' => explode('@', $doctor['email'])[0]],
|
['email' => $doctor['email']],
|
||||||
User::factory()->raw([
|
array_merge(User::factory()->raw(), [
|
||||||
'email' => $doctor['email'],
|
'email' => $doctor['email'],
|
||||||
|
'username' => explode('@', $doctor['email'])[0],
|
||||||
'level' => UserEnum::Doctor->value,
|
'level' => UserEnum::Doctor->value,
|
||||||
'fullname' => $doctor['fullname'],
|
'fullname' => $doctor['fullname'],
|
||||||
'specialty' => $doctor['specialty'],
|
'specialty' => $doctor['specialty'],
|
||||||
|
|||||||
@@ -99,6 +99,16 @@ class PermissionSeeder extends Seeder
|
|||||||
'own' => 'دسترسی به گزارش خود',
|
'own' => 'دسترسی به گزارش خود',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'shifts' => [
|
||||||
|
'label' => 'دسترسی به شیفت ها',
|
||||||
|
'children' => [
|
||||||
|
'index' => 'دسترسی به لیست شیفت ها',
|
||||||
|
'create' => 'دسترسی به ایجاد شیفت',
|
||||||
|
'edit' => 'دسترسی به ویرایش شیفت',
|
||||||
|
'delete' => 'دسترسی به حذف شیفت',
|
||||||
|
'own' => 'دسترسی به شیفت های خود',
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
foreach ($permissions as $key => $data) {
|
foreach ($permissions as $key => $data) {
|
||||||
$parent = Permission::create([
|
$parent = Permission::create([
|
||||||
|
|||||||
@@ -21,6 +21,10 @@
|
|||||||
"vite-svg-loader": "^5.1.0"
|
"vite-svg-loader": "^5.1.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "^0.11.1",
|
||||||
"@inertiajs/inertia-vue3": "^0.6.0",
|
"@inertiajs/inertia-vue3": "^0.6.0",
|
||||||
"@inertiajs/progress": "^0.2.7",
|
"@inertiajs/progress": "^0.2.7",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
@import "./Shared/uicons-brands/css/uicons-brands.css";
|
@import "./Shared/uicons-brands/css/uicons-brands.css";
|
||||||
@import "./Admin/input.css";
|
@import "./Admin/input.css";
|
||||||
@import "./Shared/font.css";
|
@import "./Shared/font.css";
|
||||||
|
@import "./Shared/shift-calendar.css";
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@config "../../Admin-tailwind.config.js";
|
@config "../../Admin-tailwind.config.js";
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
@import "./Client/ui/select.css";
|
@import "./Client/ui/select.css";
|
||||||
@import "./Client/ui/fast-request.css";
|
@import "./Client/ui/fast-request.css";
|
||||||
@import "./Client/ui/table.css";
|
@import "./Client/ui/table.css";
|
||||||
|
@import "./Shared/shift-calendar.css";
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "./Client/sidebar/sidebar.css";
|
@import "./Client/sidebar/sidebar.css";
|
||||||
@import "./Client/page-transitions.css";
|
@import "./Client/page-transitions.css";
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup>
|
||||||
|
import Master from "../../layouts/master.vue";
|
||||||
|
import {onMounted , onUnmounted} from 'vue';
|
||||||
|
import {useShiftStore} from "../../store/Shift.js";
|
||||||
|
import {Link} from "@inertiajs/inertia-vue3";
|
||||||
|
import Card from "../../components/ui/Card.vue";
|
||||||
|
|
||||||
|
const shiftStore = useShiftStore();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
is_edit: {
|
||||||
|
type: Boolean,
|
||||||
|
default: () => false
|
||||||
|
},
|
||||||
|
shift: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
shiftStore.fetchDoctorOptions();
|
||||||
|
if (props.is_edit) {
|
||||||
|
shiftStore.setEditData(props.shift);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
shiftStore.resetCreateForm();
|
||||||
|
shiftStore.is_edit_form = false;
|
||||||
|
shiftStore.errorsCreateForm = [];
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<master>
|
||||||
|
<Card class="mb-4">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-[1fr_auto] justify-between items-center gap-4">
|
||||||
|
<h2 class="font-bold text-client-Fontcolor">{{ shiftStore.pageTitle }}</h2>
|
||||||
|
<Button severity="secondary" asChild v-slot="slotProps">
|
||||||
|
<Link :class="slotProps.class" :href="shiftStore.endpoints.index">
|
||||||
|
<span class="text-sm">{{ shiftStore.indexPageTitle }}</span>
|
||||||
|
<i class="fi fi-rr-list flex text-sm"></i>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<section class="grid w-full grid-cols-1 lg:grid-cols-[1fr_22rem] gap-4">
|
||||||
|
<Card>
|
||||||
|
<div class="rounded-2xl bg-white">
|
||||||
|
<Form ref="formRef" v-slot="$form" class=" p-4 grid grid-cols-2 gap-4 !w-full">
|
||||||
|
<FormField v-slot="$field" name="user_id" initialValue="" class="flex flex-col gap-1">
|
||||||
|
<label>پزشک</label>
|
||||||
|
<Select class="!text-xs !h-full" placeholder="انتخاب پزشک (خالی = شیفت آزاد)" v-model="shiftStore.createForm.user_id" :options="shiftStore.doctorOptions" optionValue="value" optionLabel="label" showClear :pt="{ option : '!text-sm' , placeholder : '!text-sm' , label : '!pt-3' }" />
|
||||||
|
<Message v-if="shiftStore.errorsCreateForm?.user_id" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.user_id[0] }}</Message>
|
||||||
|
</FormField>
|
||||||
|
<FormField v-slot="$field" name="schedule_type" initialValue="" class="flex flex-col gap-1">
|
||||||
|
<label>نوع زمانبندی</label>
|
||||||
|
<Select class="!text-xs !h-full" placeholder="انتخاب نوع زمانبندی" v-model="shiftStore.createForm.schedule_type" :options="shiftStore.scheduleTypeOptions" optionValue="value" optionLabel="label" :pt="{ option : '!text-sm' , placeholder : '!text-sm' , label : '!pt-3' }" />
|
||||||
|
</FormField>
|
||||||
|
<FormField v-if="shiftStore.createForm.schedule_type === 'weekly'" v-slot="$field" name="day_of_week" initialValue="" class="flex flex-col gap-1">
|
||||||
|
<label>روز هفته</label>
|
||||||
|
<Select class="!text-xs !h-full" placeholder="انتخاب روز هفته" v-model="shiftStore.createForm.day_of_week" :options="shiftStore.dayOptions" optionValue="value" optionLabel="label" :pt="{ option : '!text-sm' , placeholder : '!text-sm' , label : '!pt-3' }" />
|
||||||
|
<Message v-if="shiftStore.errorsCreateForm?.day_of_week" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.day_of_week[0] }}</Message>
|
||||||
|
</FormField>
|
||||||
|
<FormField v-else v-slot="$field" name="date" initialValue="" class="flex flex-col gap-1">
|
||||||
|
<label>تاریخ</label>
|
||||||
|
<CustomDatePicker v-model="shiftStore.createForm.date" class="!text-xs mt-2" type="date" format="YYYY-MM-DD" placeholder="تاریخ شیفت را انتخاب کنید"></CustomDatePicker>
|
||||||
|
<Message v-if="shiftStore.errorsCreateForm?.date" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.date[0] }}</Message>
|
||||||
|
</FormField>
|
||||||
|
<FormField v-slot="$field" name="start_time" initialValue="" class="flex flex-col gap-1">
|
||||||
|
<label>ساعت شروع</label>
|
||||||
|
<CustomDatePicker v-model="shiftStore.createForm.start_time" class="!text-xs mt-2" type="time" format="HH:mm" display-format="HH:mm" placeholder="ساعت شروع را انتخاب کنید"></CustomDatePicker>
|
||||||
|
<Message v-if="shiftStore.errorsCreateForm?.start_time" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.start_time[0] }}</Message>
|
||||||
|
</FormField>
|
||||||
|
<FormField v-slot="$field" name="end_time" initialValue="" class="flex flex-col gap-1">
|
||||||
|
<label>ساعت پایان</label>
|
||||||
|
<CustomDatePicker v-model="shiftStore.createForm.end_time" class="!text-xs mt-2" type="time" format="HH:mm" display-format="HH:mm" placeholder="ساعت پایان را انتخاب کنید"></CustomDatePicker>
|
||||||
|
<Message v-if="shiftStore.errorsCreateForm?.end_time" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.end_time[0] }}</Message>
|
||||||
|
</FormField>
|
||||||
|
<FormField v-slot="$field" name="is_active" initialValue="" class="flex flex-col gap-1">
|
||||||
|
<label>وضعیت</label>
|
||||||
|
<Select class="!text-xs !h-full" placeholder="انتخاب وضعیت" v-model="shiftStore.createForm.is_active" :options="shiftStore.statusOptions" optionValue="value" optionLabel="label" :pt="{ option : '!text-sm' , placeholder : '!text-sm' , label : '!pt-3' }" />
|
||||||
|
<Message v-if="shiftStore.errorsCreateForm?.is_active" severity="error" size="small" variant="simple">{{ shiftStore.errorsCreateForm?.is_active[0] }}</Message>
|
||||||
|
</FormField>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<div>
|
||||||
|
<Card>
|
||||||
|
<div class="p-4 grid grid-cols-1 gap-4 rounded-2xl">
|
||||||
|
<div class="grid grid-cols-1 justify-start">
|
||||||
|
<Button :loading="shiftStore.loaderCreateForm" @click="shiftStore.update_and_store" type="button" severity="secondary" :label="shiftStore.buttonLabel" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</master>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
<script setup>
|
||||||
|
import Master from "../../layouts/master.vue";
|
||||||
|
import { ref, reactive, watch } from 'vue';
|
||||||
|
import { Inertia } from '@inertiajs/inertia';
|
||||||
|
import TableGeneric from "../../components/TableGeneric.vue";
|
||||||
|
import ShiftCalendar from "../../components/shifts/ShiftCalendar.vue";
|
||||||
|
import { Link } from "@inertiajs/inertia-vue3";
|
||||||
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
|
import { useShiftStore } from "../../store/Shift.js";
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
const shiftStore = useShiftStore();
|
||||||
|
const props = defineProps({
|
||||||
|
shifts: {
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
day_of_week: {
|
||||||
|
type: [String, Array],
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
occupancy: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
permissions_edit: {
|
||||||
|
type: Boolean
|
||||||
|
},
|
||||||
|
permissions_delete: {
|
||||||
|
type: Boolean
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const shifts = ref(props.shifts ?? []);
|
||||||
|
const selectedItems = ref([]);
|
||||||
|
const searchLoader = ref(false);
|
||||||
|
const filterLoader = ref(false);
|
||||||
|
|
||||||
|
const columns = ref(shiftStore.columns)
|
||||||
|
|
||||||
|
const queryParams = reactive({
|
||||||
|
search: props.search ?? '',
|
||||||
|
day_of_week: props.day_of_week ?? '',
|
||||||
|
status: props.status ?? '',
|
||||||
|
occupancy: props.occupancy ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const filters = ref({
|
||||||
|
status: (props.status && typeof props.status === 'string') ? props.status : null,
|
||||||
|
occupancy: (props.occupancy && typeof props.occupancy === 'string') ? props.occupancy : null,
|
||||||
|
day_of_week: shiftStore.parseStringToArray(props.day_of_week),
|
||||||
|
})
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
|
const showCalendar = ref(false);
|
||||||
|
|
||||||
|
watch(showCalendar, (visible) => {
|
||||||
|
if (visible && !shiftStore.calendarShifts.length) {
|
||||||
|
shiftStore.fetchCalendarData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onCalendarSelect = (shift) => {
|
||||||
|
if (!props.permissions_edit) {
|
||||||
|
Inertia.get(shiftStore.endpoints.edit(shift.id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPageChange = (event) => {
|
||||||
|
shiftStore.navigateToPage(event.page + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchData = () => {
|
||||||
|
searchLoader.value = true;
|
||||||
|
|
||||||
|
const searchParams = {
|
||||||
|
...(queryParams.search && { search: queryParams.search }),
|
||||||
|
...(queryParams.status && { status: queryParams.status }),
|
||||||
|
...(queryParams.occupancy && { occupancy: queryParams.occupancy }),
|
||||||
|
...shiftStore.convertFiltersToArrayParams(filters.value)
|
||||||
|
};
|
||||||
|
shiftStore.search(searchParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSelectionChange = (selection) => {
|
||||||
|
selectedItems.value = selection;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSearchChange = (value) => {
|
||||||
|
queryParams.search = value;
|
||||||
|
searchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFilterChange = (newFilters) => {
|
||||||
|
filters.value = { ...newFilters };
|
||||||
|
Object.keys(newFilters).forEach(key => {
|
||||||
|
if (Array.isArray(newFilters[key])) {
|
||||||
|
queryParams[key] = newFilters[key].join(',');
|
||||||
|
} else {
|
||||||
|
queryParams[key] = newFilters[key] || '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFilters = () => {
|
||||||
|
filterLoader.value = true;
|
||||||
|
searchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
filters.value = {
|
||||||
|
status: null,
|
||||||
|
occupancy: null,
|
||||||
|
day_of_week: [],
|
||||||
|
};
|
||||||
|
queryParams.status = '';
|
||||||
|
queryParams.occupancy = '';
|
||||||
|
queryParams.day_of_week = '';
|
||||||
|
searchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDeleteConfirm = (data) => {
|
||||||
|
confirm.require({
|
||||||
|
group: 'deleteTemplate',
|
||||||
|
header: 'مطمعن هستید؟',
|
||||||
|
message: 'اگر به عملیات اطمینان دارید، تأیید کنید.',
|
||||||
|
icon: 'fi fi-rr-info',
|
||||||
|
rejectProps: {
|
||||||
|
label: 'انصراف',
|
||||||
|
icon: 'fi fi-rr-ban',
|
||||||
|
outlined: true,
|
||||||
|
size: 'small',
|
||||||
|
class: '!text-xs !flex pt-1 !item-center',
|
||||||
|
iconClass: '!flex !text-xs',
|
||||||
|
},
|
||||||
|
acceptProps: {
|
||||||
|
label: 'حذف',
|
||||||
|
icon: 'fi fi-rr-check',
|
||||||
|
size: 'small',
|
||||||
|
class: '!text-xs !flex pt-1 !item-center',
|
||||||
|
iconClass: '!flex !text-xs',
|
||||||
|
},
|
||||||
|
accept: () => {
|
||||||
|
shiftStore.deleteRecord(data.id);
|
||||||
|
},
|
||||||
|
reject: () => {
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteSelected = (ids) => {
|
||||||
|
confirm.require({
|
||||||
|
group: 'deleteTemplate',
|
||||||
|
header: 'مطمعن هستید؟',
|
||||||
|
message: 'اگر به عملیات اطمینان دارید، تأیید کنید.',
|
||||||
|
icon: 'fi fi-rr-info',
|
||||||
|
rejectProps: {
|
||||||
|
label: 'انصراف',
|
||||||
|
icon: 'fi fi-rr-ban',
|
||||||
|
outlined: true,
|
||||||
|
size: 'small',
|
||||||
|
class: '!text-xs !flex pt-1 !item-center',
|
||||||
|
iconClass: '!flex !text-xs',
|
||||||
|
},
|
||||||
|
acceptProps: {
|
||||||
|
label: 'حذف',
|
||||||
|
icon: 'fi fi-rr-check',
|
||||||
|
size: 'small',
|
||||||
|
class: '!text-xs !flex pt-1 !item-center',
|
||||||
|
iconClass: '!flex !text-xs',
|
||||||
|
},
|
||||||
|
accept: () => {
|
||||||
|
shiftStore.bulkDelete(ids);
|
||||||
|
},
|
||||||
|
reject: () => {
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<master>
|
||||||
|
<div class="mb-4 grid grid-cols-2a justify-between items-center bg-white border-[1px] border-client-gray/20 py-4 px-4 rounded-2xl">
|
||||||
|
<h2 class="font-bold text-client-Fontcolor">{{ shiftStore.indexPageTitle }}</h2>
|
||||||
|
<div class="grid grid-cols-2a gap-2">
|
||||||
|
<Button
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
:pt="{icon : 'flex'}"
|
||||||
|
:icon="showCalendar ? 'fi fi-rr-list' : 'fi fi-rr-calendar'"
|
||||||
|
:label="showCalendar ? 'نمای جدول' : 'نمای تقویم'"
|
||||||
|
class="!text-sm"
|
||||||
|
@click="showCalendar = !showCalendar"
|
||||||
|
/>
|
||||||
|
<Button severity="secondary" asChild v-slot="slotProps">
|
||||||
|
<Link :class="slotProps.class" :href="shiftStore.endpoints.create">
|
||||||
|
<span class="text-sm">{{ shiftStore.pageTitle }}</span>
|
||||||
|
<i class="fi fi-rr-plus flex text-sm"></i>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="showCalendar" class="card border-[1px] border-client-gray/20 bg-white rounded-2xl">
|
||||||
|
<ShiftCalendar
|
||||||
|
:shifts="shiftStore.calendarShifts"
|
||||||
|
@select-shift="onCalendarSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="card border-[1px] border-client-gray/20 bg-white rounded-2xl">
|
||||||
|
<TableGeneric
|
||||||
|
:data="shifts.data"
|
||||||
|
:columns="columns"
|
||||||
|
:selectable="true"
|
||||||
|
:pagination="shifts.meta"
|
||||||
|
:searchable="true"
|
||||||
|
:searchValue="queryParams.search"
|
||||||
|
:filters="filters"
|
||||||
|
:filterLoader="filterLoader"
|
||||||
|
v-model="selectedItems"
|
||||||
|
@selection-change="onSelectionChange"
|
||||||
|
@page-change="onPageChange"
|
||||||
|
@search-change="onSearchChange"
|
||||||
|
@filter-change="onFilterChange"
|
||||||
|
@apply-filters="applyFilters"
|
||||||
|
@clear-filters="clearFilters"
|
||||||
|
>
|
||||||
|
<template #bulk-action="{ selection }" v-if="!permissions_delete">
|
||||||
|
<Button
|
||||||
|
label="حذف همه"
|
||||||
|
size="small"
|
||||||
|
:pt="{icon : 'flex'}"
|
||||||
|
icon="fi fi-rr-trash"
|
||||||
|
severity="danger"
|
||||||
|
class="!text-xs !flex"
|
||||||
|
outlined
|
||||||
|
@click="() => confirmDeleteSelected(selection.map(shift => shift.id))"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #column-doctor="{ data }">
|
||||||
|
<span v-if="data.doctor" class="text-right text-sm">{{ data.doctor.fullname }}</span>
|
||||||
|
<Tag v-else class="!text-xs" value="آزاد" severity="success" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #column-occupancy="{ data }">
|
||||||
|
<Tag
|
||||||
|
class="!text-xs"
|
||||||
|
:value="data.user_id ? 'اختصاص یافته' : 'آزاد'"
|
||||||
|
:severity="shiftStore.getOccupancySeverity(data.user_id)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #column-status="{ data }">
|
||||||
|
<Tag
|
||||||
|
class="!text-xs"
|
||||||
|
:value="data.is_active ? 'فعال' : 'غیرفعال'"
|
||||||
|
:severity="shiftStore.getStatusSeverity(data.is_active)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #column-actions="{ data }">
|
||||||
|
<div class="grid grid-cols-2a justify-start gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="!permissions_delete"
|
||||||
|
type="button"
|
||||||
|
class="!w-8 !h-8"
|
||||||
|
variant="outlined"
|
||||||
|
severity="danger"
|
||||||
|
icon="fi fi-rr-trash"
|
||||||
|
:pt="{icon : '!mt-2 !text-xs'}"
|
||||||
|
@click="showDeleteConfirm(data)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="!permissions_edit"
|
||||||
|
severity="info"
|
||||||
|
variant="outlined"
|
||||||
|
asChild
|
||||||
|
v-slot="slotProps"
|
||||||
|
>
|
||||||
|
<Link :class="[slotProps.class , '!w-8 !h-8']" :href="shiftStore.endpoints.edit(data.id)">
|
||||||
|
<i class="fi fi-rr-pen-clip flex !text-xs"></i>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</TableGeneric>
|
||||||
|
</div>
|
||||||
|
</master>
|
||||||
|
</template>
|
||||||
@@ -86,6 +86,18 @@ const items: Ref<SidebarItem[]> = ref([
|
|||||||
permission: "reports.index",
|
permission: "reports.index",
|
||||||
expanded: false,
|
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: "مدیریت رسانه",
|
label: "مدیریت رسانه",
|
||||||
icon: "fi fi-rr-gallery",
|
icon: "fi fi-rr-gallery",
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import FullCalendar from '@fullcalendar/vue3';
|
||||||
|
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||||
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
|
import faLocale from '@fullcalendar/core/locales/fa';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import { buildShiftEvent, formatJalaliDay, formatJalaliRange } from '../../../Shared/shiftCalendarEvents';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
shifts: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['select-shift']);
|
||||||
|
|
||||||
|
const doctorPalette = ['#576989', '#800E13', '#50CD89', '#7239EA', '#FFC700', '#F1416C', '#009EF7', '#965457'];
|
||||||
|
|
||||||
|
const calendarRef = ref(null);
|
||||||
|
const viewTitle = ref('');
|
||||||
|
|
||||||
|
const doctors = computed(() => {
|
||||||
|
const unique = new Map();
|
||||||
|
props.shifts.forEach((shift) => {
|
||||||
|
if (shift.doctor) unique.set(shift.doctor.id, shift.doctor);
|
||||||
|
});
|
||||||
|
return [...unique.values()];
|
||||||
|
});
|
||||||
|
|
||||||
|
const doctorColor = (doctorId) => {
|
||||||
|
const index = doctors.value.findIndex((doctor) => doctor.id === doctorId);
|
||||||
|
return doctorPalette[index % doctorPalette.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = computed(() => props.shifts.map((shift) => {
|
||||||
|
const overrides = shift.doctor
|
||||||
|
? {
|
||||||
|
title: shift.doctor.fullname,
|
||||||
|
classNames: ['shift-event--assigned', ...(shift.is_active ? [] : ['shift-event--inactive'])],
|
||||||
|
backgroundColor: doctorColor(shift.doctor.id),
|
||||||
|
borderColor: doctorColor(shift.doctor.id),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: 'شیفت آزاد',
|
||||||
|
classNames: ['shift-event--open', ...(shift.is_active ? [] : ['shift-event--inactive'])],
|
||||||
|
};
|
||||||
|
return buildShiftEvent(shift, overrides);
|
||||||
|
}));
|
||||||
|
|
||||||
|
const calendarOptions = computed(() => ({
|
||||||
|
plugins: [timeGridPlugin, interactionPlugin],
|
||||||
|
initialView: 'timeGridWeek',
|
||||||
|
locale: faLocale,
|
||||||
|
direction: 'rtl',
|
||||||
|
firstDay: 6,
|
||||||
|
headerToolbar: false,
|
||||||
|
allDaySlot: false,
|
||||||
|
height: 'auto',
|
||||||
|
nowIndicator: true,
|
||||||
|
slotLabelFormat: { hour: '2-digit', minute: '2-digit', hour12: false },
|
||||||
|
eventTimeFormat: { hour: '2-digit', minute: '2-digit', hour12: false },
|
||||||
|
dayHeaderContent: (args) => ({
|
||||||
|
html: `${args.text}<span class="shift-calendar-day-date">${formatJalaliDay(args.date)}</span>`
|
||||||
|
}),
|
||||||
|
datesSet: (info) => {
|
||||||
|
viewTitle.value = formatJalaliRange(info.start, info.end);
|
||||||
|
},
|
||||||
|
events: events.value,
|
||||||
|
eventClick: (info) => {
|
||||||
|
emit('select-shift', info.event.extendedProps.shift);
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const goPrev = () => calendarRef.value?.getApi().prev();
|
||||||
|
const goNext = () => calendarRef.value?.getApi().next();
|
||||||
|
const goToday = () => calendarRef.value?.getApi().today();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="shift-calendar p-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 pb-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button label="امروز" size="small" outlined severity="secondary" class="!text-xs" @click="goToday" />
|
||||||
|
<Button icon="fi fi-rr-angle-small-right" size="small" outlined severity="secondary" class="!w-8 !h-8" :pt="{icon : 'flex !text-xs'}" @click="goPrev" />
|
||||||
|
<Button icon="fi fi-rr-angle-small-left" size="small" outlined severity="secondary" class="!w-8 !h-8" :pt="{icon : 'flex !text-xs'}" @click="goNext" />
|
||||||
|
<span class="text-sm font-bold text-client-Fontcolor">{{ viewTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<ul class="flex flex-wrap items-center gap-4">
|
||||||
|
<li v-for="doctor in doctors" :key="doctor.id" class="flex items-center gap-1.5">
|
||||||
|
<span class="w-3 h-3 rounded" :style="{ background: doctorColor(doctor.id) }"></span>
|
||||||
|
<span class="text-xs text-client-Fontcolor">{{ doctor.fullname }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-1.5">
|
||||||
|
<span class="w-3 h-3 rounded border border-dashed border-[#7188AD] bg-[#F6F7F9]"></span>
|
||||||
|
<span class="text-xs text-client-Fontcolor">شیفت آزاد</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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 [];
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,11 +1,98 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import ClientLayout from '../../../layouts/ClientLayout.vue'
|
import { computed, ref } from 'vue';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import { toast } from 'vue3-toastify';
|
||||||
|
import ClientLayout from '../../../layouts/ClientLayout.vue';
|
||||||
|
import UiCard from '../../../components/ui/UiCard.vue';
|
||||||
|
import UiButton from '../../../components/ui/UiButton.vue';
|
||||||
|
import ShiftCalendar from '../../../components/dashboard/shifts/ShiftCalendar.vue';
|
||||||
|
import { useShiftStore } from '../../../store/Shift';
|
||||||
|
import { convertEnglishToPersian } from '../../../utils/numberUtils';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
myShifts: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
openShifts: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const shiftStore = useShiftStore();
|
||||||
|
|
||||||
|
const selectedShift = ref(null);
|
||||||
|
const confirmVisible = ref(false);
|
||||||
|
|
||||||
|
const isMine = computed(() => {
|
||||||
|
return !!selectedShift.value && props.myShifts.data.some((shift) => shift.id === selectedShift.value.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const shiftLabel = computed(() => {
|
||||||
|
if (!selectedShift.value) return '';
|
||||||
|
const day = selectedShift.value.date ?? selectedShift.value.day_of_week;
|
||||||
|
const time = `${selectedShift.value.start_time} تا ${selectedShift.value.end_time}`;
|
||||||
|
return convertEnglishToPersian(`${day} - ساعت ${time}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const openConfirm = (shift) => {
|
||||||
|
selectedShift.value = shift;
|
||||||
|
confirmVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
try {
|
||||||
|
if (isMine.value) {
|
||||||
|
await shiftStore.release(selectedShift.value.id);
|
||||||
|
toast.success('شیفت با موفقیت لغو شد.');
|
||||||
|
} else {
|
||||||
|
await shiftStore.claim(selectedShift.value.id);
|
||||||
|
toast.success('شیفت با موفقیت به شما اختصاص یافت.');
|
||||||
|
}
|
||||||
|
confirmVisible.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ClientLayout>
|
<ClientLayout>
|
||||||
<h1 class="text-2xl font-bold">
|
<UiCard title="برنامه شیفتهای من" variant="default">
|
||||||
Shifts
|
<ShiftCalendar
|
||||||
</h1>
|
:my-shifts="myShifts.data"
|
||||||
|
:open-shifts="openShifts.data"
|
||||||
|
@select-shift="openConfirm"
|
||||||
|
/>
|
||||||
|
</UiCard>
|
||||||
|
|
||||||
|
<Dialog v-model:visible="confirmVisible" :modal="true" :closable="true" :dismissableMask="true" :style="{ width: '400px' }" class="fast-request-dialog">
|
||||||
|
<template #header>
|
||||||
|
<div class="w-full flex items-center justify-start">
|
||||||
|
<h2 class="text-[14px] font-[700] text-[#576989]">{{ isMine ? 'لغو شیفت' : 'انتخاب شیفت' }}</h2>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="w-full py-4">
|
||||||
|
<p class="text-[12px] font-[600] text-[#3E4C63]">
|
||||||
|
{{ isMine ? 'آیا از لغو این شیفت اطمینان دارید؟' : 'آیا این شیفت به برنامه شما اضافه شود؟' }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-[11px] font-[600] text-[#7188AD] bg-[#F6F7F9] border border-[#DEE3EB] rounded-lg p-3">
|
||||||
|
{{ shiftLabel }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div class="w-full flex items-center justify-end gap-2">
|
||||||
|
<UiButton label="انصراف" size="small" outlined @click="confirmVisible = false" />
|
||||||
|
<UiButton
|
||||||
|
:label="isMine ? 'لغو شیفت' : 'انتخاب شیفت'"
|
||||||
|
size="small"
|
||||||
|
:severity="isMine ? 'danger' : 'success'"
|
||||||
|
:loading="shiftStore.processingId === selectedShift?.id"
|
||||||
|
@click="handleConfirm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
</ClientLayout>
|
</ClientLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import UiButton from '../../ui/UiButton.vue';
|
||||||
|
import { modalityTitle } from '../../../data/fastRequestOptions';
|
||||||
|
|
||||||
|
const visible = defineModel('visible');
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
document: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedAmount = computed(() => {
|
||||||
|
return Number(props.document?.invoice?.amount || 0).toLocaleString('fa-IR');
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePay = () => {
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:visible="visible" :modal="true" :closable="true" :dismissableMask="false" :maximizable="false" :style="{ width: '1032px' }">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[14px] font-[700] text-[#576989]">فاکتور پرداخت «{{ document?.invoice?.number }}»</h2>
|
||||||
|
</template>
|
||||||
|
<div v-if="document" class="w-full flex flex-col gap-4 border-t border-[#EBEBEB] pt-4">
|
||||||
|
<div class="w-full flex items-center gap-3 bg-[#EEF2F8] border border-[#DEE3EB] rounded-[12px] px-4 py-4">
|
||||||
|
<i class="fi-rr-info text-[20px] text-[#576989]"></i>
|
||||||
|
<p class="text-[13px] font-[400] text-[#3E4C63]">
|
||||||
|
یک فاکتور به مبلغ « <span class="font-[700]">{{ formattedAmount }} تومان</span> » برای شما ایجاد شد. لطفا جهت ادامه مراحل درخواست نسبت به پرداخت اقدام نمایید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-[13px] font-[400] text-[#3E4C63] leading-7">
|
||||||
|
این فاکتور جهت پردازش درخواست
|
||||||
|
<span class="font-[600] text-[#7A1419]">(({{ modalityTitle(document.modality) }}))</span>
|
||||||
|
به شناسه
|
||||||
|
<span class="font-[600] text-[#7A1419]">(({{ document.code }}))</span>
|
||||||
|
برای شما ایجاد شده است. پس از پرداخت ادامه مراحل درخواست شما پردازش و انجام خواهد شد.
|
||||||
|
</p>
|
||||||
|
<p class="text-[13px] font-[400] text-[#3E4C63] leading-7">
|
||||||
|
درخواست شما پس از پرداخت توسط کارشناسان بررسی میگردد و در صورت نیاز به مدارک و یا توضیحات بیشتر با شما در ارتباط خواهند بود. پس از تایید اولیه کارشناسان ما مبلغ نهایی برای شما فاکتور میگردد. تمامی مراحل با
|
||||||
|
<span class="font-[600] text-[#7A1419]">((پیامک))</span>
|
||||||
|
به اطلاع شما خواهد رسید.
|
||||||
|
<br>
|
||||||
|
بدیهیست که در صورت عدم تایید کارشناسان مبلغ مربوطه به کیف پول شما بازگردانده خواهد شد.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-full pt-3 flex items-center justify-start border-t border-[#EBEBEB]">
|
||||||
|
<ui-button label="تایید و پرداخت" severity="success" size="small" @click="handlePay" />
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import FullCalendar from '@fullcalendar/vue3';
|
||||||
|
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||||
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
|
import faLocale from '@fullcalendar/core/locales/fa';
|
||||||
|
import UiButton from '../../ui/UiButton.vue';
|
||||||
|
import { buildShiftEvent, formatJalaliDay, formatJalaliRange } from '../../../../Shared/shiftCalendarEvents';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
myShifts: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
openShifts: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['select-shift']);
|
||||||
|
|
||||||
|
const calendarRef = ref(null);
|
||||||
|
const viewTitle = ref('');
|
||||||
|
|
||||||
|
const events = computed(() => [
|
||||||
|
...props.myShifts.map((shift) => buildShiftEvent(shift, {
|
||||||
|
title: 'شیفت من',
|
||||||
|
classNames: [shift.is_active ? 'shift-event--mine' : 'shift-event--mine-inactive'],
|
||||||
|
})),
|
||||||
|
...props.openShifts.map((shift) => buildShiftEvent(shift, {
|
||||||
|
title: 'شیفت آزاد',
|
||||||
|
classNames: ['shift-event--open'],
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
|
||||||
|
const calendarOptions = computed(() => ({
|
||||||
|
plugins: [timeGridPlugin, interactionPlugin],
|
||||||
|
initialView: 'timeGridWeek',
|
||||||
|
locale: faLocale,
|
||||||
|
direction: 'rtl',
|
||||||
|
firstDay: 6,
|
||||||
|
headerToolbar: false,
|
||||||
|
allDaySlot: false,
|
||||||
|
height: 'auto',
|
||||||
|
nowIndicator: true,
|
||||||
|
slotLabelFormat: { hour: '2-digit', minute: '2-digit', hour12: false },
|
||||||
|
eventTimeFormat: { hour: '2-digit', minute: '2-digit', hour12: false },
|
||||||
|
dayHeaderContent: (args) => ({
|
||||||
|
html: `${args.text}<span class="shift-calendar-day-date">${formatJalaliDay(args.date)}</span>`
|
||||||
|
}),
|
||||||
|
datesSet: (info) => {
|
||||||
|
viewTitle.value = formatJalaliRange(info.start, info.end);
|
||||||
|
},
|
||||||
|
events: events.value,
|
||||||
|
eventClick: (info) => {
|
||||||
|
emit('select-shift', info.event.extendedProps.shift);
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const goPrev = () => calendarRef.value?.getApi().prev();
|
||||||
|
const goNext = () => calendarRef.value?.getApi().next();
|
||||||
|
const goToday = () => calendarRef.value?.getApi().today();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="shift-calendar">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 pb-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UiButton label="امروز" size="small" outlined @click="goToday" />
|
||||||
|
<button type="button" class="w-8 h-8 flex items-center justify-center rounded-lg border border-[#DEE3EB] text-[#576989] cursor-pointer" @click="goPrev">
|
||||||
|
<i class="fi fi-rr-angle-small-right flex text-sm"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="w-8 h-8 flex items-center justify-center rounded-lg border border-[#DEE3EB] text-[#576989] cursor-pointer" @click="goNext">
|
||||||
|
<i class="fi fi-rr-angle-small-left flex text-sm"></i>
|
||||||
|
</button>
|
||||||
|
<span class="text-[12px] font-[600] text-[#3E4C63]">{{ viewTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<ul class="flex items-center gap-4">
|
||||||
|
<li class="flex items-center gap-1.5">
|
||||||
|
<span class="w-3 h-3 rounded bg-[#576989]"></span>
|
||||||
|
<span class="text-[11px] font-[600] text-[#3E4C63]">شیفت من</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-1.5">
|
||||||
|
<span class="w-3 h-3 rounded border border-dashed border-[#7188AD] bg-[#F6F7F9]"></span>
|
||||||
|
<span class="text-[11px] font-[600] text-[#3E4C63]">شیفت آزاد</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-1.5">
|
||||||
|
<span class="w-3 h-3 rounded bg-[#C8D2E2]"></span>
|
||||||
|
<span class="text-[11px] font-[600] text-[#3E4C63]">غیرفعال</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -18,6 +18,7 @@ interface SidebarItem extends MenuItem {
|
|||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
items?: SidebarItem[];
|
items?: SidebarItem[];
|
||||||
permission?: string | null;
|
permission?: string | null;
|
||||||
|
level?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const globalStore = useGlobalStore();
|
const globalStore = useGlobalStore();
|
||||||
@@ -31,9 +32,14 @@ function hasPermission(permission) {
|
|||||||
return userPermissions.value.includes(permission);
|
return userPermissions.value.includes(permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasLevel(level) {
|
||||||
|
if (!level) return true;
|
||||||
|
return currentUser.value?.level === level;
|
||||||
|
}
|
||||||
|
|
||||||
function filterSidebarItems(items) {
|
function filterSidebarItems(items) {
|
||||||
return items
|
return items
|
||||||
.filter(item => hasPermission(item.permission))
|
.filter(item => hasPermission(item.permission) && hasLevel(item.level))
|
||||||
.map(item => {
|
.map(item => {
|
||||||
if (item.items) {
|
if (item.items) {
|
||||||
const filteredChildren = filterSidebarItems(item.items);
|
const filteredChildren = filterSidebarItems(item.items);
|
||||||
@@ -87,6 +93,7 @@ const items: Ref<SidebarItem[]> = ref([
|
|||||||
to: "/dashboard/shifts",
|
to: "/dashboard/shifts",
|
||||||
key: 'shifts',
|
key: 'shifts',
|
||||||
permission: null,
|
permission: null,
|
||||||
|
level: "پزشک",
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// label: "خروج از سامانه",
|
// label: "خروج از سامانه",
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ onMounted(() => {
|
|||||||
<Transition
|
<Transition
|
||||||
name="page-transition"
|
name="page-transition"
|
||||||
mode="out-in"
|
mode="out-in"
|
||||||
appear
|
|
||||||
>
|
>
|
||||||
<section class="grid grid-cols-1">
|
<section class="grid grid-cols-1">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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)}`
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ use App\Http\Controllers\Admin\UserController;
|
|||||||
use App\Http\Controllers\Admin\DocumentController;
|
use App\Http\Controllers\Admin\DocumentController;
|
||||||
use App\Http\Controllers\Admin\ReportController;
|
use App\Http\Controllers\Admin\ReportController;
|
||||||
use App\Http\Controllers\Admin\PetController;
|
use App\Http\Controllers\Admin\PetController;
|
||||||
|
use App\Http\Controllers\Admin\ShiftController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::group(['middleware' => ['auth', 'admin.access']], function () {
|
Route::group(['middleware' => ['auth', 'admin.access']], function () {
|
||||||
@@ -24,6 +25,11 @@ Route::group(['middleware' => ['auth', 'admin.access']], function () {
|
|||||||
//Reports' routes
|
//Reports' routes
|
||||||
Route::resource('/reports', ReportController::class);
|
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
|
//Pets' routes
|
||||||
Route::resource('/pets', PetController::class);
|
Route::resource('/pets', PetController::class);
|
||||||
Route::post('/pets-bulk-delete', [PetController::class , 'bulkDelete']);
|
Route::post('/pets-bulk-delete', [PetController::class , 'bulkDelete']);
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ Route::group(['prefix' => 'dashboard' , 'middleware' => 'auth' , 'as' => 'dashbo
|
|||||||
Route::resource('cases', CaseController::class);
|
Route::resource('cases', CaseController::class);
|
||||||
|
|
||||||
Route::resource('reports', ReportController::class);
|
Route::resource('reports', ReportController::class);
|
||||||
Route::resource('shifts', ShiftController::class);
|
Route::resource('shifts', ShiftController::class)->only(['index', 'update', 'destroy']);
|
||||||
|
|
||||||
// Auth Logout
|
// Auth Logout
|
||||||
Route::get('logout', [AuthController::class, 'logout'])->name('logout');
|
Route::get('logout', [AuthController::class, 'logout'])->name('logout');
|
||||||
|
|||||||
Reference in New Issue
Block a user