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'], ) ); ?>
__( 'Day', 'abientot' ), 'week' => __( 'Week', 'abientot' ), 'month' => __( 'Month', 'abientot' ) ) as $view => $label ) : ?>

__( '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 ''; foreach ( $events as $event ) { printf( '', 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 '
' . esc_html__( 'Time', 'abientot' ) . '' . esc_html__( 'Booking', 'abientot' ) . '' . esc_html__( 'Status', 'abientot' ) . '' . esc_html__( 'Actions', 'abientot' ) . '
%1$s%2$s
%3$s
%4$s%6$s
'; } }