Files
Reservair/includes/Views/RsvTimetablePage.php
T

375 lines
19 KiB
PHP
Raw Normal View History

2026-06-11 19:03:29 +02:00
<?php
use Reservair\Forms\RsvFormBuilder;
2026-06-12 16:05:14 +02:00
use Reservair\Layout\RsvColumnLayout;
class RsvTimetablePage extends RsvAdminPage {
protected function render_content(): void {
if (isset($_GET['action'])) {
if ($_GET['action'] === 'view' && isset($_GET['id'])) {
$this->show_view(intval($_GET['id']));
return;
} elseif ($_GET['action'] === 'edit' && isset($_GET['id'])) {
$this->show_capacity(intval($_GET['id']));
return;
}
// Deletion is intentionally not handled here: a state-changing GET is
// CSRF-prone. Timetables are deleted via the nonce-authenticated REST
// DELETE /timetable/{id} (see the "Trash" action in the list grid).
}
2026-06-11 19:03:29 +02:00
2026-06-12 16:05:14 +02:00
$this->show_list();
}
2026-06-11 19:03:29 +02:00
2026-06-12 16:05:14 +02:00
private function show_list(): void {
?>
<h1>Timetables</h1>
<hr>
<?php
$timetable_service = new RsvTimetableService();
$existing_emails = $timetable_service->get_all_maintainer_emails();
RsvColumnLayout::split('1:2')
->column(function () use ($existing_emails) {
echo RsvFormBuilder::create('add_timetable_form', get_rest_url(null, 'reservations/v1/timetable'), 'POST', 'Timetable created.')
->heading('Add timetable')
->datalist('maintainer_email_suggestions', $existing_emails)
->text('name', 'Name', 'Name of the timetable that can be reserved.', true)
->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, '', 1)
->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, '', 'maintainer_email_suggestions')
->submit('Add Timetable')
->render();
?>
<script>
RsvAdminForm.bind(add_timetable_form, {
transform: (body) => ({
name: body.name,
block_size: parseInt(body.block_size),
maintainer_email: body.maintainer_email || null,
}),
refresh: () => availability_dt.refresh(),
});
</script>
<?php })
->column(function () { ?>
<div id="availability_table"></div>
2026-06-11 19:03:29 +02:00
<script>
var availability_dt = RsvDataGrid.create_data_grid(availability_table,
RsvTimetableResource(), {
'id': RsvDataGrid.column('ID', false, 20),
'name': RsvDataGrid.action_column('Název', false, {
'View': RsvDataGrid.link_action((data) => `<?= menu_page_url('timetable-settings', false) ?>&id=${data.id}&action=view`),
'Edit': RsvDataGrid.link_action((data) => `<?= menu_page_url('timetable-settings', false) ?>&id=${data.id}&action=edit`),
'Trash': RsvDataGrid.func_action((dt, row, data) => {
if (confirm('Delete this timetable? This cannot be undone.')) {
dt.resource.delete(data.id).then(() => dt.refresh());
}
}),
}),
'block_size': RsvDataGrid.column('Velikost bloku', false),
});
availability_dt.refresh();
</script>
2026-06-12 16:05:14 +02:00
<?php })
->output();
?>
<?php
}
2026-06-11 19:03:29 +02:00
2026-06-12 16:05:14 +02:00
private function show_view(int $id): void {
$timetable = (new RsvTimetableService())->get($id);
if ($timetable === null) {
echo '<div class="notice notice-error"><p>Timetable not found.</p></div>';
return;
}
?>
<h1><?= esc_html($timetable->name) ?></h1>
<a href="<?= esc_url(menu_page_url('timetable-settings', false)) ?>">← Back to Timetables</a>
<hr>
<table class="form-table">
<tr><th>Name</th><td><?= esc_html($timetable->name) ?></td></tr>
<tr><th>Block size</th><td><?= esc_html($timetable->block_size) ?> minutes</td></tr>
<?php if ($timetable->maintainer_email): ?>
<tr><th>Maintainer email</th><td><?= esc_html($timetable->maintainer_email) ?></td></tr>
<?php endif; ?>
</table>
<h2>Reservations</h2>
<div id="timetable_reservations_table"></div>
<script>
RsvDataGrid.create_data_grid(
timetable_reservations_table,
RsvTimetableReservationResource(<?= $id ?>),
{
'id': RsvDataGrid.column('ID', false),
'reservation_id': RsvDataGrid.action_column('Reservation', false, {
'Accept': RsvDataGrid.func_action(
(dt, row, data) => RsvReservationClient.accept(data.reservation_id).then(() => dt.refresh()),
(item) => item.pending_confirmation_id !== null
),
'Refuse': RsvDataGrid.func_action(
(dt, row, data) => RsvReservationClient.refuse(data.reservation_id).then(() => dt.refresh()),
(item) => item.pending_confirmation_id !== null
),
}),
'start_utc': RsvDataGrid.column('Start', false),
'end_utc': RsvDataGrid.column('End', false),
'is_confirmed': RsvDataGrid.column('Status', false),
}
)
.map_column('is_confirmed', (dt, row, data) => {
const td = document.createElement('td');
td.textContent = data.pending_confirmation_id !== null ? 'Pending'
: data.is_confirmed == 1 ? 'Confirmed'
: 'Refused';
return td;
})
.refresh();
</script>
<?php
}
2026-06-11 19:03:29 +02:00
2026-06-12 16:05:14 +02:00
private function show_capacity(int $id): void {
$timetable_service = new RsvTimetableService();
$timetable = $timetable_service->get($id);
$gcal_service = new RsvGoogleCalendarService();
$gcal_connected = $gcal_service->is_google_connected();
$current_calendar_id = $timetable->google_calendar_id ?? null;
$existing_emails = $timetable_service->get_all_maintainer_emails();
?>
2026-06-11 19:03:29 +02:00
<?php
2026-06-12 16:05:14 +02:00
RsvFormBuilder::create('timetable_settings_form', get_rest_url(null, 'reservations/v1/timetable/' . $id), 'PATCH', 'Settings saved.')
->heading('Settings')
->datalist('maintainer_email_suggestions', $existing_emails)
2026-06-11 19:03:29 +02:00
->text('name', 'Name', 'Name of the timetable that can be reserved.', true, $timetable->name)
->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, $timetable->block_size, 1)
->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, $timetable->maintainer_email ?? '', 'maintainer_email_suggestions')
->custom('Google Calendar', function() use ($gcal_connected) {
if (!$gcal_connected) {
return'<p>Not connected to Google Calendar.
<a href="' . esc_url(admin_url('admin.php?page=rsv-google-calendar')) . '">Connect in settings &rarr;</a>
</p>';
} else {
return '<select id="gcal_select" name="google_calendar_id" style="min-width:260px;">
<option value="">— None —</option>
</select>
<p class="description">Sync reservations to this calendar.</p>';
}
})
->submit('Save Settings', 'button-primary', 'submit')
->output();
?>
2026-06-12 16:05:14 +02:00
<script>
(function() {
const gcalSelect = document.getElementById('gcal_select');
const currentCalId = <?= json_encode($current_calendar_id) ?>;
if (gcalSelect) {
fetch('<?= esc_js(get_rest_url(null, 'reservations/v1/google-calendars')) ?>', {
credentials: 'same-origin',
headers: { 'X-WP-Nonce': ReservairServiceAPI.nonce },
})
.then(r => r.json())
.then(calendars => {
for (const cal of calendars) {
const opt = document.createElement('option');
opt.value = cal.id;
opt.textContent = cal.summary;
if (cal.id === currentCalId) opt.selected = true;
gcalSelect.appendChild(opt);
}
})
.catch(() => {
gcalSelect.disabled = true;
gcalSelect.options[0].textContent = 'Failed to load calendars';
});
}
2026-06-11 19:03:29 +02:00
2026-06-12 16:05:14 +02:00
RsvAdminForm.bind(timetable_settings_form);
})();
</script>
2026-06-11 19:03:29 +02:00
2026-06-12 16:05:14 +02:00
<h2>Capacity</h2>
2026-06-11 19:03:29 +02:00
2026-06-12 16:05:14 +02:00
<p>Define capacities for timetable.</p>
2026-06-11 19:03:29 +02:00
2026-06-12 16:05:14 +02:00
<?php $this->create_capacity_form($id); ?>
2026-06-11 19:03:29 +02:00
2026-06-12 16:05:14 +02:00
<script>
(function() {
const DAY_DOW = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 };
2026-06-11 19:03:29 +02:00
2026-06-12 16:05:14 +02:00
function nearest_weekday(base_str, dow) {
const d = new Date(base_str + 'T00:00:00');
const diff = (dow - d.getDay() + 7) % 7;
d.setDate(d.getDate() + diff);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
2026-06-11 19:03:29 +02:00
2026-06-12 16:05:14 +02:00
RsvAdminForm.bind(document.getElementById('create_capacity_form'), {
// Expand one form submission into one capacity row per selected weekday.
transform: (body, form) => {
const fd = new FormData(form);
const is_repeating = fd.get('is_repeating') !== null;
const repeat_period_in_days = is_repeating
? parseInt(fd.get('repeat_period_in_days')) * parseInt(fd.get('repeat_period_multiplier'))
: 1;
const repeat_times = is_repeating ? parseInt(fd.get('repeat_times')) : 0;
const common = {
start_time: time_to_minutes(fd.get('start_time')),
end_time: time_to_minutes(fd.get('end_time')),
capacity: parseInt(fd.get('capacity')),
min_lead_time_minutes: parseInt(fd.get('min_lead_time_minutes')),
requires_confirmation: fd.get('requires_confirmation') !== null,
repeat_period_in_days,
repeat_times,
};
const base_date = fd.get('date');
const selected_days = Object.keys(DAY_DOW).filter(day => fd.get(day) !== null);
return selected_days.length > 0
? selected_days.map(day => ({ ...common, date: nearest_weekday(base_date, DAY_DOW[day]) }))
: [{ ...common, date: base_date }];
},
refresh: () => capacity_dt.refresh(),
});
})();
</script>
<div id="capacity_table"></div>
<script>
function minutes_to_time(minutes) {
const h = Math.floor(minutes / 60).toString().padStart(2, '0');
const m = (minutes % 60).toString().padStart(2, '0');
return `${h}:${m}`;
}
2026-06-11 19:03:29 +02:00
2026-06-12 16:05:14 +02:00
function time_to_minutes(time_str) {
const [h, m] = time_str.split(':').map(Number);
return h * 60 + m;
}
2026-06-11 19:03:29 +02:00
2026-06-12 16:05:14 +02:00
function rsv_render_capacity_inline_form(dt, row, data) {
const resource_with_time_conversion = {
...dt.resource,
put(id, form_data) {
return dt.resource.put(id, {
...form_data,
start_time: time_to_minutes(form_data.start_time),
end_time: time_to_minutes(form_data.end_time),
});
},
2026-06-11 19:03:29 +02:00
};
2026-06-12 16:05:14 +02:00
return RsvInlineFormBuilder.create(resource_with_time_conversion)
.input_hidden('min_lead_time_minutes', 0)
.fieldset('Datum a čas', '50%')
.input_date('date', 'Datum', data?.date ?? '')
.input_time('start_time', 'Začátek', minutes_to_time(data?.start_time ?? 0))
.input_time('end_time', 'Konec', minutes_to_time(data?.end_time ?? 0))
.fieldset('Kapacita a opakování', '50%')
.input_number('capacity', 'Kapacita', data?.capacity ?? '')
.input_number('repeat_period_in_days', 'Dnů mezi opakováním', data?.repeat_period_in_days ?? '')
.input_number('repeat_times', 'Počet opakování', data?.repeat_times ?? '')
.input_checkbox('requires_confirmation', 'Vyžaduje potvrzení', data?.requires_confirmation == 1)
.build({
id: data?.id,
colspan: 8,
save_label: 'Aktualizovat',
on_success: () => capacity_dt.refresh(),
on_cancel: () => capacity_dt.refresh(),
2026-06-11 19:03:29 +02:00
});
2026-06-12 16:05:14 +02:00
}
2026-06-11 19:03:29 +02:00
2026-06-12 16:05:14 +02:00
var capacity_dt = RsvDataGrid.create_data_grid(capacity_table,
RsvTimetableCapacityResource(<?= $id ?>),
{
'id': RsvDataGrid.column('ID', false),
'date': RsvDataGrid.action_column('Datum', false, {
'Edit': RsvDataGrid.edit_action((dt, row, data) => {
row.classList.add('inline-edit-row', 'inline-edit-row-post', 'quick-edit-row', 'quick-edit-row-post', 'inline-edit-post', 'inline-editor');
row.replaceChildren(rsv_render_capacity_inline_form(dt, row, data));
}),
'Trash': RsvDataGrid.func_action((dt, row, data) => {
dt.resource.delete(data.id).then(() => dt.refresh());
}),
2026-06-11 19:03:29 +02:00
}),
2026-06-12 16:05:14 +02:00
'start_time': RsvDataGrid.column('Začátek', false),
'end_time': RsvDataGrid.column('Konec', false),
'capacity': RsvDataGrid.column('Kapacita', false),
'repeat_period_in_days': RsvDataGrid.column('Dnů mezi opakováním', false),
'repeat_times': RsvDataGrid.column('Počet opakování', false),
'requires_confirmation': RsvDataGrid.column('Vyžaduje potvrzení', false),
}, );
capacity_dt.map_column('start_time', (dt, row, data) => {
const td = document.createElement('td');
td.innerText = minutes_to_time(data.start_time);
return td;
});
capacity_dt.map_column('end_time', (dt, row, data) => {
const td = document.createElement('td');
td.innerText = minutes_to_time(data.end_time);
return td;
});
2026-06-11 19:03:29 +02:00
2026-06-12 16:05:14 +02:00
capacity_dt.refresh();
</script>
<?php
}
2026-06-11 19:03:29 +02:00
2026-06-12 16:05:14 +02:00
private function create_capacity_form(int $timetable_id): void {
$form = RsvFormBuilder::create('create_capacity_form', get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity'), 'POST', 'Capacity created.');
$form->date('date', 'First Date', 'Od kterého datumu platí tato kapacita.', true, new DateTime()->format('Y-m-d'));
$form->group('Availability Range', fn($g) => $g
->time('start_time', 'Start')
->time('end_time', 'End')
);
$form->number('capacity', 'Capacity', 'How many reservations can overlap on the same time.', true, 1, 1);
$form->number('min_lead_time_minutes', 'Minimum lead time (minutes)', 'How many minutes in advance must be the reservation created. This is useful if it takes some time to prepare the reservation.', true);
$form->checkbox('requires_confirmation', 'Requires Confirmation?', 'If checked, all the reservations that overlap this capacity will require confirmation from maintainer. The maintainer will receive an email asking for the confirmation.', true);
$form->custom('Is Repeating Event', function() {
return '
<input id="is_repeating" class="regular-text" type="checkbox" name="is_repeating" checked="true">
<p>If the capacity is available repeatingly. For example: repeat each monday every week.</p>
';
});
$form->number('repeat_period_in_days', 'Repeat Period (days)', 'How many days between each repetition.', true);
$form->custom('Apply to Days', function() {
return '
<table class="option-table">
<tbody>
<tr class="form-day-names">
<td>Monday</td>
<td>Tuesday</td>
<td>Wednesday</td>
<td>Thursday</td>
<td>Friday</td>
<td>Saturday</td>
<td>Sunday</td>
</tr>
<tr class="form-days">
<td><input class="is-repeating-input" type="checkbox" name="monday"></td>
<td><input class="is-repeating-input" type="checkbox" name="tuesday"></td>
<td><input class="is-repeating-input" type="checkbox" name="wednesday"></td>
<td><input class="is-repeating-input" type="checkbox" name="thursday"></td>
<td><input class="is-repeating-input" type="checkbox" name="friday"></td>
<td><input class="is-repeating-input" type="checkbox" name="saturday"></td>
<td><input class="is-repeating-input" type="checkbox" name="sunday"></td>
</tr>
</tbody>
</table>';
});
$form->submit('Create Capacity', 'button-primary', 'submit');
2026-06-11 19:03:29 +02:00
2026-06-12 16:05:14 +02:00
$form->output();
2026-06-11 19:03:29 +02:00
}
}