feat(05-admin-panel): Admin panel z historią rezerwacji

Phase 5 complete — CPT carei_reservation z automatycznym zapisem,
lista z kolumnami i filtrem statusu, meta box szczegółów,
system statusów nowe/przeczytane/zrealizowane, auto-mark-read.

Milestone v0.1 Formularz Rezerwacji MVP — all 5 phases complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 17:39:09 +01:00
parent a82ec90a51
commit 2af73782f2
11 changed files with 1261 additions and 23 deletions

View File

@@ -50,6 +50,7 @@ function carei_parse_env() {
*/
require_once CAREI_RESERVATION_PATH . 'includes/class-softra-api.php';
require_once CAREI_RESERVATION_PATH . 'includes/class-rest-proxy.php';
require_once CAREI_RESERVATION_PATH . 'includes/class-admin-panel.php';
/**
* Initialize plugin on plugins_loaded
@@ -73,6 +74,9 @@ add_action( 'plugins_loaded', function () {
// Initialize REST proxy
new Carei_REST_Proxy();
// Initialize admin panel
new Carei_Admin_Panel();
} );
/**

View File

@@ -0,0 +1,412 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Carei_Admin_Panel {
const POST_TYPE = 'carei_reservation';
const META_PREFIX = '_carei_';
private static $statuses = array(
'nowe' => array( 'label' => 'Nowe', 'color' => '#2F2482' ),
'przeczytane' => array( 'label' => 'Przeczytane', 'color' => '#f59e0b' ),
'zrealizowane' => array( 'label' => 'Zrealizowane', 'color' => '#22c55e' ),
);
public function __construct() {
add_action( 'init', array( $this, 'register_post_type' ) );
add_filter( 'manage_' . self::POST_TYPE . '_posts_columns', array( $this, 'admin_columns' ) );
add_action( 'manage_' . self::POST_TYPE . '_posts_custom_column', array( $this, 'render_column' ), 10, 2 );
add_action( 'restrict_manage_posts', array( $this, 'status_filter_dropdown' ) );
add_action( 'pre_get_posts', array( $this, 'filter_by_status' ) );
add_action( 'add_meta_boxes', array( $this, 'add_meta_boxes' ) );
add_action( 'save_post_' . self::POST_TYPE, array( $this, 'save_meta_box' ), 10, 2 );
add_action( 'edit_form_after_title', array( $this, 'auto_mark_read' ) );
add_action( 'admin_head', array( $this, 'admin_styles' ) );
}
public function register_post_type() {
register_post_type( self::POST_TYPE, array(
'labels' => array(
'name' => 'Rezerwacje',
'singular_name' => 'Rezerwacja',
'menu_name' => 'Rezerwacje',
'all_items' => 'Wszystkie rezerwacje',
'view_item' => 'Zobacz rezerwację',
'edit_item' => 'Szczegóły rezerwacji',
'search_items' => 'Szukaj rezerwacji',
'not_found' => 'Nie znaleziono rezerwacji',
'not_found_in_trash' => 'Brak rezerwacji w koszu',
),
'public' => false,
'show_ui' => true,
'show_in_menu' => true,
'menu_icon' => 'dashicons-car',
'menu_position' => 26,
'supports' => array( 'title' ),
'capability_type' => 'post',
'has_archive' => false,
'hierarchical' => false,
'show_in_rest' => false,
) );
}
// ─── Admin Columns ──────────────────────────────────────────
public function admin_columns( $columns ) {
return array(
'cb' => '<input type="checkbox" />',
'reservation_no' => 'Nr rezerwacji',
'client' => 'Klient',
'segment' => 'Segment',
'dates' => 'Daty',
'branch' => 'Oddział',
'carei_status' => 'Status',
'date' => 'Data',
);
}
public function render_column( $column, $post_id ) {
switch ( $column ) {
case 'reservation_no':
$no = get_post_meta( $post_id, self::META_PREFIX . 'reservation_no', true ) ?: '—';
printf(
'<a class="row-title" href="%s"><strong>%s</strong></a>',
esc_url( get_edit_post_link( $post_id ) ),
esc_html( $no )
);
break;
case 'client':
$first = get_post_meta( $post_id, self::META_PREFIX . 'first_name', true );
$last = get_post_meta( $post_id, self::META_PREFIX . 'last_name', true );
echo esc_html( trim( $first . ' ' . $last ) ?: '—' );
break;
case 'segment':
echo esc_html( get_post_meta( $post_id, self::META_PREFIX . 'segment', true ) ?: '—' );
break;
case 'dates':
$from = get_post_meta( $post_id, self::META_PREFIX . 'date_from', true );
$to = get_post_meta( $post_id, self::META_PREFIX . 'date_to', true );
if ( $from && $to ) {
$from_fmt = date_i18n( 'd.m.Y H:i', strtotime( $from ) );
$to_fmt = date_i18n( 'd.m.Y H:i', strtotime( $to ) );
echo esc_html( $from_fmt . ' — ' . $to_fmt );
} else {
echo '—';
}
break;
case 'branch':
echo esc_html( get_post_meta( $post_id, self::META_PREFIX . 'pickup_branch', true ) ?: '—' );
break;
case 'carei_status':
$status = get_post_meta( $post_id, self::META_PREFIX . 'status', true ) ?: 'nowe';
$info = isset( self::$statuses[ $status ] ) ? self::$statuses[ $status ] : self::$statuses['nowe'];
printf(
'<span class="carei-status-badge" style="background:%s;">%s</span>',
esc_attr( $info['color'] ),
esc_html( $info['label'] )
);
break;
}
}
// ─── Status Filter ──────────────────────────────────────────
public function status_filter_dropdown( $post_type ) {
if ( $post_type !== self::POST_TYPE ) {
return;
}
$current = isset( $_GET['carei_status'] ) ? sanitize_text_field( $_GET['carei_status'] ) : '';
echo '<select name="carei_status">';
echo '<option value="">Wszystkie statusy</option>';
foreach ( self::$statuses as $key => $info ) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr( $key ),
selected( $current, $key, false ),
esc_html( $info['label'] )
);
}
echo '</select>';
}
public function filter_by_status( $query ) {
if ( ! is_admin() || ! $query->is_main_query() ) {
return;
}
if ( ( $query->get( 'post_type' ) !== self::POST_TYPE ) ) {
return;
}
if ( ! empty( $_GET['carei_status'] ) ) {
$status = sanitize_text_field( $_GET['carei_status'] );
if ( isset( self::$statuses[ $status ] ) ) {
$query->set( 'meta_query', array(
array(
'key' => self::META_PREFIX . 'status',
'value' => $status,
),
) );
}
}
// Default sort by date desc
if ( ! $query->get( 'orderby' ) ) {
$query->set( 'orderby', 'date' );
$query->set( 'order', 'DESC' );
}
}
// ─── Meta Box ───────────────────────────────────────────────
public function add_meta_boxes() {
add_meta_box(
'carei_reservation_details',
'Szczegóły rezerwacji',
array( $this, 'render_meta_box' ),
self::POST_TYPE,
'normal',
'high'
);
}
public function render_meta_box( $post ) {
wp_nonce_field( 'carei_save_reservation', 'carei_reservation_nonce' );
$meta = array(
'reservation_no' => get_post_meta( $post->ID, self::META_PREFIX . 'reservation_no', true ),
'reservation_id' => get_post_meta( $post->ID, self::META_PREFIX . 'reservation_id', true ),
'customer_id' => get_post_meta( $post->ID, self::META_PREFIX . 'customer_id', true ),
'segment' => get_post_meta( $post->ID, self::META_PREFIX . 'segment', true ),
'date_from' => get_post_meta( $post->ID, self::META_PREFIX . 'date_from', true ),
'date_to' => get_post_meta( $post->ID, self::META_PREFIX . 'date_to', true ),
'pickup_branch' => get_post_meta( $post->ID, self::META_PREFIX . 'pickup_branch', true ),
'return_branch' => get_post_meta( $post->ID, self::META_PREFIX . 'return_branch', true ),
'first_name' => get_post_meta( $post->ID, self::META_PREFIX . 'first_name', true ),
'last_name' => get_post_meta( $post->ID, self::META_PREFIX . 'last_name', true ),
'email' => get_post_meta( $post->ID, self::META_PREFIX . 'email', true ),
'phone' => get_post_meta( $post->ID, self::META_PREFIX . 'phone', true ),
'pesel' => get_post_meta( $post->ID, self::META_PREFIX . 'pesel', true ),
'address' => get_post_meta( $post->ID, self::META_PREFIX . 'address', true ),
'extras' => get_post_meta( $post->ID, self::META_PREFIX . 'extras', true ),
'comments' => get_post_meta( $post->ID, self::META_PREFIX . 'comments', true ),
'status' => get_post_meta( $post->ID, self::META_PREFIX . 'status', true ) ?: 'nowe',
);
$address = $meta['address'] ? json_decode( $meta['address'], true ) : null;
$address_str = '';
if ( $address ) {
$parts = array_filter( array(
isset( $address['street'] ) ? $address['street'] : '',
isset( $address['zipCode'] ) ? $address['zipCode'] : '',
isset( $address['city'] ) ? $address['city'] : '',
) );
$address_str = implode( ', ', $parts );
}
$extras = $meta['extras'] ? json_decode( $meta['extras'], true ) : array();
$extras_str = '';
if ( is_array( $extras ) && ! empty( $extras ) ) {
$names = array_map( function ( $e ) {
return isset( $e['name'] ) ? $e['name'] : '';
}, $extras );
$extras_str = implode( ', ', array_filter( $names ) );
}
$from_fmt = $meta['date_from'] ? date_i18n( 'd.m.Y H:i', strtotime( $meta['date_from'] ) ) : '—';
$to_fmt = $meta['date_to'] ? date_i18n( 'd.m.Y H:i', strtotime( $meta['date_to'] ) ) : '—';
?>
<table class="carei-meta-table">
<tr><th>Nr rezerwacji</th><td><?php echo esc_html( $meta['reservation_no'] ?: '—' ); ?></td></tr>
<tr><th>ID rezerwacji (Softra)</th><td><?php echo esc_html( $meta['reservation_id'] ?: '—' ); ?></td></tr>
<tr><th>ID klienta (Softra)</th><td><?php echo esc_html( $meta['customer_id'] ?: '—' ); ?></td></tr>
<tr class="carei-meta-divider"><td colspan="2"><hr></td></tr>
<tr><th>Segment</th><td><?php echo esc_html( $meta['segment'] ?: '—' ); ?></td></tr>
<tr><th>Data od</th><td><?php echo esc_html( $from_fmt ); ?></td></tr>
<tr><th>Data do</th><td><?php echo esc_html( $to_fmt ); ?></td></tr>
<tr><th>Oddział odbioru</th><td><?php echo esc_html( $meta['pickup_branch'] ?: '—' ); ?></td></tr>
<tr><th>Oddział zwrotu</th><td><?php echo esc_html( $meta['return_branch'] ?: $meta['pickup_branch'] ?: '—' ); ?></td></tr>
<tr class="carei-meta-divider"><td colspan="2"><hr></td></tr>
<tr><th>Imię</th><td><?php echo esc_html( $meta['first_name'] ?: '—' ); ?></td></tr>
<tr><th>Nazwisko</th><td><?php echo esc_html( $meta['last_name'] ?: '—' ); ?></td></tr>
<tr><th>Email</th><td><?php echo esc_html( $meta['email'] ?: '—' ); ?></td></tr>
<tr><th>Telefon</th><td><?php echo esc_html( $meta['phone'] ?: '—' ); ?></td></tr>
<tr><th>PESEL</th><td><?php echo esc_html( $meta['pesel'] ?: '—' ); ?></td></tr>
<tr><th>Adres</th><td><?php echo esc_html( $address_str ?: '—' ); ?></td></tr>
<tr class="carei-meta-divider"><td colspan="2"><hr></td></tr>
<tr><th>Opcje dodatkowe</th><td><?php echo esc_html( $extras_str ?: 'Brak' ); ?></td></tr>
<tr><th>Wiadomość</th><td><?php echo esc_html( $meta['comments'] ?: '—' ); ?></td></tr>
<tr class="carei-meta-divider"><td colspan="2"><hr></td></tr>
<tr>
<th>Status</th>
<td>
<select name="carei_status">
<?php foreach ( self::$statuses as $key => $info ) : ?>
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $meta['status'], $key ); ?>>
<?php echo esc_html( $info['label'] ); ?>
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
</table>
<?php
}
public function save_meta_box( $post_id, $post ) {
if ( ! isset( $_POST['carei_reservation_nonce'] ) ) {
return;
}
if ( ! wp_verify_nonce( $_POST['carei_reservation_nonce'], 'carei_save_reservation' ) ) {
return;
}
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
if ( isset( $_POST['carei_status'] ) ) {
$status = sanitize_text_field( $_POST['carei_status'] );
if ( isset( self::$statuses[ $status ] ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'status', $status );
}
}
}
// ─── Auto Mark Read ─────────────────────────────────────────
public function auto_mark_read( $post ) {
if ( $post->post_type !== self::POST_TYPE ) {
return;
}
$status = get_post_meta( $post->ID, self::META_PREFIX . 'status', true );
if ( $status === 'nowe' ) {
update_post_meta( $post->ID, self::META_PREFIX . 'status', 'przeczytane' );
}
}
// ─── Admin Styles ───────────────────────────────────────────
public function admin_styles() {
$screen = get_current_screen();
if ( ! $screen || $screen->post_type !== self::POST_TYPE ) {
return;
}
?>
<style>
.carei-status-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
color: #fff;
font-size: 12px;
font-weight: 600;
line-height: 1.4;
white-space: nowrap;
}
.carei-meta-table {
width: 100%;
border-collapse: collapse;
}
.carei-meta-table th {
text-align: left;
padding: 8px 12px;
width: 180px;
color: #1d2327;
font-weight: 600;
vertical-align: top;
}
.carei-meta-table td {
padding: 8px 12px;
color: #50575e;
}
.carei-meta-table tr:nth-child(odd):not(.carei-meta-divider) {
background: #f9f9f9;
}
.carei-meta-divider td {
padding: 4px 0;
}
.carei-meta-divider hr {
border: none;
border-top: 1px solid #e0e0e0;
margin: 0;
}
.carei-meta-table select {
min-width: 180px;
}
/* Hide title input — auto-generated */
#post-body-content #titlediv { display: none; }
</style>
<?php
}
// ─── Save Reservation (called from REST proxy) ──────────────
public static function save_reservation( $booking_data, $api_result ) {
$reservation_no = isset( $api_result['reservationNo'] ) ? $api_result['reservationNo'] : '';
$reservation_id = isset( $api_result['reservationId'] ) ? $api_result['reservationId'] : '';
$driver = array();
if ( isset( $booking_data['drivers'] ) && is_array( $booking_data['drivers'] ) && ! empty( $booking_data['drivers'] ) ) {
$driver = $booking_data['drivers'][0];
}
$first_name = isset( $driver['firstName'] ) ? $driver['firstName'] : '';
$last_name = isset( $driver['lastName'] ) ? $driver['lastName'] : '';
$title = sprintf( 'Rezerwacja #%s — %s %s', $reservation_no ?: $reservation_id, $first_name, $last_name );
$post_id = wp_insert_post( array(
'post_type' => self::POST_TYPE,
'post_title' => trim( $title ),
'post_status' => 'publish',
), true );
if ( is_wp_error( $post_id ) ) {
error_log( 'Carei: Failed to save reservation — ' . $post_id->get_error_message() );
return false;
}
$pickup_branch = '';
if ( isset( $booking_data['pickUpLocation']['branchName'] ) ) {
$pickup_branch = $booking_data['pickUpLocation']['branchName'];
}
$return_branch = '';
if ( isset( $booking_data['returnLocation']['branchName'] ) ) {
$return_branch = $booking_data['returnLocation']['branchName'];
}
$segment = '';
if ( isset( $booking_data['carParameters']['categoryName'] ) ) {
$segment = $booking_data['carParameters']['categoryName'];
}
$meta = array(
'reservation_no' => $reservation_no,
'reservation_id' => $reservation_id,
'customer_id' => isset( $booking_data['customerId'] ) ? $booking_data['customerId'] : '',
'segment' => $segment,
'date_from' => isset( $booking_data['dateFrom'] ) ? $booking_data['dateFrom'] : '',
'date_to' => isset( $booking_data['dateTo'] ) ? $booking_data['dateTo'] : '',
'pickup_branch' => $pickup_branch,
'return_branch' => $return_branch,
'first_name' => $first_name,
'last_name' => $last_name,
'email' => isset( $driver['email'] ) ? $driver['email'] : '',
'phone' => isset( $driver['phone'] ) ? $driver['phone'] : '',
'pesel' => isset( $driver['pesel'] ) ? $driver['pesel'] : '',
'address' => isset( $driver['address'] ) ? wp_json_encode( $driver['address'] ) : '',
'extras' => isset( $booking_data['priceItems'] ) ? wp_json_encode( $booking_data['priceItems'] ) : '',
'comments' => isset( $booking_data['comments'] ) ? $booking_data['comments'] : '',
'status' => 'nowe',
'raw_response' => wp_json_encode( $api_result ),
);
foreach ( $meta as $key => $value ) {
update_post_meta( $post_id, self::META_PREFIX . $key, $value );
}
return $post_id;
}
}

