Add visual worker calendar and floating lunch break system

- Monthly calendar grid per worker with colour-coded day status
- Override types: vacation, sick, custom hours, other, day-off
- Date-range override support via modal
- Floating 30-min lunch break: finds slot closest to preferred time
  within configurable window, adapts to existing bookings
- Lunch break only applies for shifts >= 6 hours
- Lunch slot shown in admin calendar; blocked in booking availability
- DB migrations: absence_type/note on worker_schedule_overrides,
  lunch_window_start/end/preferred_time on workers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 13:34:58 +01:00
co-authored by Claude Sonnet 4.6
parent 993e0cb8b1
commit b8f4f67b23
7 changed files with 771 additions and 47 deletions
+248 -42
View File
@@ -651,7 +651,10 @@ class Admin extends CI_Controller {
'service_category_id' => $_POST['service_category_id'],
'is_active' => $_POST['is_active'],
'google_calendar_id' => isset($_POST['google_calendar_id']) ? $_POST['google_calendar_id'] : '',
'ntfy_topic' => isset($_POST['ntfy_topic']) ? $_POST['ntfy_topic'] : ''
'ntfy_topic' => isset($_POST['ntfy_topic']) ? $_POST['ntfy_topic'] : '',
'lunch_window_start' => !empty($_POST['lunch_window_start']) ? $_POST['lunch_window_start'] : null,
'lunch_window_end' => !empty($_POST['lunch_window_end']) ? $_POST['lunch_window_end'] : null,
'lunch_preferred_time' => !empty($_POST['lunch_preferred_time']) ? $_POST['lunch_preferred_time'] : null,
);
$this->Service_model->updateWorker($_POST['worker_id'], $workerArray);
$this->Log_model->addLog('workers', $data['currentUser']->username.' módosította a(z) '.$_POST['worker_name'].' nevű dolgozót ('.implode(',',$workerArray).')', $data['currentUser']->username);
@@ -798,6 +801,24 @@ class Admin extends CI_Controller {
if(isset($_GET['delete-booking'])){
$selectedBooking = $this->Service_model->getBookingById($_GET['delete-booking']);
// Google Calendar: delete events before removing booking
$this->load->library('GoogleCalendar');
$this->config->load('google_calendar');
$delWorker = $this->Service_model->getWorkerById($selectedBooking->worker_id);
if(!empty($selectedBooking->gcal_event_id_worker) && $delWorker && !empty($delWorker->google_calendar_id)){
$this->googlecalendar->deleteEvent($delWorker->google_calendar_id, $selectedBooking->gcal_event_id_worker);
}
$evelinCalIdDel = $this->config->item('gcal_evelin_calendar_id');
if(!empty($selectedBooking->gcal_event_id_owner) && !empty($evelinCalIdDel)){
$this->googlecalendar->deleteEvent($evelinCalIdDel, $selectedBooking->gcal_event_id_owner);
}
// ntfy push notification
$this->_ntfy(
'Admin deleted booking: ' . $selectedBooking->guest_name,
'Worker: ' . ($delWorker ? $delWorker->worker_name : '?') . "\n" .
'Date: ' . $selectedBooking->booking_date . ' ' . $selectedBooking->booking_start_time,
($delWorker && isset($delWorker->ntfy_topic)) ? $delWorker->ntfy_topic : null
);
$this->Service_model->deleteBooking($_GET['delete-booking']);
$this->Log_model->addLog('bookings', $data['currentUser']->username.' törölte a(z) '.$_GET['delete-booking'].' számú foglalást ('.$selectedBooking->guest_name.', '.$selectedBooking->guest_email.', '.$selectedBooking->guest_phone.', '.$selectedBooking->booking_date.', '.$selectedBooking->booking_start_time.', '.$selectedBooking->booking_finish_time.', '.$selectedBooking->service_ids.')', $data['currentUser']->username);
header('Location:'.SITEURL.'bookings');
@@ -852,7 +873,7 @@ class Admin extends CI_Controller {
$this->Log_model->addLog('bookings', $data['currentUser']->username.' létrehoza a(z) '.$_POST['guest_name'].' nevű vendég foglalást ('.implode(', ', $bookingArray).')', $data['currentUser']->username);
$this->Service_model->createBooking($bookingArray);
}
if($_POST['send_notification']){
$worker = $this->Service_model->getWorkerById($bookingArray['worker_id']);
@@ -961,10 +982,6 @@ class Admin extends CI_Controller {
}
if(isset($_POST['updateBookingManual'])){
$booking_date = new DateTime(date('Y-m-d', strtotime($_POST['booking_date'])));
$end_booking_date = new DateTime(date('Y-m-d', strtotime($_POST['end_booking_date'])));
$diff = $booking_date->diff($end_booking_date);
$selectedServiceArray = array();
if($_POST['service_ids'] != ''){
@@ -972,45 +989,66 @@ class Admin extends CI_Controller {
}
if($_POST['booking_date'] != $_POST['end_booking_date'] && $booking_date <= $end_booking_date){
for($index = 0; $index <= $diff->days; $index++){
$selectedDate = date('Y-m-d', strtotime($_POST['booking_date']. ' + '.$index.' days'));
$bookingArray = array(
'guest_name' => $_POST['guest_name'],
'guest_email' => $_POST['guest_email'],
'guest_phone' => $_POST['guest_phone'],
'worker_id' => $_POST['worker_id'],
'booking_date' => $selectedDate,
'booking_start_time' => $_POST['booking_start_time'],
'booking_finish_time' => $_POST['booking_finish_time'],
'service_ids' => serialize($selectedServiceArray),
'guest_confirmed' => '0',
'guest_confirm_code' => '1234'
);
$this->Service_model->createBooking($bookingArray);
$bookingArray = array(
'guest_name' => $_POST['guest_name'],
'guest_email' => $_POST['guest_email'],
'guest_phone' => $_POST['guest_phone'],
'worker_id' => $_POST['worker_id'],
'booking_date' => $_POST['booking_date'],
'booking_start_time' => $_POST['booking_start_time'],
'booking_finish_time' => $_POST['booking_finish_time'],
'service_ids' => serialize($selectedServiceArray),
'guest_confirmed' => '0',
'guest_confirm_code' => '1234'
);
$oldBooking = $this->Service_model->getBookingById($_POST['booking_id']);
$this->Service_model->updateBooking($_POST['booking_id'], $bookingArray);
$this->Log_model->addLog('bookings', $data['currentUser']->username.' módosította a foglalását a(z) '.$bookingArray['guest_name'].' nevű vendégnek ('.implode(', ', $bookingArray).')', $data['currentUser']->username);
// Google Calendar: delete old events, create new ones
$this->load->library('GoogleCalendar');
$this->config->load('google_calendar');
$evelinCalId = $this->config->item('gcal_evelin_calendar_id');
if($oldBooking){
$oldWorker = $this->Service_model->getWorkerById($oldBooking->worker_id);
if(!empty($oldBooking->gcal_event_id_worker) && $oldWorker && !empty($oldWorker->google_calendar_id)){
$this->googlecalendar->deleteEvent($oldWorker->google_calendar_id, $oldBooking->gcal_event_id_worker);
}
if(!empty($oldBooking->gcal_event_id_owner) && !empty($evelinCalId)){
$this->googlecalendar->deleteEvent($evelinCalId, $oldBooking->gcal_event_id_owner);
}
}
else{
$bookingArray = array(
'guest_name' => $_POST['guest_name'],
'guest_email' => $_POST['guest_email'],
'guest_phone' => $_POST['guest_phone'],
'worker_id' => $_POST['worker_id'],
'booking_date' => $_POST['booking_date'],
'booking_start_time' => $_POST['booking_start_time'],
'booking_finish_time' => $_POST['booking_finish_time'],
'service_ids' => serialize($selectedServiceArray),
'guest_confirmed' => '0',
'guest_confirm_code' => '1234'
);
$this->Service_model->updateBooking($_POST['booking_id'], $bookingArray);
$this->Log_model->addLog('bookings', $data['currentUser']->username.' módosította a foglalását a(z) '.$bookingArray['guest_name'].' nevű vendégnek ('.implode(', ', $bookingArray).')', $data['currentUser']->username);
$gcalWorker = $this->Service_model->getWorkerById($bookingArray['worker_id']);
$gcalServices = unserialize($bookingArray['service_ids']);
$gcalServiceArray = array();
if(is_array($gcalServices)){
foreach($gcalServices as $sid){
$svc = $this->Service_model->getServiceById($sid);
if(is_object($svc)) $gcalServiceArray[] = $svc;
}
}
$gcalServiceNames = implode(', ', array_map(function($s){ return $s->service_name_no; }, $gcalServiceArray));
$gcalTitle = $gcalWorker->worker_name . ' — ' . $gcalServiceNames . ' — ' . $bookingArray['guest_name'];
$gcalDescription = 'Guest: ' . $bookingArray['guest_name'] . "\nPhone: " . $bookingArray['guest_phone'] . "\nEmail: " . $bookingArray['guest_email'];
$gcalStart = $bookingArray['booking_date'] . 'T' . $bookingArray['booking_start_time'];
$gcalEnd = $bookingArray['booking_date'] . 'T' . $bookingArray['booking_finish_time'];
$newWorkerEventId = null;
$newOwnerEventId = null;
if(!empty($gcalWorker->google_calendar_id)){
$newWorkerEventId = $this->googlecalendar->createEvent($gcalWorker->google_calendar_id, $gcalTitle, $gcalDescription, $gcalStart, $gcalEnd);
}
if(!empty($evelinCalId)){
$newOwnerEventId = $this->googlecalendar->createEvent($evelinCalId, $gcalTitle, $gcalDescription, $gcalStart, $gcalEnd);
}
$this->Service_model->updateBookingCalEvents($_POST['booking_id'], $newWorkerEventId, $newOwnerEventId);
// ntfy push notification
$this->_ntfy(
'Admin modified booking: ' . $bookingArray['guest_name'],
'Worker: ' . $gcalWorker->worker_name . "\n" .
'Date: ' . $bookingArray['booking_date'] . ' ' . $bookingArray['booking_start_time'] . ' - ' . $bookingArray['booking_finish_time'] . "\n" .
'Services: ' . $gcalServiceNames,
isset($gcalWorker->ntfy_topic) ? $gcalWorker->ntfy_topic : null
);
if($_POST['send_notification']){
$worker = $this->Service_model->getWorkerById($bookingArray['worker_id']);
@@ -1126,6 +1164,26 @@ class Admin extends CI_Controller {
}
}
private function _ntfy($title, $message, $workerTopic = null){
$this->config->load('google_calendar');
$evelinTopic = $this->config->item('ntfy_topic');
$topics = [];
if(!empty($evelinTopic)) $topics[] = $evelinTopic;
if(!empty($workerTopic) && $workerTopic !== $evelinTopic) $topics[] = $workerTopic;
if(empty($topics)) return;
foreach($topics as $topic){
$ch = curl_init('https://ntfy.sh/' . $topic);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $message,
CURLOPT_HTTPHEADER => ['Title: ' . $title, 'Priority: high', 'Tags: calendar'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
curl_exec($ch);
curl_close($ch);
}
}
@@ -1143,9 +1201,157 @@ class Admin extends CI_Controller {
public function worker_calendar($worker_id = null) {
$this->load->helper('url');
$this->load->model('Service_model');
$this->load->model('User_model');
$this->load->model('Module_model');
if (!isset($_SESSION['username'])) {
header("Location:".SITEURL."login");
exit;
}
$data['currentUser'] = $this->User_model->getUserByUsername($_SESSION['username']);
if ($this->User_model->is_user_admin($_SESSION['username'])) {
$data['activeModules'] = $this->Module_model->getAllModule();
} else {
$data['activeModules'] = $this->Module_model->getUserModules($data['currentUser']->user_id);
}
$data['workers'] = $this->Service_model->getAllWorkers();
$data['selectedWorker'] = null;
$data['overridesByDate'] = [];
$data['scheduleRows'] = [];
$monthParam = $this->input->get('month');
if ($monthParam && preg_match('/^\d{4}-\d{2}$/', $monthParam)) {
list($year, $month) = explode('-', $monthParam);
$year = (int)$year;
$month = (int)$month;
} else {
$year = (int)date('Y');
$month = (int)date('m');
}
$data['year'] = $year;
$data['month'] = $month;
if ($worker_id) {
$data['selectedWorker'] = $this->Service_model->getWorkerById($worker_id);
$data['overridesByDate'] = $this->Service_model->getWorkerOverridesByMonth($worker_id, $year, $month);
$data['scheduleRows'] = $this->Service_model->getWorkerSchedule($worker_id);
}
// Compute lunch break slot for every working day of the month
$data['lunchByDate'] = [];
$sw = $data['selectedWorker'];
if ($sw && !empty($sw->lunch_window_start) && !empty($sw->lunch_window_end)) {
$monthBookings = $this->Service_model->getBookingsForWorkerMonth($worker_id, $year, $month);
$scheduleMap = [];
foreach ($data['scheduleRows'] as $row) {
$scheduleMap[$row->weekday] = $row;
}
$daysInMonth = (int)date('t', mktime(0, 0, 0, $month, 1, $year));
for ($d = 1; $d <= $daysInMonth; $d++) {
$dateStr = $year . '-' . str_pad($month, 2, '0', STR_PAD_LEFT) . '-' . str_pad($d, 2, '0', STR_PAD_LEFT);
$override = isset($data['overridesByDate'][$dateStr]) ? $data['overridesByDate'][$dateStr] : null;
if ($override && $override->is_day_off) continue;
if ($override && !$override->is_day_off && $override->end_time) {
$shiftStart = new DateTime($dateStr . ' ' . $override->start_time);
$shiftEnd = new DateTime($dateStr . ' ' . $override->end_time);
} else {
$phpWeekday = (int)date('w', strtotime($dateStr));
if (!isset($scheduleMap[$phpWeekday])) continue;
$shiftStart = new DateTime($dateStr . ' ' . $scheduleMap[$phpWeekday]->start_time);
$shiftEnd = new DateTime($dateStr . ' ' . $scheduleMap[$phpWeekday]->end_time);
}
$dayBookings = isset($monthBookings[$dateStr]) ? $monthBookings[$dateStr] : [];
$lunch = $this->Service_model->computeLunchBreak(
$sw->lunch_window_start, $sw->lunch_window_end,
$dateStr, $dayBookings, $shiftEnd, $shiftStart,
isset($sw->lunch_preferred_time) ? $sw->lunch_preferred_time : null
);
if ($lunch) $data['lunchByDate'][$dateStr] = $lunch;
}
}
$data['pageTitle'] = 'Munkaidő naptár';
$this->load->view('admin/worker_calendar', $data);
}
public function store_calendar_override() {
$this->load->model('Service_model');
$this->load->helper('url');
if (!isset($_SESSION['username'])) {
header("Location:".SITEURL."login");
exit;
}
$worker_id = $this->input->post('worker_id');
$start_date = $this->input->post('start_date');
$end_date = $this->input->post('end_date') ?: $start_date;
$type = $this->input->post('override_type');
$start_time = $this->input->post('start_time');
$end_time = $this->input->post('end_time');
$note = $this->input->post('note');
$current = strtotime($start_date);
$last = strtotime($end_date);
$guard = 0;
while ($current <= $last && $guard < 366) {
$dateStr = date('Y-m-d', $current);
if ($type === 'remove') {
$this->Service_model->deleteWorkerScheduleOverrideByDate($worker_id, $dateStr);
} else {
$data = [];
if ($type === 'day_off') {
$data['is_day_off'] = 1;
$data['absence_type'] = null;
$data['start_time'] = null;
$data['end_time'] = null;
$data['note'] = null;
} elseif ($type === 'custom') {
$data['is_day_off'] = 0;
$data['absence_type'] = 'custom';
$data['start_time'] = $start_time;
$data['end_time'] = $end_time;
$data['note'] = $note;
} else {
// vacation, sick, other
$data['is_day_off'] = 1;
$data['absence_type'] = $type;
$data['start_time'] = null;
$data['end_time'] = null;
$data['note'] = $note;
}
$this->Service_model->upsertWorkerScheduleOverride($worker_id, $dateStr, $data);
}
$current = strtotime('+1 day', $current);
$guard++;
}
$monthParam = date('Y-m', strtotime($start_date));
redirect('workers/worker-calendar/' . $worker_id . '?month=' . $monthParam);
}
public function delete_calendar_override() {
$this->load->model('Service_model');
$this->load->helper('url');
if (!isset($_SESSION['username'])) {
header("Location:".SITEURL."login");
exit;
}
$worker_id = $this->input->post('worker_id');
$date = $this->input->post('date');
$this->Service_model->deleteWorkerScheduleOverrideByDate($worker_id, $date);
$monthParam = date('Y-m', strtotime($date));
redirect('workers/worker-calendar/' . $worker_id . '?month=' . $monthParam);
}
}