filled('rentable')) { $this->rentable = Rentable::find(request('rentable')); $locationId = $this->rentable->location_id; } $this->form->fill([ 'rentable' => request('rentable', null), 'location' => $locationId != null ? $locationId : auth()->user()->currentTeam->location_id, 'name' => auth()->user()->currentTeam->name, ]); /** @phpstan-ignore-next-line */ $this->currentTeam = auth()->user()->currentTeam; } public function form(Form $form): Form { return $form ->schema([ Forms\Components\Split::make([ Forms\Components\Section::make([ Forms\Components\Select::make('location') ->required() ->options(Location::pluck('name', 'id')) ->afterStateUpdated(function (Set $set) { $this->rentable = null; $set('rentable', null); $this->startOptions = []; $set('date', now()->toDateString()); }) ->live(), Forms\Components\Select::make('rentable') ->label('Room') ->required() ->options(function (Get $get): Collection { return $this->getRentables($get('location')) ->pluck('name', 'id'); }) ->searchable() ->hint(new HtmlString(Blade::render(''))) ->afterStateUpdated(function (Forms\Components\Select $component, $state, Set $set) { if ($state) { $this->rentable = Rentable::find($state); $set('date', null); return $component ->getContainer() ->getComponent('rentableDetails') ->getChildComponentContainer() ->fill(); } else { $this->rentable = null; $this->startOptions = []; $set('date', now()->toDateString()); } }) ->live(onBlur: true), Forms\Components\Grid::make(3) ->schema(function (Get $get): array { return [ Forms\Components\Placeholder::make('price_for_hour') ->hiddenLabel() ->content(new HtmlString(''.Number::currency($this->rentable->rate ?? 0).' ('.Number::currency($this->rentable->member_rate ?? 0).' for members) / '.Mill::fromMinutesReadable($this->rentable?->billing_period).'')) ->columnSpanFull(), Forms\Components\Placeholder::make('amenities') ->content(view('filament.pages.reservation.amenities-icons', ['amenities' => $this->rentable?->amenities])) ->hiddenLabel() ->columnSpanFull() ->visible(fn () => $this->rentable?->amenities?->isNotEmpty()), Forms\Components\Placeholder::make('hours') ->content($this->rentable?->open_readable.' - '.$this->rentable?->close_readable), Forms\Components\Placeholder::make('minimum_reservation') ->content(Mill::fromMinutesReadable($this->rentable?->minimum_length)), Forms\Components\Placeholder::make('interval') ->content(Mill::fromMinutesReadable($this->rentable?->interval)) ->visible(fn () => $this->rentable?->interval), Forms\Components\Placeholder::make('capacity') ->content($this->rentable?->capacity) ->visible(fn () => $this->rentable?->capacity), Forms\Components\Placeholder::make('description') ->content($this->rentable?->description) ->hiddenLabel() ->columnSpanFull(), Forms\Components\Placeholder::make('images') ->content(view('filament.pages.reservation.images', ['images' => $this->rentable?->getImages()])) ->hiddenLabel() ->columnSpanFull() ->visible(fn () => $this->rentable?->getImages()), ]; }) ->key('rentableDetails') ->visible(fn (Get $get) => $get('rentable')), ]), Forms\Components\Section::make([ Forms\Components\Placeholder::make('subtotal') ->hiddenLabel() ->content('Select a room first to show the available dates here') ->visible(fn (Get $get) => ! $get('rentable')), InlineDateTimePicker::make('date') ->default(today()) ->minDate(today()) ->date(true) ->time(false) ->hint(new HtmlString(Blade::render(''))) ->required() ->label('Reservation date') ->locale('en-US') ->firstDayOfWeek(0) ->afterStateHydrated(function (InlineDateTimePicker $component, ?string $state) { if (! $state) { $component->state(now()->toDateString()); } }) ->format('Y-m-d') ->live(onBlur: true) ->afterStateUpdated(function (InlineDateTimePicker $component, $state) { return $component ->getContainer() ->getComponent('reservationDetails') ->getChildComponentContainer() ->fill(); }) ->visible(fn (Get $get) => $get('rentable')), Forms\Components\Grid::make(2) ->schema(function (Get $get): array { return [ Forms\Components\Select::make('start') ->options(function (Get $get, $state) { return $this->getStartOptions($get('date'), $state, $get('end')); }) ->live(onBlur: true) ->afterStateUpdated(function (Set $set, $state) { if (! $state) { $set('end', null); } }) ->searchable() ->getOptionLabelUsing(function ($value) { return Carbon::parse($value)->format('h:i a'); }) ->required(), Forms\Components\Select::make('end') ->options(fn (Get $get): array => $get('start') ? $this->getEndOptions() : []) ->live() ->searchable() ->getOptionLabelUsing(function ($value) { return Carbon::parse($value)->format('h:i a'); }) ->afterStateUpdated(function (Set $set, Get $get) { $subtotal = $this->getSubtotal($get('start'), $get('end')); $credits = auth()->user()->currentTeam->credit > $subtotal ? $subtotal : auth()->user()->currentTeam->credit; $set('credit', Number::format($credits, precision: 2, locale: 'en')); }) ->required(), Forms\Components\Group::make() ->schema([ Forms\Components\TextInput::make('name') ->default(fn (Get $get) => auth()->user()->currentTeam->name) ->required(), Forms\Components\Placeholder::make('subtotal') ->hiddenLabel() ->content(function (Get $get) { return new HtmlString('Subtotal '.Number::currency($this->getSubtotal($get('start'), $get('end'))).''); }), ]) ->columnSpanFull(), Forms\Components\Select::make('selectedPayment') ->options(Team::retrievePaymentMethods(auth()->user())->pluck('payment_method_label', 'id')) ->required(function (Get $get) { $subtotal = $this->getSubtotal($get('start'), $get('end')); if ($this->currentTeam->credit >= $subtotal) { return false; } return true; }), Forms\Components\TextInput::make('credit') ->numeric() ->live() ->label(function () { return 'Applied Credit ('.$this->currentTeam->credit.' available)'; }), Forms\Components\Placeholder::make('total') ->hiddenLabel() ->columnSpanFull() ->content(function (Get $get) { $subtotal = $this->getSubtotal($get('start'), $get('end')); $total = $subtotal - (float) $get('credit'); if ($get('credit') > $subtotal) { $total = 0; } return new HtmlString('Total '.Number::currency($total).''); }), Forms\Components\ViewField::make('submit') ->view('filament.pages.reservation.submit-button') ->columnSpanFull(), ]; }) ->key('reservationDetails') ->visible(fn (Get $get) => $get('rentable') && $get('date')), ]), // ->visible(fn (Get $get) => $get('rentable')), ]), /* // TODO: use Alpine for availability bar template Forms\Components\Placeholder::make('availabilities_bar') ->content(function (Get $get) { return view('filament.pages.reservation.availabilities', [ 'rentable' => $this->rentable, 'availabilities' => $this->getAvailabilities($get('date'), $get('start'), $get('end')), 'date' => $get('date'), 'rentables' => $this->getRentables($get('location'))->get(), 'selectedStart' => $get('start'), 'selectedEnd' => $get('end'), 'selectedRentableId' => $this->rentable ? $this->rentable->id : false, ]); }) ->columnSpanFull() ->hiddenLabel() ->live() ->visible(fn (Get $get) => $get('location') || $get('rentable')), */ ]) ->statePath('data'); } protected function getRentables($location) { return Rentable::with(['location', 'amenities']) ->when($location, function ($q) use ($location) { return $q->where('location_id', $location) ->orderBy('name'); }); } protected function getStartOptions($date, $start, $end) { $availabilities = $this->getAvailabilities($date, $start, $end); Log::debug($availabilities); // [{"rentable_id":2,"start":"2024-10-05 00:00:00","end":"2024-09-30 22:00:00"}] if ($this->rentable) { $result = $availabilities->reduce(function ($acc, $a) { $starts = []; // check if it's current date $dateToCompare = Carbon::parse($this->data['date'])->startOfDay(); $today = Carbon::now()->startOfDay(); $start = CarbonImmutable::parse($a['start']); if ($dateToCompare->eq($today)) { $currentTime = now(); $minutes = $currentTime->minute; $interval = $this->rentable->interval ?: $this->rentable->minimum_length; // Calculate next interval $minutesToAdd = $interval - ($minutes % $interval); $start = $currentTime->copy()->addMinutes($minutesToAdd)->startOfMinute(); } $end = CarbonImmutable::parse($a['end']); $interval = (int) ($this->rentable->interval ? $this->rentable->interval : $this->rentable->minimum_length); $time = $this->nextStart(); while ($time->lt($end)) { if ($time->gte($start)) { $starts[$time->format('Y-m-d H:i:s')] = $time->format('h:i a'); } $time->addMinutes($interval); } // TODO: check if this is necessary: /*$starts = collect($starts)->filter(function ($item) use ($end) { return $item->addMinutes($this->rentable->minimum_length)->lte($end); })->toArray();*/ return $acc + $starts; }, []); $this->startOptions = $result; return $result; } return []; } protected function getEndOptions() { if (! $this->rentable || ! $this->data['start']) { return []; } $availabilities = $this->getAvailabilities($this->data['date'], $this->data['start'], null)->first(); if (! $availabilities) { return []; } $availabilityEnd = Carbon::parse($availabilities['end']); $startCarbon = Carbon::parse($this->data['start']); /*$res = collect($this->startOptions)->map(function ($item, $key) use ($startCarbon) { return Carbon::parse($key) ->addMinutes($this->rentable->minimum_length); })->filter(function ($item, $key) use ($startCarbon, $availabilityEnd) { $item->gt($startCarbon) && $item->lte($availabilityEnd); });*/ return collect($this->startOptions)->filter(function ($item, $key) use ($startCarbon, $availabilityEnd) { $keyCarbon = Carbon::parse($key); return $keyCarbon->gt($startCarbon) && $keyCarbon->lte($availabilityEnd); })->all(); } protected function nextStart() { // dd($this->data['date']); // 2024-10-04 $date = Carbon::parse($this->data['date']); $time = $date->startOfDay()->addHours($this->rentable->open); if (! $this->rentable) { return; } elseif (now()->startOfDay()->diffInDays($date->startOfDay()) !== 0) { return $time; } while ($time < now()) { $time->addMinutes($this->rentable->minimum_length); } return $time; } protected function getAvailabilities($date, $start, $end) { $startTime = $start ? Carbon::parse($start)->format('H:i:s') : '00:00:00'; $endTime = $end ? Carbon::parse($end)->format('H:i:s') : '03:00:00'; $date = Str::before($date, ' '); // return empty array for invalid times $carbonStart = Carbon::parse($date.' '.$startTime); $carbonEnd = Carbon::parse($date.' '.$endTime); if ($carbonEnd->toTimeString() <= '03:00:00') { $carbonEnd->addDay(); } if ($carbonStart->gte($carbonEnd) || $carbonEnd->lt(Carbon::now())) { return collect([]); } $startDate = $carbonStart; $endDate = $carbonEnd; $conflicts = Reservation::where(function ($q) use ($startDate, $endDate) { $q->whereBetween('start', [$startDate->toDateTimeString(), $endDate]) ->orWhereBetween('end', [$startDate->toDateTimeString(), $endDate]); }) ->when($this->rentable, function ($q) { $q->where('rentable_id', $this->rentable->id); }) ->orderBy('start', 'asc') ->get() ->map(function ($c) { return [ 'rentable_id' => $c->rentable_id, 'start' => $c->start->toDateTimeString(), 'end' => $c->end->toDateTimeString(), ]; }); $openings = Rentable::when($this->rentable, function ($q) { $q->where('id', $this->rentable->id); }) ->get() ->map(function ($r) use ($startDate, $endDate, $date) { $open = Carbon::parse($date.' '.$r->open); $close = Carbon::parse($date.' '.$r->close); $close = $close->lt($open) ? $close->addDay() : $close; return [ 'rentable_id' => $r->id, 'start' => $open->gt($startDate) ? $open->toDateTimeString() : $startDate->toDateTimeString(), 'end' => $close->lt($endDate) ? $close->toDateTimeString() : $endDate->toDateTimeString(), ]; })->toArray(); foreach ($conflicts->toArray() as $conflict) { foreach ($openings as $key => $opening) { // first if checks if the opening and conflict are actually in conflict. $openingStartCarbon = Carbon::parse($opening['start']); $openingEndCarbon = Carbon::parse($opening['end']); $conflictStartCarbon = Carbon::parse($conflict['start']); $conflictEndCarbon = Carbon::parse($conflict['end']); if ($opening['rentable_id'] == $conflict['rentable_id'] && ! ($conflictStartCarbon->gte($openingEndCarbon) || $conflictEndCarbon->lte($openingStartCarbon))) { if ($conflictStartCarbon->lte($openingStartCarbon)) { $openings[$key] = [ 'rentable_id' => $opening['rentable_id'], 'start' => $conflict['end'], 'end' => $opening['end'], ]; } else { $openings[] = [ 'rentable_id' => $opening['rentable_id'], 'start' => $conflict['end'], 'end' => $opening['end'], ]; $openings[$key] = [ 'rentable_id' => $opening['rentable_id'], 'start' => $opening['start'], 'end' => $conflict['start'], ]; } } } } // if opening start==end remove it $openings = collect($openings)->filter(function ($opening) { return $opening['start'] != $opening['end']; }); return $openings; } protected function getSubtotal($start, $end) { if ($start && $end) { $start = CarbonImmutable::parse($start); $end = CarbonImmutable::parse($end); $minutes = $start->diffInMinutes($end); if ($this->currentTeam->gets_member_rate) { return ($minutes * $this->rentable->member_rate) / $this->rentable->billing_period; } else { return ($minutes * $this->rentable->rate) / $this->rentable->billing_period; } } return 0; } public function submit() { $formData = $this->form->getState(); /** @phpstan-ignore-next-line */ $currentTeam = auth()->user()->currentTeam; try { DB::beginTransaction(); User::where('id', auth()->user()->id)->lockForUpdate()->first(); $rentable_id = $formData['rentable']; $start = $formData['start']; $end = $formData['end']; $rentable = Rentable::find($rentable_id); if (! $rentable) { DB::rollback(); Log::debug('Could not find room', ['rentable_id' => $rentable_id]); Notification::make() ->title('Could not find room.') ->danger() ->send(); return; } // check if it is a 30 minute interval if (Carbon::parse($start)->minute % $rentable->minimum_length != 0 || Carbon::parse($end)->minute % $rentable->minimum_length != 0) { DB::rollback(); Log::debug('Reservation start and end times are not in the correct interval', ['rentable_id' => $rentable_id, 'start' => $start, 'end' => $end, 'minimum_length' => $rentable->minimum_length]); Notification::make() ->title("Reservation start and end times must be in {$rentable->minimum_length} minute intervals") ->danger() ->send(); return; } if (strtotime($end) < time()) { DB::rollback(); Log::debug('Reservation end time is in the past', ['rentable_id' => $rentable_id, 'end' => $end]); Notification::make() ->title('It is too late to reserve this room') ->danger() ->send(); return; } elseif (strtotime($start) >= strtotime($end)) { DB::rollback(); Log::debug('Reservation start and end times are invalid', ['rentable_id' => $rentable_id, 'start' => $start, 'end' => $end]); Notification::make() ->title('Invalid start and end times') ->danger() ->send(); return; } elseif ($rentable->open > Carbon::parse($start)->toTimeString() || $rentable->close < Carbon::parse($end)->toTimeString()) { DB::rollback(); Log::debug('Reservation start and end times are outside of the rentable open and close times', ['rentable_id' => $rentable_id, 'start' => $start, 'end' => $end, 'open' => $rentable->open, 'close' => $rentable->close]); Notification::make() ->title('The room: '.$rentable->name.' is open from ' .$rentable->open_readable.' to '.$rentable->close_readable) ->danger() ->send(); return; } $conflicts = Reservation::where('rentable_id', $rentable_id) ->whereRaw("!( reservations.start >= '".$end. "' OR reservations.end <= '".$start. "' )") ->get(); if (! $conflicts->isEmpty()) { DB::rollback(); Log::debug('Reservation conflicts', ['conflicts' => $conflicts]); Notification::make() ->title('The room is already reserved for that time period.') ->danger() ->send(); return; } $start = Carbon::parse($formData['start']); $end = Carbon::parse($formData['end']); $length = (int) $start->diffInMinutes($end, true); if ($length < $rentable->minimum_length) { DB::rollback(); Log::debug('Reservation minimum length is less than minimum length', ['rentable_id' => $rentable_id, 'minimum_length' => $rentable->minimum_length, 'length' => $length]); Notification::make() ->title('The minimum reservation length for this room is '.$rentable->minimum_length.' minutes') ->danger() ->send(); return; } if ($currentTeam->gets_member_rate) { $price = $length * ($rentable->member_rate / $rentable->billing_period); } else { $price = $length * ($rentable->rate / $rentable->billing_period); } $price = round($price, 2); $total = $price; $appliedCredit = $formData['credit']; $availableCredit = $currentTeam->credit; // if user not provided credit and payment, apply credit if ($appliedCredit == null && $formData['selectedPayment'] == null) { $appliedCredit = $price; } // if the user applied more than needed if ($appliedCredit > $price) { $appliedCredit = $price; } // subtract credit used from price if ($appliedCredit > 0) { $availableCredit = $availableCredit - $appliedCredit; if ($availableCredit < 0) { DB::rollback(); Log::debug('Team credit is less than applied credit', ['team_id' => $currentTeam->id, 'available_credit' => $availableCredit, 'applied_credit' => $appliedCredit]); Notification::make() ->title('Your team ('.$currentTeam->name.') has $'.($currentTeam->credit).' available credit') ->danger() ->send(); return; } else { $total = $price - $appliedCredit; } } // charge if ($total > 0) { $defaultPaymentMethod = TeamPaymentMethod::where('team_id', $currentTeam->id) ->where('default', 1) ->first(); $paymentMethod = TeamPaymentMethod::where('team_id', $currentTeam->id) ->where('id', $formData['selectedPayment']) ->first(); if (! $paymentMethod) { DB::rollback(); Log::debug('No payment method set for team', ['team_id' => $currentTeam->id]); Notification::make() ->title("There is no payment method set for your team. Please have your TEAM OWNER update your billing information here: id.'#/payment-method')."'>Settings") ->danger() ->send(); return; } try { // Make source default for stripe charge if ($paymentMethod || ($paymentMethod && $defaultPaymentMethod && $defaultPaymentMethod->id != $paymentMethod->id)) { $paymentMethod->makeDefault(); } $invoiceName = $rentable->name.' '.$start->format('m/d/Y h:i a'); $currentTeam->invoiceFor($invoiceName, $total * 100); // Revert back for stripe subscription if ($paymentMethod && $defaultPaymentMethod && $defaultPaymentMethod->id != $paymentMethod->id) { $defaultPaymentMethod->makeDefault(); } } catch (\Exception $e) { Log::info($e); DB::rollback(); Notification::make() ->title("There was an error charging your account. Please have your TEAM OWNER correct your billing information here: id.'#/payment-method')."'>Settings") ->danger() ->send(); return; } } // update team credit $sub = $currentTeam->customSubscription; // dump('appliedCredit: ' . $appliedCredit); if (isset($sub) && $appliedCredit > 0) { // Subtract Applied from rollover first $remainingRollover = $sub->rollover_credit - $appliedCredit; // dump('initial rollover: ' . $sub->rollover_credit); // dump('remaining rollover: ' . $remainingRollover); if ($remainingRollover < 0) { // if team rollover credit was not enough $sub->rollover_credit = 0; // add the negative to credit // dump('initial credit: ' . $sub->credit); $sub->credit += $remainingRollover; // dump('remaining credit: ' . $sub->credit); } else { $sub->rollover_credit = $remainingRollover; // dump('save remaining rollover: ' . $sub->rollover_credit); } // dd($sub); $sub->save(); } $name = filled($formData['name']) ? $formData['name'] : $rentable->name; $reservation = Reservation::create([ 'rentable_id' => $rentable_id, 'user_id' => auth()->user()->id, 'name' => $name, 'start' => $start, 'end' => $end, 'price' => $total, 'credit_used' => $appliedCredit ? $appliedCredit : 0, ]); // notify admin for event space $admins = User::where('is_admin', true)->get(); if ($admins->isNotEmpty()) { foreach ($admins as $a) { if ($reservation->rentable->name == 'Event Space') { $a->notify(new EventSpaceBooked($reservation, $rentable)); } else { $a->notify(new ReservationBooked($reservation, $rentable)); } } } // Store in Google calendar if (isset($rentable->google_calendar_id)) { try { $event = Event::create([ 'location' => $rentable->name, 'name' => $name, 'startDateTime' => Carbon::parse($reservation->start), 'endDateTime' => Carbon::parse($reservation->end), ], $rentable->google_calendar_id); $reservation->google_event_id = $event->id; } catch (\Exception $e) { Log::error($e->getMessage()); Log::notice('Not Added to Google Calendar:'); Log::info($reservation); } } $reservation->save(); DB::commit(); if ($rentable->send_webhook) { WebhookCall::create() ->url(config('mill.send-webhook-url')) ->payload([ 'event' => 'studio_reservation_created', 'id' => $reservation->id, 'reservation_name' => $reservation->name, 'name' => $rentable->name, 'location' => $rentable->location->name, 'user' => [ 'name' => auth()->user()->name, 'email' => auth()->user()->email, ], 'company' => [ 'name' => auth()->user()->currentTeam->name, ], 'start' => "$reservation->start", 'end' => "$reservation->end", ]) ->useSecret(config('mill.send-webhook-secret')) ->dispatch(); } Notification::make() ->title('You have successfully reserved '.$rentable->name) ->success() ->send(); // 'credit' => $currentTeam->credit, $this->redirect(Reservations::getUrl(panel: 'app')); } catch (\Illuminate\Database\QueryException $e) { Log::info('Query exception'); Log::info($e); } catch (\Exception $e) { Log::error($e); Notification::make() ->title('An error occurred.') ->danger() ->send(); return; } } }