View File

@@ -30,6 +30,13 @@ class Carei_REST_Proxy {
'permission_callback' => '__return_true',
) );
// GET /segments-branches-map (cached segment→branches mapping)
register_rest_route( self::NAMESPACE, '/segments-branches-map', array(
'methods' => 'GET',
'callback' => array( $this, 'get_segments_branches_map' ),
'permission_callback' => '__return_true',
) );
// POST /car-classes
register_rest_route( self::NAMESPACE, '/car-classes', array(
'methods' => 'POST',
@@ -99,6 +106,17 @@ class Carei_REST_Proxy {
),
) );
// POST /booking/cancel
register_rest_route( self::NAMESPACE, '/booking/cancel', array(
'methods' => 'POST',
'callback' => array( $this, 'cancel_booking' ),
'permission_callback' => array( $this, 'check_nonce' ),
'args' => array(
'reservationId' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field' ),
'reason' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field' ),
),
) );
// GET /agreements
register_rest_route( self::NAMESPACE, '/agreements', array(
'methods' => 'GET',
@@ -141,6 +159,14 @@ class Carei_REST_Proxy {
// ─── Callbacks ────────────────────────────────────────────────
public function get_segments_branches_map( WP_REST_Request $request ) {
$api = $this->api();
if ( is_wp_error( $api ) ) {
return $api;
}
return $this->respond( $api->get_segments_branches_map() );
}
public function get_all_car_classes( WP_REST_Request $request ) {
$api = $this->api();
if ( is_wp_error( $api ) ) {
@@ -218,8 +244,15 @@ class Carei_REST_Proxy {
if ( is_wp_error( $api ) ) {
return $api;
}
$data = $request->get_json_params();
return $this->respond( $api->make_booking( $data ) );
$data = $request->get_json_params();
$result = $api->make_booking( $data );
// Save reservation to WP on success (fire-and-forget)
if ( ! is_wp_error( $result ) && isset( $result['success'] ) && $result['success'] ) {
Carei_Admin_Panel::save_reservation( $data, $result );
}
return $this->respond( $result );
}
public function confirm_booking( WP_REST_Request $request ) {
@@ -232,6 +265,17 @@ class Carei_REST_Proxy {
) );
}
public function cancel_booking( WP_REST_Request $request ) {
$api = $this->api();
if ( is_wp_error( $api ) ) {
return $api;
}
return $this->respond( $api->cancel_booking(
$request->get_param( 'reservationId' ),
$request->get_param( 'reason' )
) );
}
public function get_agreements( WP_REST_Request $request ) {
$api = $this->api();
if ( is_wp_error( $api ) ) {