feat: enhance FastRequest functionality with shift scheduling and improve payment callback handling
This commit is contained in:
@@ -2,32 +2,32 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Client;
|
namespace App\Http\Controllers\Client;
|
||||||
|
|
||||||
use App\Enums\UserEnum;
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Client\FastRequestRequest;
|
use App\Http\Requests\Client\FastRequestRequest;
|
||||||
use App\Http\Resources\Client\DoctorResource;
|
use App\Http\Resources\Client\DoctorResource;
|
||||||
use App\Http\Resources\Client\FastRequestResource;
|
use App\Http\Resources\Client\FastRequestResource;
|
||||||
use App\Http\Resources\Client\PetArchiveResource;
|
use App\Http\Resources\Client\PetArchiveResource;
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Document\FastRequestService;
|
use App\Services\Document\FastRequestService;
|
||||||
|
use App\Services\Shift\ShiftScheduleService;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
class FastRequestController extends Controller
|
class FastRequestController extends Controller
|
||||||
{
|
{
|
||||||
protected FastRequestService $fastRequestService;
|
protected FastRequestService $fastRequestService;
|
||||||
|
|
||||||
public function __construct(FastRequestService $fastRequestService)
|
protected ShiftScheduleService $shiftScheduleService;
|
||||||
|
|
||||||
|
public function __construct(FastRequestService $fastRequestService, ShiftScheduleService $shiftScheduleService)
|
||||||
{
|
{
|
||||||
$this->fastRequestService = $fastRequestService;
|
$this->fastRequestService = $fastRequestService;
|
||||||
|
$this->shiftScheduleService = $shiftScheduleService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
$pets = Auth::user()->pets()->latest()->get();
|
$pets = Auth::user()->pets()->latest()->get();
|
||||||
|
|
||||||
$doctors = User::where('level', UserEnum::Doctor->value)
|
$doctors = $this->shiftScheduleService->availableDoctorsAt(now());
|
||||||
->latest()
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'pets' => PetArchiveResource::collection($pets),
|
'pets' => PetArchiveResource::collection($pets),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use App\Services\Payment\ZibalPaymentService;
|
|||||||
use App\Services\Wallet\WalletService;
|
use App\Services\Wallet\WalletService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class PaymentController extends Controller
|
class PaymentController extends Controller
|
||||||
{
|
{
|
||||||
@@ -68,7 +69,7 @@ class PaymentController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($wasPending && $payment->status === PaymentStatusEnum::Paid) {
|
if ($wasPending && $payment->status === PaymentStatusEnum::Paid) {
|
||||||
$this->settle($payment);
|
DB::transaction(fn () => $this->settle($payment));
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->flash('payment_result', [
|
session()->flash('payment_result', [
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Enums\DocumentModalityEnum;
|
|||||||
use App\Enums\DocumentPriorityEnum;
|
use App\Enums\DocumentPriorityEnum;
|
||||||
use App\Enums\PetGenderEnum;
|
use App\Enums\PetGenderEnum;
|
||||||
use App\Enums\PetTypeEnum;
|
use App\Enums\PetTypeEnum;
|
||||||
|
use App\Enums\UserEnum;
|
||||||
|
|
||||||
class FastRequestRequest extends FormRequest
|
class FastRequestRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -22,7 +23,7 @@ class FastRequestRequest extends FormRequest
|
|||||||
'modality' => ['required', 'string', Rule::in(DocumentModalityEnum::values())],
|
'modality' => ['required', 'string', Rule::in(DocumentModalityEnum::values())],
|
||||||
'priority' => ['required', 'string', Rule::in(DocumentPriorityEnum::keys())],
|
'priority' => ['required', 'string', Rule::in(DocumentPriorityEnum::keys())],
|
||||||
'note' => ['nullable', 'string', 'max:1000'],
|
'note' => ['nullable', 'string', 'max:1000'],
|
||||||
'pet_id' => ['nullable', 'exists:pets,id'],
|
'pet_id' => ['nullable', Rule::exists('pets', 'id')->where('owner_id', $this->user()->id)],
|
||||||
'pet' => ['required_without:pet_id', 'array'],
|
'pet' => ['required_without:pet_id', 'array'],
|
||||||
'pet.name' => ['required_without:pet_id', 'nullable', 'string', 'max:255'],
|
'pet.name' => ['required_without:pet_id', 'nullable', 'string', 'max:255'],
|
||||||
'pet.type' => ['required_without:pet_id', 'nullable', 'string', Rule::in(PetTypeEnum::keys())],
|
'pet.type' => ['required_without:pet_id', 'nullable', 'string', Rule::in(PetTypeEnum::keys())],
|
||||||
@@ -31,7 +32,7 @@ class FastRequestRequest extends FormRequest
|
|||||||
'pet.age' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
'pet.age' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||||
'pet.weight' => ['nullable', 'numeric', 'min:0', 'max:1000'],
|
'pet.weight' => ['nullable', 'numeric', 'min:0', 'max:1000'],
|
||||||
'pet.sterilized' => ['nullable', 'boolean'],
|
'pet.sterilized' => ['nullable', 'boolean'],
|
||||||
'doctor_id' => ['nullable', 'exists:users,id'],
|
'doctor_id' => ['nullable', Rule::exists('users', 'id')->where('level', UserEnum::Doctor->value)],
|
||||||
'history_files' => ['nullable', 'array'],
|
'history_files' => ['nullable', 'array'],
|
||||||
'history_files.*' => ['file', 'image', 'max:10240'],
|
'history_files.*' => ['file', 'image', 'max:10240'],
|
||||||
'scan_files' => ['nullable', 'array'],
|
'scan_files' => ['nullable', 'array'],
|
||||||
|
|||||||
@@ -156,12 +156,13 @@ class CaseService
|
|||||||
->where('invoices.paid_at', '>=', $monthStart);
|
->where('invoices.paid_at', '>=', $monthStart);
|
||||||
|
|
||||||
$nextShift = $isDoctor ? $this->shiftScheduleService->nextShiftStart($user, now()) : null;
|
$nextShift = $isDoctor ? $this->shiftScheduleService->nextShiftStart($user, now()) : null;
|
||||||
|
$onShift = $isDoctor && $this->shiftScheduleService->isOnShift($user, now());
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'pending_cases' => $this->pendingForUser($user)->count(),
|
'pending_cases' => $this->pendingForUser($user)->count(),
|
||||||
'monthly_reviewed' => $reviewedQuery->count(),
|
'monthly_reviewed' => $reviewedQuery->count(),
|
||||||
'monthly_amount' => (int) $amountQuery->sum('invoices.amount'),
|
'monthly_amount' => (int) $amountQuery->sum('invoices.amount'),
|
||||||
'next_shift_hours' => $nextShift ? (int) ceil(now()->diffInMinutes($nextShift, true) / 60) : null,
|
'next_shift_hours' => $onShift ? 0 : ($nextShift ? (int) ceil(now()->diffInMinutes($nextShift, true) / 60) : null),
|
||||||
'is_doctor' => $isDoctor,
|
'is_doctor' => $isDoctor,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class FastRequestService
|
|||||||
protected function resolvePet(User $requester, array $data): Pet
|
protected function resolvePet(User $requester, array $data): Pet
|
||||||
{
|
{
|
||||||
if (!empty($data['pet_id'])) {
|
if (!empty($data['pet_id'])) {
|
||||||
return Pet::findOrFail($data['pet_id']);
|
return $requester->pets()->findOrFail($data['pet_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$pet = $data['pet'];
|
$pet = $data['pet'];
|
||||||
|
|||||||
@@ -60,12 +60,17 @@ class ShiftScheduleService
|
|||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isOnShift(User $doctor, Carbon $moment): bool
|
||||||
|
{
|
||||||
|
return $this->shiftsCoveringMoment($moment)->contains('user_id', $doctor->id);
|
||||||
|
}
|
||||||
|
|
||||||
public function autoSelectDoctor(Carbon $moment): ?User
|
public function autoSelectDoctor(Carbon $moment): ?User
|
||||||
{
|
{
|
||||||
$doctorIds = $this->shiftsCoveringMoment($moment)->pluck('user_id')->filter()->unique();
|
$doctorIds = $this->shiftsCoveringMoment($moment)->pluck('user_id')->filter()->unique();
|
||||||
|
|
||||||
if ($doctorIds->isEmpty()) {
|
if ($doctorIds->isEmpty()) {
|
||||||
return null;
|
return $this->doctorWithNearestShift($moment);
|
||||||
}
|
}
|
||||||
|
|
||||||
return User::where('level', UserEnum::Doctor->value)
|
return User::where('level', UserEnum::Doctor->value)
|
||||||
@@ -81,6 +86,20 @@ class ShiftScheduleService
|
|||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fallback when nobody is on shift: the doctor whose next shift starts soonest
|
||||||
|
public function doctorWithNearestShift(Carbon $from): ?User
|
||||||
|
{
|
||||||
|
$doctorIds = Shift::active()->whereNotNull('user_id')->distinct()->pluck('user_id');
|
||||||
|
|
||||||
|
return User::where('level', UserEnum::Doctor->value)
|
||||||
|
->whereIn('id', $doctorIds)
|
||||||
|
->get()
|
||||||
|
->map(fn (User $doctor) => ['doctor' => $doctor, 'start' => $this->nextShiftStart($doctor, $from)])
|
||||||
|
->filter(fn (array $item) => $item['start'] !== null)
|
||||||
|
->sortBy('start')
|
||||||
|
->first()['doctor'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
public function nextShiftStart(User $doctor, Carbon $from): ?Carbon
|
public function nextShiftStart(User $doctor, Carbon $from): ?Carbon
|
||||||
{
|
{
|
||||||
$shifts = Shift::active()->where('user_id', $doctor->id)->get();
|
$shifts = Shift::active()->where('user_id', $doctor->id)->get();
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ import RequestDoctor from './fastRequest/RequestDoctor.vue';
|
|||||||
import RequestReview from './fastRequest/RequestReview.vue';
|
import RequestReview from './fastRequest/RequestReview.vue';
|
||||||
import InvoiceModal from './fastRequest/InvoiceModal.vue';
|
import InvoiceModal from './fastRequest/InvoiceModal.vue';
|
||||||
import { useFastRequestStore } from '../../store/FastRequest';
|
import { useFastRequestStore } from '../../store/FastRequest';
|
||||||
|
import { useAnimatedHeight } from '../../composables/useAnimatedHeight';
|
||||||
|
|
||||||
const visible = defineModel('visible');
|
const visible = defineModel('visible');
|
||||||
const fastRequestStore = useFastRequestStore();
|
const fastRequestStore = useFastRequestStore();
|
||||||
|
|
||||||
|
const stepContentRef = ref(null);
|
||||||
|
const stepContentHeight = useAnimatedHeight(stepContentRef);
|
||||||
|
|
||||||
const invoiceVisible = ref(false);
|
const invoiceVisible = ref(false);
|
||||||
const invoiceDocument = ref(null);
|
const invoiceDocument = ref(null);
|
||||||
|
|
||||||
@@ -83,7 +87,11 @@ const handleNext = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<component :is="currentStepComponent" />
|
<div class="overflow-hidden transition-[height] duration-300 ease-in-out" :style="stepContentHeight !== null ? { height: `${stepContentHeight}px` } : null">
|
||||||
|
<div ref="stepContentRef">
|
||||||
|
<component :is="currentStepComponent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="w-full pt-3 flex items-center justify-between border-t border-[#EBEBEB]">
|
<div class="w-full pt-3 flex items-center justify-between border-t border-[#EBEBEB]">
|
||||||
<ui-button v-if="fastRequestStore.step > 1" label="مرحله قبل" severity="secondary" size="small" @click="fastRequestStore.prevStep" />
|
<ui-button v-if="fastRequestStore.step > 1" label="مرحله قبل" severity="secondary" size="small" @click="fastRequestStore.prevStep" />
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const selectDoctor = (doctorId) => {
|
|||||||
<h3 class="text-[13px] font-[600] text-[#576989]">انتخاب خودکار</h3>
|
<h3 class="text-[13px] font-[600] text-[#576989]">انتخاب خودکار</h3>
|
||||||
<p class="max-w-[190px] px-2 text-center text-[11px] font-[600] text-[#3E4C63]">انتخاب در دسترس ترین پزشک جهت بررسی سریع درخواست شما</p>
|
<p class="max-w-[190px] px-2 text-center text-[11px] font-[600] text-[#3E4C63]">انتخاب در دسترس ترین پزشک جهت بررسی سریع درخواست شما</p>
|
||||||
</button>
|
</button>
|
||||||
|
<p v-if="!fastRequestStore.doctors.length" class="sm:col-span-1 md:col-span-2 text-[11px] font-[600] text-[#7188AD] leading-6">
|
||||||
|
در حال حاضر پزشکی در شیفت نیست؛ درخواست شما به پزشک نزدیکترین شیفت آینده ارجاع داده میشود.
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
v-for="doctor in fastRequestStore.doctors"
|
v-for="doctor in fastRequestStore.doctors"
|
||||||
:key="doctor.id"
|
:key="doctor.id"
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const menuItems = [
|
|||||||
customClass="placeholder:!text-dominant-300 !border-dominant-50 !font-normal !text-[11px] truncate"
|
customClass="placeholder:!text-dominant-300 !border-dominant-50 !font-normal !text-[11px] truncate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 sm:gap-4 shrink-0 me-auto">
|
<div class="flex items-center gap-2 sm:gap-4 shrink-0 ms-auto">
|
||||||
<Link href="/dashboard/wallet" class="flex items-center gap-2.5 rounded-lg bg-dominant-light border border-dominant-50 ps-1.5 pe-3 py-1.5 transition-colors hover:border-dominant-100">
|
<Link href="/dashboard/wallet" class="flex items-center gap-2.5 rounded-lg bg-dominant-light border border-dominant-50 ps-1.5 pe-3 py-1.5 transition-colors hover:border-dominant-100">
|
||||||
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-white border border-dominant-50 text-dominant-400">
|
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-white border border-dominant-50 text-dominant-400">
|
||||||
<i class="fi fi-rr-wallet flex text-[15px]"></i>
|
<i class="fi fi-rr-wallet flex text-[15px]"></i>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
export function useAnimatedHeight(contentRef) {
|
||||||
|
const height = ref(null)
|
||||||
|
let observer = null
|
||||||
|
|
||||||
|
watch(contentRef, (element) => {
|
||||||
|
observer?.disconnect()
|
||||||
|
observer = null
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
height.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
observer = new ResizeObserver(([entry]) => {
|
||||||
|
height.value = entry.contentRect.height
|
||||||
|
})
|
||||||
|
observer.observe(element)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => observer?.disconnect())
|
||||||
|
|
||||||
|
return height
|
||||||
|
}
|
||||||
+2
-1
@@ -49,6 +49,8 @@ Route::group(['prefix' => 'auth','middleware' => 'guest'] , function (){
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::get('dashboard/payments/callback', [PaymentController::class, 'callback'])->name('dashboard.payments.callback');
|
||||||
|
|
||||||
Route::group(['prefix' => 'dashboard' , 'middleware' => 'auth' , 'as' => 'dashboard.'] , function (){
|
Route::group(['prefix' => 'dashboard' , 'middleware' => 'auth' , 'as' => 'dashboard.'] , function (){
|
||||||
Route::get('/', [DashboardController::class, 'index'])->name('index');
|
Route::get('/', [DashboardController::class, 'index'])->name('index');
|
||||||
|
|
||||||
@@ -58,7 +60,6 @@ Route::group(['prefix' => 'dashboard' , 'middleware' => 'auth' , 'as' => 'dashbo
|
|||||||
Route::post('profile', [ProfileController::class, 'update'])->name('profile.update');
|
Route::post('profile', [ProfileController::class, 'update'])->name('profile.update');
|
||||||
|
|
||||||
Route::post('payments', [PaymentController::class, 'store'])->name('payments.store');
|
Route::post('payments', [PaymentController::class, 'store'])->name('payments.store');
|
||||||
Route::get('payments/callback', [PaymentController::class, 'callback'])->name('payments.callback');
|
|
||||||
|
|
||||||
Route::get('wallet', [WalletController::class, 'index'])->name('wallet.index');
|
Route::get('wallet', [WalletController::class, 'index'])->name('wallet.index');
|
||||||
Route::post('wallet/deposit', [WalletController::class, 'deposit'])->name('wallet.deposit');
|
Route::post('wallet/deposit', [WalletController::class, 'deposit'])->name('wallet.deposit');
|
||||||
|
|||||||
Reference in New Issue
Block a user