Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 74 additions & 9 deletions application/libraries/Availability.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,18 @@ protected function consider_multiple_attendants(
return [];
}

$period_start = new DateTime($date . ' ' . $date_working_plan['start']);
$period_end = new DateTime($date . ' ' . $date_working_plan['end']);

// If end is before or equal to start, it means the period crosses midnight (e.g., 18:00-02:00)
if ($period_end <= $period_start) {
$period_end->modify('+1 day');
}

$periods = [
[
'start' => new DateTime($date . ' ' . $date_working_plan['start']),
'end' => new DateTime($date . ' ' . $date_working_plan['end']),
'start' => $period_start,
'end' => $period_end,
],
];

Expand Down Expand Up @@ -202,11 +210,33 @@ public function remove_breaks(string $date, array $periods, array $breaks): arra
return $periods;
}

// Determine the working day start time for cross-midnight handling
// by looking at the first period's start time
$working_day_start_time = null;
if (!empty($periods) && isset($periods[0]['start'])) {
$first_period_start = $periods[0]['start'];
if ($first_period_start instanceof DateTime) {
$working_day_start_time = $first_period_start->format('H:i');
} else {
$working_day_start_time = $first_period_start;
}
}

foreach ($breaks as $break) {
$break_start = new DateTime($date . ' ' . $break['start']);

$break_end = new DateTime($date . ' ' . $break['end']);

// For cross-midnight periods: if break time is before the working day start,
// the break is on the next day (e.g., break at 01:00 when work starts at 18:00)
if ($working_day_start_time !== null) {
if ($break['start'] < $working_day_start_time) {
$break_start->modify('+1 day');
}
if ($break['end'] <= $working_day_start_time) {
$break_end->modify('+1 day');
}
}

foreach ($periods as &$period) {
$period_start = $period['start'];

Expand Down Expand Up @@ -391,13 +421,27 @@ public function get_available_periods(string $date, array $provider, ?int $exclu
'end' => $date_working_plan['end'],
];

$day_start = new DateTime($date_working_plan['start']);
$day_end = new DateTime($date_working_plan['end']);
$day_start = new DateTime($date . ' ' . $date_working_plan['start']);
$day_end = new DateTime($date . ' ' . $date_working_plan['end']);

// If end is before or equal to start, it means the period crosses midnight (e.g., 18:00-02:00)
if ($day_end <= $day_start) {
$day_end->modify('+1 day');
}

// Split the working plan to available time periods that do not contain the breaks in them.
foreach ($date_working_plan['breaks'] as $break) {
$break_start = new DateTime($break['start']);
$break_end = new DateTime($break['end']);
$break_start = new DateTime($date . ' ' . $break['start']);
$break_end = new DateTime($date . ' ' . $break['end']);

// For cross-midnight periods: if break time is before the working day start,
// the break is on the next day (e.g., break at 01:00 when work starts at 18:00)
if ($break['start'] < $date_working_plan['start']) {
$break_start->modify('+1 day');
}
if ($break['end'] <= $date_working_plan['start']) {
$break_end->modify('+1 day');
}

if ($break_start < $day_start) {
$break_start = $day_start;
Expand All @@ -412,8 +456,16 @@ public function get_available_periods(string $date, array $provider, ?int $exclu
}

foreach ($periods as $key => $period) {
$period_start = new DateTime($period['start']);
$period_end = new DateTime($period['end']);
$period_start = new DateTime($date . ' ' . $period['start']);
$period_end = new DateTime($date . ' ' . $period['end']);

// Adjust for cross-midnight: if period time is before working day start, it's on the next day
if ($period['start'] < $date_working_plan['start']) {
$period_start->modify('+1 day');
}
if ($period['end'] <= $date_working_plan['start']) {
$period_end->modify('+1 day');
}

$remove_current_period = false;

Expand Down Expand Up @@ -459,6 +511,14 @@ public function get_available_periods(string $date, array $provider, ?int $exclu
$period_start = new DateTime($date . ' ' . $period['start']);
$period_end = new DateTime($date . ' ' . $period['end']);

// Adjust for cross-midnight: if period time is before working day start, it's on the next day
if ($period['start'] < $date_working_plan['start']) {
$period_start->modify('+1 day');
}
if ($period['end'] <= $date_working_plan['start']) {
$period_end->modify('+1 day');
}

if (
$appointment_start <= $period_start &&
$appointment_end <= $period_end &&
Expand Down Expand Up @@ -552,6 +612,11 @@ protected function generate_available_hours(string $date, array $service, array

$end_hour = new DateTime($date . ' ' . $period['end']);

// If end is before or equal to start, it means the period crosses midnight (e.g., 18:00-02:00)
if ($end_hour <= $start_hour) {
$end_hour->modify('+1 day');
}

$interval = !empty($service['slot_interval']) ? (int) $service['slot_interval'] : 15;

$current_hour = $start_hour;
Expand Down
11 changes: 2 additions & 9 deletions application/models/Providers_model.php
Original file line number Diff line number Diff line change
Expand Up @@ -545,15 +545,8 @@ public function save_working_plan_exception(int $provider_id, array $working_pla
throw new InvalidArgumentException('Working plan exception start date must be before or equal to end date.');
}

// If start_time and end_time are provided, validate them
if (!empty($start_time) && !empty($end_time)) {
$start = date('H:i', strtotime($start_time));
$end = date('H:i', strtotime($end_time));

if ($start > $end) {
throw new InvalidArgumentException('Working plan exception start time must be before end time.');
}
}
// Note: We do not validate start_time > end_time, as cross-midnight working hours
// (e.g., 18:00 - 02:00) are supported and handled in the Availability library

// Make sure the provider record exists.
$where = [
Expand Down
35 changes: 32 additions & 3 deletions assets/js/utils/calendar_default_view.js
Original file line number Diff line number Diff line change
Expand Up @@ -871,7 +871,7 @@ App.Utils.CalendarDefaultView = (function () {
* @returns {Array} Calendar event objects.
*/
function createAppointmentEvents(appointments) {
return appointments.map((appointment) => {
return appointments.flatMap((appointment) => {
const customerName = [appointment.customer.first_name, appointment.customer.last_name]
.filter(Boolean)
.join(' ');
Expand All @@ -883,11 +883,40 @@ App.Utils.CalendarDefaultView = (function () {

const title = customerName ? customerName + ' - ' + type : appointment.service.name;

const start = moment(appointment.start_datetime);
const end = moment(appointment.end_datetime);

// Split multi-day events for proper timeGrid display (e.g., cross-midnight appointments)
if (!start.isSame(end, 'day')) {
return [
{
id: appointment.id + '_day1',
title,
start: start.toDate(),
end: start.clone().endOf('day').toDate(),
allDay: false,
color: appointment.color,
data: appointment,
display: 'block',
},
{
id: appointment.id + '_day2',
title,
start: end.clone().startOf('day').toDate(),
end: end.toDate(),
allDay: false,
color: appointment.color,
data: appointment,
display: 'block',
},
];
}

return {
id: appointment.id,
title,
start: moment(appointment.start_datetime).toDate(),
end: moment(appointment.end_datetime).toDate(),
start: start.toDate(),
end: end.toDate(),
allDay: false,
color: appointment.color,
data: appointment,
Expand Down
37 changes: 33 additions & 4 deletions assets/js/utils/calendar_table_view.js
Original file line number Diff line number Diff line change
Expand Up @@ -839,18 +839,47 @@ App.Utils.CalendarTableView = (function () {

return !filterServiceIds.length || filterServiceIds.includes(appointment.id_services);
})
.map((appointment) => {
.flatMap((appointment) => {
const customerName = [appointment.customer.first_name, appointment.customer.last_name]
.filter(Boolean)
.join(' ');

const title = customerName ? customerName + ' - ' + appointment.service.name : appointment.service.name;

const start = moment(appointment.start_datetime);
const end = moment(appointment.end_datetime);

// Split multi-day events for proper timeGrid display (e.g., cross-midnight appointments)
if (!start.isSame(end, 'day')) {
return [
{
id: appointment.id + '_day1',
title,
start: start.toDate(),
end: start.clone().endOf('day').toDate(),
allDay: false,
color: appointment.color,
display: 'block',
data: appointment,
},
{
id: appointment.id + '_day2',
title,
start: end.clone().startOf('day').toDate(),
end: end.toDate(),
allDay: false,
color: appointment.color,
display: 'block',
data: appointment,
},
];
}

return {
id: appointment.id,
title: title,
start: moment(appointment.start_datetime).toDate(),
end: moment(appointment.end_datetime).toDate(),
title,
start: start.toDate(),
end: end.toDate(),
allDay: false,
color: appointment.color,
display: 'block',
Expand Down