appointments = new AppointmentsRepository();
$this->exceptions = new AvailabilityExceptionsRepository();
$this->availability = new AvailabilityRepository();
$this->services = new ServicesRepository();
$this->staff = new StaffRepository();
$this->statuses = new StatusesRepository();
}
public function register(): void {
add_action( 'wp_ajax_abientot_calendar_events', array( $this, 'events' ) );
}
public function render_calendar(): void {
if ( ! current_user_can( 'abientot_manage_bookings' ) ) {
wp_die( esc_html__( 'You do not have permission to manage the calendar.', 'abientot' ) );
}
$state = $this->state_from_request();
$services = $this->services->list_for_admin();
$statuses = $this->statuses->ordered();
$range = $this->range_for_view( $state['view'], $state['date'], DateTimeHelper::business_timezone() );
$events = $this->appointments->calendar_all(
$range['start_utc'],
$range['end_utc'],
array(
'service_id' => $state['service_id'],
'status_id' => $state['status_id'],
'staff_id' => $state['staff_id'],
)
);
?>
__( 'Permission denied.', 'abientot' ) ), 403 );
}
$state = $this->state_from_request( true );
$timezone = DateTimeHelper::business_timezone();
$range = $this->range_for_view( $state['view'], $state['date'], $timezone );
$rows = $this->appointments->calendar_all(
$range['start_utc'],
$range['end_utc'],
array(
'service_id' => $state['service_id'],
'status_id' => $state['status_id'],
'staff_id' => $state['staff_id'],
)
);
wp_send_json_success(
array(
'view' => $state['view'],
'date' => $state['date'],
'range' => array(
'start' => $range['start']->format( 'Y-m-d' ),
'end' => $range['end']->modify( '-1 second' )->format( 'Y-m-d' ),
'label' => $range['label'],
),
'days' => $this->day_payloads( $range['start'], $range['end'], $timezone ),
'events' => $this->event_payloads( $rows, $timezone ),
'availability' => $this->availability_payload( $range['start']->format( 'Y-m-d' ), $range['end']->modify( '-1 day' )->format( 'Y-m-d' ) ),
'createUrl' => $this->create_url( $state['date'], $state['service_id'] ),
)
);
}
/**
* @return array{view:string,date:string,service_id:int,status_id:int,staff_id:int}
*/
private function state_from_request( bool $include_post = false ): array {
// The page uses GET navigation filters; events() also reads its nonce-verified POST fields.
$fields = array( 'view', 'date', 'service_id', 'status_id', 'staff_id' );
$request = \Abientot\Support\Request::view_fields( $fields );
if ( $include_post ) {
$request = array_merge( $request, \Abientot\Support\Request::post_fields( 'abientot_calendar_events', $fields, 'nonce' ) );
}
$view = isset( $request['view'] ) && is_string( $request['view'] ) ? sanitize_key( $request['view'] ) : 'week';
$date = isset( $request['date'] ) && is_string( $request['date'] ) ? sanitize_text_field( $request['date'] ) : wp_date( 'Y-m-d' );
if ( ! in_array( $view, array( 'day', 'week', 'month' ), true ) ) {
$view = 'week';
}
if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
$date = wp_date( 'Y-m-d' );
}
return array(
'view' => $view,
'date' => $date,
'service_id' => isset( $request['service_id'] ) && is_scalar( $request['service_id'] ) ? absint( $request['service_id'] ) : 0,
'status_id' => isset( $request['status_id'] ) && is_scalar( $request['status_id'] ) ? absint( $request['status_id'] ) : 0,
'staff_id' => \Abientot\Support\StaffFilter::enabled() ? \Abientot\Support\StaffFilter::request( isset( $request['staff_id'] ) && is_string( $request['staff_id'] ) ? sanitize_text_field( $request['staff_id'] ) : 0 ) : 0,
);
}
/**
* @return array{start:DateTimeImmutable,end:DateTimeImmutable,start_utc:string,end_utc:string,label:string}
*/
private function range_for_view( string $view, string $date, DateTimeZone $timezone ): array {
try {
$anchor = new DateTimeImmutable( $date . ' 00:00:00', $timezone );
} catch ( Exception $exception ) {
$anchor = new DateTimeImmutable( 'today', $timezone );
}
if ( 'day' === $view ) {
$start = $anchor;
$end = $start->modify( '+1 day' );
$label = \Abientot\Support\Locale::date( 'l, ' . DateTimeHelper::date_display_format(), $start->getTimestamp(), $timezone );
} elseif ( 'month' === $view ) {
$start = $anchor->modify( 'first day of this month' );
$end = $start->modify( 'first day of next month' );
$label = \Abientot\Support\Locale::date( DateTimeHelper::month_year_format(), $start->getTimestamp(), $timezone );
$start = \Abientot\Support\Locale::week_start_date( $start );
$last = $end->modify( '-1 day' );
$end = \Abientot\Support\Locale::week_start_date( $last )->modify( '+7 days' );
} else {
$start = \Abientot\Support\Locale::week_start_date( $anchor );
$end = $start->modify( '+7 days' );
$label = \Abientot\Support\Locale::date( DateTimeHelper::date_display_format(), $start->getTimestamp(), $timezone ) . ' – ' . \Abientot\Support\Locale::date( DateTimeHelper::date_display_format(), $end->modify( '-1 day' )->getTimestamp(), $timezone );
}
return array(
'start' => $start,
'end' => $end,
'start_utc' => $start->setTimezone( DateTimeHelper::utc_timezone() )->format( 'Y-m-d H:i:s' ),
'end_utc' => $end->setTimezone( DateTimeHelper::utc_timezone() )->format( 'Y-m-d H:i:s' ),
'label' => $label,
);
}
/**
* @return array>
*/
private function day_payloads( DateTimeImmutable $start, DateTimeImmutable $end, DateTimeZone $timezone ): array {
$days = array();
$today = ( new DateTimeImmutable( 'today', $timezone ) )->format( 'Y-m-d' );
for ( $day = $start; $day < $end; $day = $day->modify( '+1 day' ) ) {
$date = $day->format( 'Y-m-d' );
$days[] = array(
'date' => $date,
'label' => \Abientot\Support\Locale::date( 'D, ' . DateTimeHelper::date_display_format(), $day->getTimestamp(), $timezone ),
'dayName' => \Abientot\Support\Locale::date( 'D',$day->getTimestamp(),$timezone ),
'day' => $day->format( 'j' ),
'current' => $date === $today,
);
}
return $days;
}
/**
* @param array> $rows Calendar rows.
* @return array>
*/
private function event_payloads( array $rows, DateTimeZone $fallback_timezone ): array {
$events = array();
foreach ( $rows as $row ) {
$timezone = $fallback_timezone;
$start = new DateTimeImmutable( (string) $row['start_utc'], DateTimeHelper::utc_timezone() );
$end = new DateTimeImmutable( (string) $row['end_utc'], DateTimeHelper::utc_timezone() );
$local = $start->setTimezone( $timezone );
$local_end = $end->setTimezone( $timezone );
$color = \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) $row['service_color'] ) ) ?: \Abientot\Support\BrandPalette::color( (string) sanitize_hex_color( (string) $row['status_color'] ) ) ?: '#172033';
$events[] = array(
'id' => (int) $row['id'],
'calendarTimezone' => $timezone->getName(),
'staffId' => (int) $row['staff_id'],
'staffName' => (string) $row['staff_name'],
'bookingKind' => (string) ( $row['booking_kind'] ?? 'service' ),
'resourceCategory' => (string) ( $row['resource_category'] ?? '' ),
'resourceLabel' => (string) ( $row['resource_label'] ?? '' ),
'partySize' => (int) ( $row['party_size'] ?? 0 ),
'service' => (string) $row['service_name'],
'endDate' => $local_end->format( 'Y-m-d' ),
'duration' => ( $end->getTimestamp() - $start->getTimestamp() ) / 60,
'startTimestamp' => $start->getTimestamp() * 1000,
'endTimestamp' => $end->getTimestamp() * 1000,
'date' => $local->format( 'Y-m-d' ),
'start' => $local->format( 'Y-m-d H:i:s' ),
'end' => $local_end->format( 'Y-m-d H:i:s' ),
'time' => $local->format( 'H:i' ),
'endTime' => $local_end->format( 'H:i' ),
'timeLabel' => DateTimeHelper::format_utc_datetime( (string) $row['start_utc'], $timezone->getName(), DateTimeHelper::time_display_format() ),
'endTimeLabel' => DateTimeHelper::format_utc_datetime( (string) $row['end_utc'], $timezone->getName(), DateTimeHelper::time_display_format() ),
'title' => (string) $row['service_name'] . ( \Abientot\Support\BookingStaff::show_relationships() ? ' · ' . (string) $row['staff_name'] : '' ) . ( \Abientot\Support\BookingLocation::show_relationships() ? ' · ' . (string) $row['location_name'] : '' ),
'customer' => (string) $row['customer_name'],
'status' => (string) $row['status_label'],
'statusSlug' => (string) $row['status_slug'],
'payment' => (string) $row['payment_status'],
'color' => $color,
'blocksAvailability' => ! empty( $row['blocks_availability'] ),
'editUrl' => admin_url( 'admin.php?page=abientot-bookings&booking_id=' . absint( $row['id'] ) ),
);
}
return $events;
}
/**
* @param array $row Event row.
*/
private function row_timezone( array $row, DateTimeZone $fallback ): DateTimeZone {
try {
return new DateTimeZone( (string) $row['timezone'] );
} catch ( Exception $exception ) {
return $fallback;
}
}
/**
* @return array>>
*/
public function availability_payload( string $start_date, string $end_date ): array {
if ( ! current_user_can( 'abientot_manage_bookings' ) ) {
return array();
}
$default = $this->staff->default_staff();
$location = ( new \Abientot\Repositories\LocationsRepository() )->default_location() ?? array();
$people = array();
foreach ( $this->staff_rows() as $person ) {
$id = (int) $person['id'];
$people[] = array(
'id' => $id,
'name' => (string) $person['display_name'],
'photo' => esc_url_raw( wp_get_attachment_image_url( (int) ( $person['photo_id'] ?? 0 ), 'thumbnail' ) ?: '' ),
'active' => ( $person['status'] ?? 'active' ) === 'active',
'timezone' => \Abientot\Support\BookingStaff::timezone( $person, $location )->getName(),
'calendarTimezone' => DateTimeHelper::business_timezone()->getName(),
'weekly' => $this->weekly_availability_payloads( $this->availability->weekly_rules_for_staff( $id ) ),
'exceptions' => $this->exception_payloads( $this->exceptions->for_staff_between( $id, ( new DateTimeImmutable( $start_date ) )->modify( '-1 day' )->format( 'Y-m-d' ), ( new DateTimeImmutable( $end_date ) )->modify( '+1 day' )->format( 'Y-m-d' ) ) ),
);
}
usort(
$people,
static function ( array $a, array $b ): int {
return $a['id'] <=> $b['id'];
}
);
$retained = new \Abientot\Repositories\ResourceBookingRepository();
$unassigned = ( \Abientot\Edition::is_pro() && $retained->present() ) || $retained->has_unassigned();
if ( $unassigned ) {
$people[] = array(
'id' => 0,
'name' => \Abientot\Support\StaffFilter::label(),
'photo' => '',
'active' => false,
'unassigned' => true,
'timezone' => DateTimeHelper::business_timezone()->getName(),
'calendarTimezone' => DateTimeHelper::business_timezone()->getName(),
'weekly' => array(),
'exceptions' => array()
);
}
$solo = array();
foreach ( $people as $person ) {
if ( $default && $person['id'] === (int) $default['id'] ) {
$solo = $person;
break;
}
}
return array(
'weekly' => $solo['weekly'] ?? array(),
'exceptions' => $solo['exceptions'] ?? array(),
'staff' => $people,
'defaultStaffId' => (int) ( $default['id'] ?? 0 ),
'timezone' => DateTimeHelper::business_timezone()->getName(),
'today' => ( new DateTimeImmutable( 'now', DateTimeHelper::business_timezone() ) )->format( 'Y-m-d' ),
'staffLanes' => (bool) apply_filters( 'abientot_admin_staff_filters_enabled', false ),
'unassignedLane' => $unassigned,
'unassignedCreate' => (bool) apply_filters( 'abientot_resource_admin_enabled',false ),
);
}
/** Read all staff in bounded pages, including retained historical profiles. */
private function staff_rows(): array {
$rows = array();
for ( $offset = 0; ; $offset += 500 ) {
$page = $this->staff->all( 500, $offset );
$rows = array_merge( $rows, $page );
if ( count( $page ) < 500 ) {
break;
}
}
return $rows;
}
/**
* @param array> $rows Availability rows.
* @return array>
*/
private function weekly_availability_payloads( array $rows ): array {
$payloads = array();
foreach ( $rows as $row ) {
if ( ! in_array( (string) $row['rule_type'], array( 'working', 'break' ), true ) ) {
continue;
}
$payloads[] = array(
'weekday' => (int) $row['weekday'],
'type' => (string) $row['rule_type'],
'start' => substr( (string) $row['start_time'], 0, 5 ),
'end' => substr( (string) $row['end_time'], 0, 5 ),
);
}
return $payloads;
}
/**
* @param array> $rows Exception rows.
* @return array>
*/
private function exception_payloads( array $rows ): array {
$payloads = array();
foreach ( $rows as $row ) {
$payloads[] = array(
'date' => (string) $row['exception_date'],
'type' => (string) $row['exception_type'],
'start' => empty( $row['start_time'] ) ? '' : substr( (string) $row['start_time'], 0, 5 ),
'end' => empty( $row['end_time'] ) ? '' : substr( (string) $row['end_time'], 0, 5 ),
);
}
return $payloads;
}
private function create_url( string $date, int $service_id = 0 ): string {
$args = array(
'page' => 'abientot-bookings',
'start_local' => $date . 'T09:00',
);
if ( $service_id > 0 ) {
$args['service_id'] = $service_id;
}
return add_query_arg( $args, admin_url( 'admin.php' ) );
}
/**
* @param array> $events Initial fallback events.
*/
private function render_fallback( array $events ): void {
if ( empty( $events ) ) {
echo '' . esc_html__( 'No bookings in this range.', 'abientot' ) . '
';
return;
}
echo '| ' . esc_html__( 'Time', 'abientot' ) . ' | ' . esc_html__( 'Booking', 'abientot' ) . ' | ' . esc_html__( 'Status', 'abientot' ) . ' | ' . esc_html__( 'Actions', 'abientot' ) . ' |
';
foreach ( $events as $event ) {
printf(
'| %1$s | %2$s %3$s | %4$s | %6$s |
',
esc_html( DateTimeHelper::format_utc_datetime( (string) $event['start_utc'], (string) ( $event['timezone'] ?? DateTimeHelper::business_timezone()->getName() ) ) ),
esc_html( (string) $event['service_name'] ),
esc_html( (string) $event['customer_name'] ),
esc_html( (string) $event['status_label'] ),
esc_url( admin_url( 'admin.php?page=abientot-bookings&booking_id=' . absint( $event['id'] ) ) ),
esc_html__( 'Edit', 'abientot' )
);
}
echo '
';
}
}