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:
@@ -96,10 +96,8 @@ class Service_model extends CI_Model {
|
||||
|
||||
public function updateBooking($booking_id, $bookingArray){
|
||||
$this->load->database();
|
||||
|
||||
foreach($bookingArray as $propertyKey => $propertyValue){
|
||||
$query = $this->db->query("UPDATE bookings SET ".$propertyKey." = '".$propertyValue."' WHERE booking_id = '".$booking_id."';");
|
||||
}
|
||||
$this->db->where('booking_id', $booking_id);
|
||||
$this->db->update('bookings', $bookingArray);
|
||||
}
|
||||
|
||||
public function getAllServiceByServiceType($service_type, $lang){
|
||||
@@ -490,6 +488,67 @@ class Service_model extends CI_Model {
|
||||
log_message('error', '⏳ Min start (next 15min +1h, Europe/Oslo): ' . $dayStartTime->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
// --- Lunch break: find free 30-min slot closest to preferred time within worker's lunch window ---
|
||||
$lunchBlockStart = null;
|
||||
$lunchBlockEnd = null;
|
||||
|
||||
$workerRow = $this->db->query("SELECT lunch_window_start, lunch_window_end, lunch_preferred_time FROM workers WHERE worker_id = '$worker_id' LIMIT 1")->row();
|
||||
$shiftSeconds = $finishTime->getTimestamp() - $dayStartTime->getTimestamp();
|
||||
if ($workerRow && $workerRow->lunch_window_start && $workerRow->lunch_window_end && $shiftSeconds >= 6 * 3600) {
|
||||
$allBookings = $this->db->query("
|
||||
SELECT booking_start_time, booking_finish_time FROM bookings
|
||||
WHERE booking_date = '$selectedDate' AND worker_id = '$worker_id'
|
||||
")->result();
|
||||
|
||||
$lunchWinStart = new DateTime($selectedDate . ' ' . $workerRow->lunch_window_start);
|
||||
$lunchWinEnd = new DateTime($selectedDate . ' ' . $workerRow->lunch_window_end);
|
||||
$lunchDuration = new DateInterval('PT30M');
|
||||
$lunchStep = new DateInterval('PT15M');
|
||||
|
||||
// Clamp window to actual shift hours
|
||||
if ($lunchWinStart < $dayStartTime) $lunchWinStart = clone $dayStartTime;
|
||||
if ($lunchWinEnd > $finishTime) $lunchWinEnd = clone $finishTime;
|
||||
|
||||
$scanEnd = (clone $lunchWinEnd)->sub($lunchDuration);
|
||||
|
||||
$isFree = function($c) use ($lunchDuration, $allBookings, $selectedDate) {
|
||||
$cEnd = (clone $c)->add($lunchDuration);
|
||||
foreach ($allBookings as $bk) {
|
||||
$bkS = new DateTime($selectedDate . ' ' . $bk->booking_start_time);
|
||||
$bkE = new DateTime($selectedDate . ' ' . $bk->booking_finish_time);
|
||||
if ($bkS < $cEnd && $bkE > $c) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Collect free slots inside window, pick closest to preferred time
|
||||
$freeSlots = [];
|
||||
for ($c = clone $lunchWinStart; $c <= $scanEnd; $c->add($lunchStep)) {
|
||||
if ($isFree($c)) $freeSlots[] = clone $c;
|
||||
}
|
||||
|
||||
if (!empty($freeSlots)) {
|
||||
$prefDt = $workerRow->lunch_preferred_time
|
||||
? new DateTime($selectedDate . ' ' . $workerRow->lunch_preferred_time)
|
||||
: clone $lunchWinStart;
|
||||
$best = null;
|
||||
$bestDiff = PHP_INT_MAX;
|
||||
foreach ($freeSlots as $slot) {
|
||||
$diff = abs($slot->getTimestamp() - $prefDt->getTimestamp());
|
||||
if ($diff < $bestDiff) { $bestDiff = $diff; $best = $slot; }
|
||||
}
|
||||
$lunchBlockStart = $best;
|
||||
$lunchBlockEnd = (clone $best)->add($lunchDuration);
|
||||
} else {
|
||||
// Fallback: search after the window
|
||||
for ($c = clone $lunchWinEnd; ; $c->add($lunchStep)) {
|
||||
$candEnd = (clone $c)->add($lunchDuration);
|
||||
if ($candEnd > $finishTime) break;
|
||||
if ($isFree($c)) { $lunchBlockStart = clone $c; $lunchBlockEnd = $candEnd; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$interval = new DateInterval('PT15M');
|
||||
list($h, $m) = explode(':', $servicelength);
|
||||
$serviceDuration = new DateInterval('PT' . (int)$h . 'H' . (int)$m . 'M');
|
||||
@@ -511,7 +570,8 @@ class Service_model extends CI_Model {
|
||||
)
|
||||
");
|
||||
|
||||
if ($bookingQuery->num_rows() == 0) {
|
||||
$overlapsLunch = $lunchBlockStart && ($current < $lunchBlockEnd) && ($slotEnd > $lunchBlockStart);
|
||||
if ($bookingQuery->num_rows() == 0 && !$overlapsLunch) {
|
||||
$availableTimeArray[] = $startTimeStr;
|
||||
}
|
||||
|
||||
@@ -608,6 +668,120 @@ class Service_model extends CI_Model {
|
||||
public function createWorkerScheduleOverride($override) {
|
||||
$this->db->insert('worker_schedule_overrides', $override);
|
||||
}
|
||||
|
||||
public function getBookingsForWorkerMonth($worker_id, $year, $month) {
|
||||
$this->load->database();
|
||||
$yearMonth = $year . '-' . str_pad($month, 2, '0', STR_PAD_LEFT);
|
||||
$query = $this->db->query("
|
||||
SELECT booking_date, booking_start_time, booking_finish_time FROM bookings
|
||||
WHERE worker_id = ? AND DATE_FORMAT(booking_date,'%Y-%m') = ?
|
||||
ORDER BY booking_date, booking_start_time
|
||||
", [$worker_id, $yearMonth]);
|
||||
$result = [];
|
||||
foreach ($query->result() as $row) {
|
||||
$result[$row->booking_date][] = $row;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function computeLunchBreak($winStart, $winEnd, $date, $dayBookings, $shiftEnd, $shiftStart = null, $preferredTime = null) {
|
||||
// Lunch break only applies for shifts of 6+ hours
|
||||
if ($shiftStart && ($shiftEnd->getTimestamp() - $shiftStart->getTimestamp()) < 6 * 3600) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$lunchDuration = new DateInterval('PT30M');
|
||||
$lunchStep = new DateInterval('PT15M');
|
||||
$winStartDt = new DateTime($date . ' ' . $winStart);
|
||||
$winEndDt = new DateTime($date . ' ' . $winEnd);
|
||||
|
||||
// Clamp window to actual shift hours
|
||||
if ($shiftStart && $winStartDt < $shiftStart) $winStartDt = clone $shiftStart;
|
||||
if ($winEndDt > $shiftEnd) $winEndDt = clone $shiftEnd;
|
||||
|
||||
$scanEnd = (clone $winEndDt)->sub($lunchDuration);
|
||||
|
||||
$isFree = function($candidate) use ($lunchDuration, $dayBookings, $date) {
|
||||
$candEnd = (clone $candidate)->add($lunchDuration);
|
||||
foreach ($dayBookings as $bk) {
|
||||
$bkS = new DateTime($date . ' ' . $bk->booking_start_time);
|
||||
$bkE = new DateTime($date . ' ' . $bk->booking_finish_time);
|
||||
if ($bkS < $candEnd && $bkE > $candidate) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Collect all free candidate slots within window
|
||||
$freeSlots = [];
|
||||
for ($c = clone $winStartDt; $c <= $scanEnd; $c->add($lunchStep)) {
|
||||
if ($isFree($c)) $freeSlots[] = clone $c;
|
||||
}
|
||||
|
||||
if (!empty($freeSlots)) {
|
||||
// Pick the slot closest to preferred time (or start of window if no preference)
|
||||
$prefDt = $preferredTime
|
||||
? new DateTime($date . ' ' . $preferredTime)
|
||||
: clone $winStartDt;
|
||||
$best = null;
|
||||
$bestDiff = PHP_INT_MAX;
|
||||
foreach ($freeSlots as $slot) {
|
||||
$diff = abs($slot->getTimestamp() - $prefDt->getTimestamp());
|
||||
if ($diff < $bestDiff) { $bestDiff = $diff; $best = $slot; }
|
||||
}
|
||||
$bestEnd = (clone $best)->add($lunchDuration);
|
||||
return ['start' => $best->format('H:i'), 'end' => $bestEnd->format('H:i')];
|
||||
}
|
||||
|
||||
// Fallback: search after the window
|
||||
for ($c = clone $winEndDt; ; $c->add($lunchStep)) {
|
||||
$candEnd = (clone $c)->add($lunchDuration);
|
||||
if ($candEnd > $shiftEnd) break;
|
||||
if ($isFree($c)) return ['start' => $c->format('H:i'), 'end' => $candEnd->format('H:i')];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getWorkerOverridesByMonth($worker_id, $year, $month) {
|
||||
$this->load->database();
|
||||
$yearMonth = $year . '-' . str_pad($month, 2, '0', STR_PAD_LEFT);
|
||||
$query = $this->db->query(
|
||||
"SELECT * FROM worker_schedule_overrides WHERE worker_id = ? AND DATE_FORMAT(date,'%Y-%m') = ?",
|
||||
[$worker_id, $yearMonth]
|
||||
);
|
||||
$result = [];
|
||||
foreach ($query->result() as $row) {
|
||||
$result[$row->date] = $row;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getWorkerScheduleOverrideByDate($worker_id, $date) {
|
||||
$this->load->database();
|
||||
$this->db->where('worker_id', $worker_id);
|
||||
$this->db->where('date', $date);
|
||||
$this->db->limit(1);
|
||||
return $this->db->get('worker_schedule_overrides')->row();
|
||||
}
|
||||
|
||||
public function upsertWorkerScheduleOverride($worker_id, $date, $data) {
|
||||
$existing = $this->getWorkerScheduleOverrideByDate($worker_id, $date);
|
||||
if ($existing) {
|
||||
$this->db->where('worker_id', $worker_id);
|
||||
$this->db->where('date', $date);
|
||||
$this->db->update('worker_schedule_overrides', $data);
|
||||
} else {
|
||||
$data['worker_id'] = $worker_id;
|
||||
$data['date'] = $date;
|
||||
$this->db->insert('worker_schedule_overrides', $data);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteWorkerScheduleOverrideByDate($worker_id, $date) {
|
||||
$this->load->database();
|
||||
$this->db->where('worker_id', $worker_id);
|
||||
$this->db->where('date', $date);
|
||||
$this->db->delete('worker_schedule_overrides');
|
||||
}
|
||||
|
||||
public function getWorkerScheduleByDay($worker_id, $weekday) {
|
||||
$this->db->where('worker_id', $worker_id);
|
||||
|
||||
Reference in New Issue
Block a user