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;
}
}
}