calendar.php
52.87 KB
<?php
require_once __DIR__ . '/../bootstrap.php';
requireAuth();
if (!hasPermission('calendar.view')) {
http_response_code(403);
die('Access denied');
}
$pageTitle = 'Calendar';
$canEdit = hasPermission('calendar.edit');
// Page-specific assets
$extraHead = ''
. '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.3/main.min.css">' . "\n"
. '<link rel="stylesheet" href="' . e(app_asset_url('css/calendar-new.css')) . '?v=' . APP_VERSION . '">' . "\n";
// Fetch categories
$categories = [];
try {
$stmt = $pdo->query("SELECT * FROM calendar_categories ORDER BY sort_order ASC, name ASC");
$categories = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) {
error_log("Calendar categories error: " . $e->getMessage());
}
// Fetch active vans
$vans = [];
try {
$stmt = $pdo->query("SELECT id, plate_full, plate_short, make, model, tag_color FROM vans WHERE is_active=1 ORDER BY plate_full ASC");
$vans = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) {
error_log("Vans error: " . $e->getMessage());
}
// Optional: focus a specific vehicle from map (calendar?van_id=123)
$focusVanId = (isset($_GET['van_id']) && $_GET['van_id'] !== '') ? (int)$_GET['van_id'] : 0;
$focusVan = null;
if ($focusVanId > 0) {
try {
$stmt = $pdo->prepare("SELECT id, plate_full, plate_short, make, model FROM vans WHERE id = ?");
$stmt->execute([$focusVanId]);
$focusVan = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
} catch (Throwable $e) {
$focusVan = null;
}
}
// Fetch active users for drivers/porters
$drivers = [];
$porters = [];
try {
$stmt = $pdo->query("SELECT id, display_name, role, secondary_group FROM users WHERE is_active = 1 ORDER BY display_name ASC");
$allUsers = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($allUsers as $user) {
$role = strtolower($user['role'] ?? '');
$secondaryGroup = strtolower($user['secondary_group'] ?? '');
if (strpos($role, 'driver') !== false || $secondaryGroup === 'driver') {
$drivers[] = $user;
}
if (strpos($role, 'porter') !== false || $secondaryGroup === 'porter') {
$porters[] = $user;
}
}
} catch (Throwable $e) {
error_log("Users fetch error: " . $e->getMessage());
}
// CSRF token
if (!isset($_SESSION['calendar_csrf_token'])) {
$_SESSION['calendar_csrf_token'] = bin2hex(random_bytes(32));
}
$csrfToken = $_SESSION['calendar_csrf_token'];
include __DIR__ . '/../partials/header.php';
?>
<div class="content-header">
<div>
<h1 class="content-title">📅 Calendar</h1>
<p class="content-subtitle">Schedule and manage events</p>
</div>
<div style="display: flex; gap: 10px;">
<button class="btn btn-secondary" onclick="openFilterModal()">
🔍 Filter
</button>
<?php if ($canEdit): ?>
<button class="btn btn-primary" onclick="openAddEventModal()">
➕ Add Event
</button>
<?php endif; ?>
</div>
</div>
<?php if (!empty($focusVan)): ?>
<div class="alert alert-warning" style="display:flex;align-items:center;justify-content:space-between;gap:12px;">
<div>
Showing events for vehicle <strong><?= htmlspecialchars($focusVan['plate_full'] ?? '') ?></strong>
<?php if (!empty($focusVan['make']) || !empty($focusVan['model'])): ?>
<span class="text-muted">— <?= htmlspecialchars(trim(($focusVan['make'] ?? '') . ' ' . ($focusVan['model'] ?? ''))) ?></span>
<?php endif; ?>
</div>
<a class="btn btn-secondary btn-xs" href="<?= e(app_url('calendar')) ?>" style="white-space:nowrap;">Clear</a>
</div>
<?php endif; ?>
<!-- Calendar Card -->
<div class="calendar-container">
<div id="calendar"></div>
</div>
<!-- Filter Modal -->
<div class="modal-overlay" id="filterModal" style="display: none;">
<div class="modal" style="max-width: 400px;">
<div class="modal-header">
<h3>Filter Events</h3>
<button class="modal-close" onclick="closeFilterModal()">×</button>
</div>
<div class="modal-body" style="padding: 20px;">
<div class="form-group">
<label style="display: flex; align-items: center; padding: 10px; cursor: pointer; border-radius: 6px; transition: background 0.2s;" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='transparent'">
<input type="radio" name="filterCategory" value="" checked onchange="applyFilter()" style="margin-right: 10px;">
<span class="pill-dot" style="background: var(--accent); margin-right: 10px;"></span>
<span style="font-weight: 600;">All Events</span>
</label>
<?php foreach ($categories as $cat): ?>
<label style="display: flex; align-items: center; padding: 10px; cursor: pointer; border-radius: 6px; transition: background 0.2s;" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='transparent'">
<input type="radio" name="filterCategory" value="<?= (int)$cat['id'] ?>" onchange="applyFilter()" style="margin-right: 10px;">
<span class="pill-dot" style="background: <?= htmlspecialchars($cat['color']) ?>; margin-right: 10px;"></span>
<span style="font-weight: 600;"><?= htmlspecialchars($cat['name']) ?></span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeFilterModal()">Close</button>
</div>
</div>
</div>
<!-- Event Modal -->
<div class="modal-overlay" id="eventModal">
<div class="modal">
<div class="modal-header">
<h3 id="modalTitle">Add Event</h3>
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div class="modal-body">
<form id="eventForm">
<input type="hidden" id="eventId" name="id">
<!-- Category Selection -->
<div class="info-row">
<label>Event Type *</span>
<select id="categoryId" name="category_id" class="form-input" required onchange="switchEventType()">
<option value="">Select type...</option>
<?php foreach ($categories as $cat): ?>
<option value="<?= (int)$cat['id'] ?>" data-name="<?= htmlspecialchars($cat['name']) ?>">
<?= htmlspecialchars($cat['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<!-- JOBS Fields -->
<div id="runsFields" class="event-fields" style="display: none;">
<div class="info-row">
<label>Job Number</span>
<input type="text" id="runNumber" name="run_number" class="form-input" placeholder="e.g., J123">
</div>
<div class="info-row">
<label>Vehicle *</span>
<select id="runVehicle" name="van_id" class="form-input">
<option value="">Select vehicle...</option>
<?php foreach ($vans as $van): ?>
<option value="<?= (int)$van['id'] ?>"
data-color="<?= htmlspecialchars($van['tag_color'] ?? '#ff8c1a') ?>"
data-plate-short="<?= htmlspecialchars($van['plate_short'] ?? '') ?>"
data-plate-full="<?= htmlspecialchars($van['plate_full']) ?>">
<?= htmlspecialchars($van['plate_full']) ?>
<?php if ($van['make'] && $van['model']): ?>
- <?= htmlspecialchars($van['make']) ?> <?= htmlspecialchars($van['model']) ?>
<?php endif; ?>
</option>
<?php endforeach; ?>
<option value="temp">Temporary Vehicle</option>
</select>
</div>
<div id="tempVehicleField" class="form-group" style="display: none;">
<label>Temp Vehicle Plate *</span>
<input type="text" id="tempVehiclePlate" name="van_plate_temp" class="form-input" placeholder="ABC123">
</div>
<div class="info-row">
<label>Driver *</span>
<select id="runDriver" name="driver_user_id" class="form-input">
<option value="">Select driver...</option>
<?php foreach ($drivers as $driver): ?>
<option value="<?= (int)$driver['id'] ?>"><?= htmlspecialchars($driver['display_name']) ?></option>
<?php endforeach; ?>
<option value="other">Other (External)</option>
</select>
</div>
<div id="otherDriverField" class="form-group" style="display: none;">
<label>Driver Name *</span>
<input type="text" id="otherDriverName" name="driver_name_other" class="form-input" placeholder="External driver name">
</div>
<div class="info-row">
<label>Porter</span>
<select id="runPorter" name="porter_user_id" class="form-input">
<option value="">No porter</option>
<?php foreach ($porters as $porter): ?>
<option value="<?= (int)$porter['id'] ?>"><?= htmlspecialchars($porter['display_name']) ?></option>
<?php endforeach; ?>
<option value="other">Other (External)</option>
</select>
</div>
<div id="otherPorterField" class="form-group" style="display: none;">
<label>Porter Name</span>
<input type="text" id="otherPorterName" name="porter_name_other" class="form-input" placeholder="External porter name">
</div>
</div>
<!-- MEETINGS Fields -->
<div id="meetingsFields" class="event-fields" style="display: none;">
<div class="form-group">
<label>Meeting Title *</label>
<input type="text" id="meetingTitle" name="title" class="form-input" placeholder="e.g., Client Consultation, Team Review">
</div>
<div class="form-group">
<label>Meeting With</label>
<input type="text" id="meetingWith" name="meeting_with" class="form-input" placeholder="e.g., John Smith, ABC Company">
<small class="form-help">Who is this meeting with? (client, external party, etc.)</small>
</div>
<div class="form-group">
<label>Organized By</label>
<select id="meetingBy" name="meeting_by_user_id" class="form-input">
<option value="">Select staff member...</option>
<?php foreach ($allUsers as $user): ?>
<option value="<?= (int)$user['id'] ?>"><?= htmlspecialchars($user['display_name']) ?></option>
<?php endforeach; ?>
</select>
<small class="form-help">Which staff member is organizing this meeting?</small>
</div>
<div class="form-group">
<label>Location</label>
<input type="text" id="meetingLocation" name="location" class="form-input" placeholder="e.g., Office, Zoom, Client Site">
</div>
<div class="form-group" id="meetingStatusGroup" style="display: none;">
<label>Attendance Status</label>
<select id="meetingStatus" name="meeting_status" class="form-input">
<option value="pending">Pending</option>
<option value="attended">Attended</option>
<option value="no_show">No Show</option>
<option value="cancelled">Cancelled</option>
</select>
<small class="form-help">Mark attendance after the meeting takes place</small>
</div>
</div>
<!-- ANNUAL LEAVE Fields -->
<div id="holidaysFields" class="event-fields" style="display: none;">
<div class="form-group">
<label>Leave Name *</label>
<input type="text" id="holidayTitle" name="title" class="form-input" placeholder="e.g., Christmas Break, Summer Holiday">
</div>
<div class="form-group">
<label>Staff Members *</label>
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
<select id="staffSelector" class="form-input" style="flex: 1;">
<option value="">Select staff member...</option>
<?php foreach ($allUsers as $user): ?>
<option value="<?= (int)$user['id'] ?>" data-name="<?= htmlspecialchars($user['display_name']) ?>">
<?= htmlspecialchars($user['display_name']) ?>
</option>
<?php endforeach; ?>
</select>
<button type="button" class="btn btn-primary" onclick="addStaffMember()" style="white-space: nowrap;">+ Add</button>
</div>
<!-- Selected Staff Display Box -->
<div id="selectedStaffBox" style="border: 1px solid var(--border); border-radius: 6px; padding: 10px; min-height: 60px; background: var(--bg-panel); margin-top: 8px;">
<div id="selectedStaffList" style="display: flex; flex-wrap: wrap; gap: 6px;">
<span style="color: var(--text-muted); font-size: 0.9rem;" id="noStaffMessage">No staff selected</span>
</div>
</div>
<small class="form-help">Select staff members who will be on leave</small>
<input type="hidden" id="selectedStaffIds" name="staff_ids" value="">
</div>
<div class="form-group">
<label>Start Date *</label>
<input type="date" id="holidayStartDate" name="start_date" class="form-input">
</div>
<div class="form-group">
<label>End Date</label>
<input type="date" id="holidayEndDate" name="end_date" class="form-input">
<small class="form-help">Leave empty for single-day leave</small>
</div>
</div>
<!-- GENERAL Fields (Maintenance, Appointment, Personal, Other) -->
<div id="generalFields" class="event-fields" style="display: none;">
<div class="info-row">
<label>Title *</span>
<input type="text" id="generalTitle" name="title" class="form-input" placeholder="Event title">
</div>
<div class="info-row">
<label>Location</span>
<input type="text" id="generalLocation" name="location" class="form-input" placeholder="Event location">
</div>
</div>
<!-- Common Fields (Date & Time) -->
<div id="commonFields" style="display: none;">
<div id="commonDateTimeFields">
<div class="info-row">
<label>Date *</span>
<input type="date" id="eventDate" name="date" class="form-input" required>
</div>
<div class="info-row">
<label class="checkbox-label">
<input type="checkbox" id="allDay" name="all_day" value="1" onchange="toggleTimeFields()">
<span>All-day event</span>
</span>
</div>
<div id="timeFields">
<div class="form-row" style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<div class="form-group">
<label>Start Time</label>
<div style="display: flex; gap: 8px; align-items: center;">
<select id="startHour" class="form-input" style="flex: 1;">
<?php for ($h = 0; $h < 24; $h++): ?>
<option value="<?= sprintf('%02d', $h) ?>"<?= $h == 8 ? ' selected' : '' ?>><?= sprintf('%02d', $h) ?></option>
<?php endfor; ?>
</select>
<span style="font-weight: 700;">:</span>
<select id="startMinute" class="form-input" style="flex: 1;">
<option value="00" selected>00</option>
<option value="15">15</option>
<option value="30">30</option>
<option value="45">45</option>
</select>
</div>
</div>
<div class="form-group">
<label>End Time</label>
<div style="display: flex; gap: 8px; align-items: center;">
<select id="endHour" class="form-input" style="flex: 1;">
<option value="">--</option>
<?php for ($h = 0; $h < 24; $h++): ?>
<option value="<?= sprintf('%02d', $h) ?>"><?= sprintf('%02d', $h) ?></option>
<?php endfor; ?>
</select>
<span style="font-weight: 700;">:</span>
<select id="endMinute" class="form-input" style="flex: 1;">
<option value="00" selected>00</option>
<option value="15">15</option>
<option value="30">30</option>
<option value="45">45</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="form-group">
<label>Details/Notes</label>
<textarea id="eventNotes" name="notes" class="form-input" rows="3" placeholder="Details/Notes..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Event</button>
</div>
</form>
</div>
</div>
</div>
<script src="<?= e(app_asset_url('js/modal.js')) ?>?v=<?= APP_VERSION ?>"></script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.11.3/main.min.js"></script>
<script>
const CSRF_TOKEN = '<?= $csrfToken ?>';
let calendar = null;
let currentFilter = '';
const INITIAL_VAN_FILTER = <?= (int)($focusVan['id'] ?? 0) ?>;
let currentVanFilter = INITIAL_VAN_FILTER ? String(INITIAL_VAN_FILTER) : '';
document.addEventListener('DOMContentLoaded', function() {
initCalendar();
});
function initCalendar() {
const calendarEl = document.getElementById('calendar');
// Match the app's responsive breakpoints (sidebar collapses around Bootstrap lg)
const isMobile = window.matchMedia('(max-width: 991.98px)').matches;
// Remove legacy compact mode (no longer supported)
try {
localStorage.removeItem('wp_calendar_compact');
localStorage.removeItem('wp_calendar_view_noncompact');
} catch (e) {}
// Mobile: force Day view only
const savedView = localStorage.getItem('wp_calendar_view') || '';
const initialView = isMobile ? 'timeGridDay' : (savedView || 'dayGridMonth');
function nudgeNowIndicator() {
// FullCalendar sometimes mis-positions the now-indicator after view changes/resizes.
// A size update + small delay fixes the majority of cases.
try { calendar.updateSize(); } catch (e) {}
setTimeout(() => { try { calendar.updateSize(); } catch (e) {} }, 160);
// In day view, keep the scroll around "now" (helps the now-indicator appear correctly).
if (calendar && calendar.view && String(calendar.view.type) === 'timeGridDay') {
const d = new Date();
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
const t = `${hh}:${mm}:${ss}`;
try { calendar.scrollToTime(t); } catch (e) {}
}
}
function applyToolbarForWidth() {
const mobileNow = window.matchMedia('(max-width: 991.98px)').matches;
calendar.setOption('headerToolbar', {
left: mobileNow ? 'prev,next' : 'prev,next today',
center: 'title',
right: mobileNow ? '' : 'dayGridMonth,timeGridWeek,timeGridDay'
});
// Mobile is day-only
if (mobileNow && calendar.view && calendar.view.type !== 'timeGridDay') {
try { calendar.changeView('timeGridDay'); } catch (e) {}
}
}
calendar = new FullCalendar.Calendar(calendarEl, {
initialView: initialView,
headerToolbar: {
left: isMobile ? 'prev,next' : 'prev,next today',
center: 'title',
// Mobile is day-only, and we hide the view switcher completely
right: isMobile ? '' : 'dayGridMonth,timeGridWeek,timeGridDay'
},
height: 'auto',
expandRows: true,
firstDay: 1, // Monday as start of week
// Explicitly stick to local time (prevents odd now-indicator offsets)
timeZone: 'local',
// Time grid settings
slotMinTime: '06:00:00',
slotMaxTime: '22:00:00',
scrollTime: '06:00:00',
slotDuration: '01:00:00',
allDaySlot: true,
nowIndicator: true,
scrollTimeReset: false,
events: {
url: '/api/calendar',
method: 'GET',
extraParams: function() {
return {
action: 'list',
category: currentFilter || '',
van_id: currentVanFilter || ''
};
},
failure: function(error) {
console.error('Failed to load events:', error);
}
},
eventClick: function(info) {
viewEvent(info.event.id);
},
dateClick: function(info) {
<?php if ($canEdit): ?>
openAddEventModal(info.dateStr);
<?php endif; ?>
},
datesSet: function(arg) {
const mobileNow = window.matchMedia('(max-width: 991.98px)').matches;
// Persist view choice on desktop only
if (!mobileNow && arg && arg.view && arg.view.type) {
localStorage.setItem('wp_calendar_view', arg.view.type);
}
// Force day-only on mobile
if (mobileNow && arg && arg.view && arg.view.type !== 'timeGridDay') {
try { calendar.changeView('timeGridDay'); } catch (e) {}
return;
}
nudgeNowIndicator();
},
viewDidMount: function() {
nudgeNowIndicator();
},
windowResize: function() {
// Keep toolbar + view in sync with responsive layout
try { applyToolbarForWidth(); } catch (e) {}
// FullCalendar sometimes needs a nudge after resize
setTimeout(() => { try { calendar.updateSize(); } catch (e) {} }, 120);
}
});
// IMPORTANT: without render() the calendar stays blank
calendar.render();
// Ensure correct toolbar after first render (helps when CSS breakpoints differ)
try { applyToolbarForWidth(); } catch (e) {}
}
function openAddEventModal(date = null) {
document.getElementById('modalTitle').textContent = 'Add Event';
document.getElementById('eventForm').reset();
document.getElementById('eventId').value = '';
// Set default date to today if not provided
const defaultDate = date || new Date().toISOString().substring(0, 10);
document.getElementById('eventDate').value = defaultDate;
document.getElementById('holidayStartDate').value = defaultDate;
// Set default time to 08:00
document.getElementById('startHour').value = '08';
document.getElementById('startMinute').value = '00';
document.getElementById('endHour').value = '';
document.getElementById('endMinute').value = '00';
hideAllEventFields();
const dtBlock = document.getElementById('commonDateTimeFields');
if (dtBlock) dtBlock.style.display = 'block';
document.getElementById('eventModal').classList.add('active');
}
function closeModal() {
document.getElementById('eventModal').classList.remove('active');
document.getElementById('eventForm').reset();
hideAllEventFields();
clearSelectedStaff();
}
function hideAllEventFields() {
document.querySelectorAll('.event-fields').forEach(el => el.style.display = 'none');
document.getElementById('commonFields').style.display = 'none';
}
function isJobType(name) {
const n = (name || '').trim();
return ['Jobs','Job','Runs','Run'].includes(n);
}
function isMeetingType(name) {
const n = (name || '').trim();
return ['Meetings','Meeting'].includes(n);
}
function switchEventType(skipReset = false) {
const select = document.getElementById('categoryId');
const categoryName = select.options[select.selectedIndex]?.getAttribute('data-name') || '';
hideAllEventFields();
const dt = document.getElementById('commonDateTimeFields');
if (dt) dt.style.display = 'block';
// Reset all inputs only when creating new events, not when editing
if (!skipReset) {
document.querySelectorAll('.event-fields input, .event-fields textarea, .event-fields select').forEach(input => {
if (input.type !== 'checkbox') input.value = '';
});
}
// Show appropriate fields
if (isJobType(categoryName)) {
document.getElementById('runsFields').style.display = 'block';
document.getElementById('commonFields').style.display = 'block';
if (!skipReset) document.getElementById('allDay').checked = true;
toggleTimeFields();
} else if (isMeetingType(categoryName)) {
document.getElementById('meetingsFields').style.display = 'block';
document.getElementById('commonFields').style.display = 'block';
if (!skipReset) document.getElementById('allDay').checked = false;
toggleTimeFields();
} else if (categoryName === 'Annual Leave') {
document.getElementById('holidaysFields').style.display = 'block';
document.getElementById('commonFields').style.display = 'block';
const dt = document.getElementById('commonDateTimeFields');
if (dt) dt.style.display = 'none';
} else if (categoryName) {
document.getElementById('generalFields').style.display = 'block';
document.getElementById('commonFields').style.display = 'block';
if (!skipReset) document.getElementById('allDay').checked = false;
toggleTimeFields();
}
}
function toggleTimeFields() {
const allDay = document.getElementById('allDay').checked;
document.getElementById('timeFields').style.display = allDay ? 'none' : 'block';
}
// Vehicle selection handler
document.getElementById('runVehicle')?.addEventListener('change', function() {
const tempField = document.getElementById('tempVehicleField');
tempField.style.display = this.value === 'temp' ? 'block' : 'none';
});
// Driver selection handler
document.getElementById('runDriver')?.addEventListener('change', function() {
const otherField = document.getElementById('otherDriverField');
otherField.style.display = this.value === 'other' ? 'block' : 'none';
});
// Porter selection handler
document.getElementById('runPorter')?.addEventListener('change', function() {
const otherField = document.getElementById('otherPorterField');
otherField.style.display = this.value === 'other' ? 'block' : 'none';
});
// Form submission
document.getElementById('eventForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const categoryName = document.getElementById('categoryId').options[document.getElementById('categoryId').selectedIndex]?.getAttribute('data-name') || '';
// Get event ID for update vs create
const eventId = document.getElementById('eventId').value;
if (eventId) {
formData.set('id', eventId);
}
// Build proper start/end based on event type
if (categoryName === 'Annual Leave') {
const startDate = document.getElementById('holidayStartDate').value;
const endDate = document.getElementById('holidayEndDate').value || startDate;
formData.set('start', startDate);
formData.set('end', endDate);
formData.set('all_day', '1');
// Use annual leave title
const title = document.getElementById('holidayTitle').value;
formData.set('title', title);
// Details/Notes
const notes = (document.getElementById('eventNotes')?.value || '').trim();
if (notes) formData.set('notes', notes);
} else {
const date = document.getElementById('eventDate').value;
const allDay = document.getElementById('allDay').checked;
if (allDay) {
formData.set('start', date);
formData.set('end', date);
formData.set('all_day', '1');
} else {
const startHour = document.getElementById('startHour').value || '08';
const startMinute = document.getElementById('startMinute').value || '00';
const startTime = `${startHour}:${startMinute}`;
formData.set('start', `${date}T${startTime}:00`);
const endHour = document.getElementById('endHour').value;
if (endHour) {
const endMinute = document.getElementById('endMinute').value || '00';
const endTime = `${endHour}:${endMinute}`;
formData.set('end', `${date}T${endTime}:00`);
}
formData.set('all_day', '0');
}
// Set title based on event type
if (isJobType(categoryName)) {
const vehicleSelect = document.getElementById('runVehicle');
const selectedOption = vehicleSelect.options[vehicleSelect.selectedIndex];
const plateShort = selectedOption?.getAttribute('data-plate-short') || '';
// Format (Option B): "FV1 - FTT"
const jobNum = (document.getElementById('runNumber').value || '').trim();
formData.set('run_number', jobNum);
// Vehicle label (supports temp vehicles)
let vehicleLabel = plateShort;
if (vehicleSelect.value === 'temp') {
vehicleLabel = (document.getElementById('tempVehiclePlate').value || '').trim();
}
const title = (vehicleLabel && jobNum) ? `${vehicleLabel} - ${jobNum}` : (vehicleLabel || jobNum || 'Job');
formData.set('title', title);
formData.set('is_run_event', '1');
// Get notes from eventNotes if it exists
const notesField = document.getElementById('eventNotes');
if (notesField && notesField.value) {
formData.set('notes', notesField.value);
}
} else if (isMeetingType(categoryName)) {
formData.set('title', document.getElementById('meetingTitle').value);
// Meeting-specific fields
const meetingWith = document.getElementById('meetingWith').value;
if (meetingWith) formData.set('meeting_with', meetingWith);
const meetingBy = document.getElementById('meetingBy').value;
if (meetingBy) formData.set('meeting_by_user_id', meetingBy);
const meetingStatus = document.getElementById('meetingStatus').value;
if (meetingStatus) formData.set('meeting_status', meetingStatus);
} else {
formData.set('title', document.getElementById('generalTitle').value);
}
}
// Details/Notes (applies to all event types)
const notesField = document.getElementById('eventNotes');
const notesVal = (notesField && notesField.value) ? notesField.value.trim() : '';
if (notesVal) formData.set('notes', notesVal);
formData.set('action', 'save');
// Debug: log what we're sending
console.log('Submitting event:', {
id: formData.get('id'),
action: formData.get('action'),
title: formData.get('title'),
start: formData.get('start'),
all_day: formData.get('all_day')
});
try {
const response = await fetch('/api/calendar', {
method: 'POST',
headers: { 'X-CSRF-Token': CSRF_TOKEN },
body: formData
});
const result = await response.json();
console.log('Server response:', result);
if (result.success) {
calendar.refetchEvents();
closeModal();
} else {
alert('Error: ' + (result.error || 'Failed to save event'));
}
} catch (error) {
console.error('Save error:', error);
alert('Failed to save event');
}
});
// Filter Modal Functions
function openFilterModal() {
document.getElementById('filterModal').style.display = 'flex';
}
function closeFilterModal() {
document.getElementById('filterModal').style.display = 'none';
}
function applyFilter() {
const selected = document.querySelector('input[name="filterCategory"]:checked');
currentFilter = selected ? selected.value : '';
calendar.refetchEvents();
}
function filterByCategory(categoryId) {
// Legacy function - keeping for compatibility
currentFilter = categoryId;
calendar.refetchEvents();
}
// View Event Functions
let currentViewEventId = null;
let currentViewModal = null;
async function viewEvent(eventId) {
currentViewEventId = eventId;
// Create modal with loading state
const canEdit = <?= $canEdit ? 'true' : 'false' ?>;
const buttons = [
{ text: 'Close', class: 'btn-secondary', onClick: (e, m) => m.close() }
];
if (canEdit) {
buttons.push(
{ text: 'Delete', class: 'btn-danger', onClick: () => deleteEventFromModal() },
{ text: 'Edit', class: 'btn-primary', onClick: () => editEventFromModal() }
);
}
currentViewModal = new Modal({
title: 'Event Details',
content: '<p style="text-align: center; padding: 20px;">Loading...</p>',
size: 'medium',
buttons: buttons,
onClose: () => {
currentViewEventId = null;
currentViewModal = null;
}
});
currentViewModal.open();
currentViewModal.setLoading(true);
try {
const response = await fetch(`/api/calendar?action=get&id=${eventId}`);
const result = await response.json();
if (result.success && result.event) {
const contentHtml = buildEventDetailsHtml(result.event);
currentViewModal.updateContent(contentHtml);
currentViewModal.setLoading(false);
} else {
currentViewModal.updateContent('<p style="color: var(--text-danger);">Error loading event details</p>');
currentViewModal.setLoading(false);
}
} catch (error) {
console.error('Error fetching event:', error);
currentViewModal.updateContent('<p style="color: var(--text-danger);">Failed to load event details</p>');
currentViewModal.setLoading(false);
}
}
function buildEventDetailsHtml(event) {
const categoryName = event.category_name || '';
let html = '<div style="line-height: 2;">';
// Title
html += `<div class="info-row">
<span class="info-label">Event</span>
<div class="info-value" style="font-size: 1.1rem; font-weight: 600;">${escapeHtml(event.title)}</div>
</div>`;
// Category
if (categoryName) {
html += `<div class="info-row">
<span class="info-label">Type</span>
<div class="info-value">${escapeHtml(categoryName)}</div>
</div>`;
}
// Date & Time
const startDate = new Date(event.start_at || event.start);
const endDate = (event.end_at || event.end) ? new Date(event.end_at || event.end) : null;
if (event.all_day) {
if (endDate && endDate.toDateString() !== startDate.toDateString()) {
html += `<div class="info-row">
<span class="info-label">Dates</span>
<div class="info-value">${formatDate(startDate)} - ${formatDate(endDate)}</div>
</div>`;
} else {
html += `<div class="info-row">
<span class="info-label">Date</span>
<div class="info-value">${formatDate(startDate)}${(isJobType(categoryName) || event.is_run_event) ? '' : ' (All-day)'}</div>
</div>`;
}
} else {
html += `<div class="info-row">
<span class="info-label">Date & Time</span>
<div class="info-value">${formatDate(startDate)} at ${formatTime(startDate)}${endDate ? ' - ' + formatTime(endDate) : ''}</div>
</div>`;
}
// Jobs-specific fields
if (isJobType(categoryName) || event.is_run_event) {
if (event.run_number) {
html += `<div class="info-row">
<span class="info-label">Job Number</span>
<div class="info-value">${escapeHtml(event.run_number)}</div>
</div>`;
}
if (event.van_plate_full || event.van_plate_temp) {
html += `<div class="info-row">
<span class="info-label">Vehicle</span>
<div class="info-value">${escapeHtml(event.van_plate_full || event.van_plate_temp)}</div>
</div>`;
}
if (event.driver_name || event.driver_name_other) {
html += `<div class="info-row">
<span class="info-label">Driver</span>
<div class="info-value">${escapeHtml(event.driver_name || event.driver_name_other)}</div>
</div>`;
}
if (event.porter_name || event.porter_name_other) {
html += `<div class="info-row">
<span class="info-label">Porter</span>
<div class="info-value">${escapeHtml(event.porter_name || event.porter_name_other)}</div>
</div>`;
}
}
// Meetings-specific fields
if (categoryName === 'Meetings') {
if (event.meeting_with) {
html += `<div class="info-row">
<span class="info-label">Meeting With</span>
<div class="info-value">${escapeHtml(event.meeting_with)}</div>
</div>`;
}
if (event.meeting_by_name) {
html += `<div class="info-row">
<span class="info-label">Organized By</span>
<div class="info-value">${escapeHtml(event.meeting_by_name)}</div>
</div>`;
}
if (event.meeting_status) {
const statusLabels = {
'pending': '⏳ Pending',
'attended': '✅ Attended',
'no_show': '❌ No Show',
'cancelled': '🚫 Cancelled'
};
const statusLabel = statusLabels[event.meeting_status] || event.meeting_status;
html += `<div class="info-row">
<span class="info-label">Status</span>
<div class="info-value">${statusLabel}</div>
</div>`;
}
}
// Annual Leave-specific fields
if (categoryName === 'Annual Leave' && event.staff_members && event.staff_members.length > 0) {
html += `<div class="info-row">
<span class="info-label">Staff Members</span>
<div class="info-value">`;
event.staff_members.forEach((staff, index) => {
if (index > 0) html += ', ';
html += escapeHtml(staff.display_name);
});
html += `</div>
</div>`;
}
// Location
if (event.location) {
html += `<div class="info-row">
<span class="info-label">Location</span>
<div class="info-value">${escapeHtml(event.location)}</div>
</div>`;
}
// Description
if (event.description) {
html += `<div class="info-row">
<span class="info-label">Details/Notes</span>
<div class="info-value" style="white-space: pre-wrap;">${escapeHtml(event.description)}</div>
</div>`;
}
html += '</div>';
return html;
}
function deleteEventFromModal() {
if (!currentViewEventId) return;
showConfirm(
'Delete Event',
'Are you sure you want to delete this event? This action cannot be undone.',
async () => {
try {
const response = await fetch('/api/calendar', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': CSRF_TOKEN
},
body: JSON.stringify({
action: 'delete',
id: currentViewEventId
})
});
const result = await response.json();
if (result.success) {
if (currentViewModal) currentViewModal.close();
calendar.refetchEvents();
showAlert('Success', 'Event deleted successfully');
} else {
showAlert('Error', result.error || 'Failed to delete event');
}
} catch (error) {
console.error('Delete error:', error);
showAlert('Error', 'Failed to delete event');
}
}
);
}
async function editEventFromModal() {
if (!currentViewEventId) return;
console.log('Editing event ID:', currentViewEventId);
try {
const response = await fetch(`/api/calendar?action=get&id=${currentViewEventId}`);
const result = await response.json();
console.log('Loaded event data:', result);
if (result.success && result.event) {
console.log('Loading into form - Category:', result.event.category_name);
loadEventIntoForm(result.event);
if (currentViewModal) currentViewModal.close();
document.getElementById('eventModal').classList.add('active');
} else {
console.error('Failed to load event:', result.error);
showAlert('Error', 'Failed to load event for editing');
}
} catch (error) {
console.error('Error loading event:', error);
showAlert('Error', 'Failed to load event');
}
}
function loadEventIntoForm(event) {
// Set modal title
document.getElementById('modalTitle').textContent = 'Edit Event';
// Set event ID
document.getElementById('eventId').value = event.id;
// Set category
const categorySelect = document.getElementById('categoryId');
categorySelect.value = event.category_id || '';
switchEventType(true); // Pass true to skip resetting fields
const categoryName = event.category_name || '';
// Set common fields
if (categoryName !== 'Annual Leave') {
document.getElementById('eventDate').value = event.start?.substring(0, 10) || '';
document.getElementById('allDay').checked = event.all_day == 1;
toggleTimeFields();
if (!event.all_day && event.start_at) {
const startTime = event.start_at.substring(11, 16); // HH:MM
const [startHour, startMinute] = startTime.split(':');
document.getElementById('startHour').value = startHour;
document.getElementById('startMinute').value = startMinute;
if (event.end_at) {
const endTime = event.end_at.substring(11, 16); // HH:MM
const [endHour, endMinute] = endTime.split(':');
document.getElementById('endHour').value = endHour;
document.getElementById('endMinute').value = endMinute;
}
}
if (document.getElementById('eventNotes')) {
document.getElementById('eventNotes').value = event.description || '';
}
}
// Set type-specific fields
if (isJobType(categoryName)) {
if (event.run_number) {
document.getElementById('runNumber').value = event.run_number;
}
if (event.van_id) {
document.getElementById('runVehicle').value = event.van_id;
} else if (event.van_plate_temp) {
document.getElementById('runVehicle').value = 'temp';
document.getElementById('tempVehicleField').style.display = 'block';
document.getElementById('tempVehiclePlate').value = event.van_plate_temp;
}
if (event.driver_user_id) {
document.getElementById('runDriver').value = event.driver_user_id;
} else if (event.driver_name_other) {
document.getElementById('runDriver').value = 'other';
document.getElementById('otherDriverField').style.display = 'block';
document.getElementById('otherDriverName').value = event.driver_name_other;
}
if (event.porter_user_id) {
document.getElementById('runPorter').value = event.porter_user_id;
} else if (event.porter_name_other) {
document.getElementById('runPorter').value = 'other';
document.getElementById('otherPorterField').style.display = 'block';
document.getElementById('otherPorterName').value = event.porter_name_other;
}
} else if (isMeetingType(categoryName)) {
document.getElementById('meetingTitle').value = event.title || '';
if (event.location) document.getElementById('meetingLocation').value = event.location;
if (event.meeting_with) document.getElementById('meetingWith').value = event.meeting_with;
if (event.meeting_by_user_id) document.getElementById('meetingBy').value = event.meeting_by_user_id;
// Show attendance status for existing meetings
const statusGroup = document.getElementById('meetingStatusGroup');
if (event.id) {
statusGroup.style.display = 'block';
if (event.meeting_status) {
document.getElementById('meetingStatus').value = event.meeting_status;
}
} else {
statusGroup.style.display = 'none';
}
} else if (categoryName === 'Annual Leave') {
document.getElementById('holidayTitle').value = event.title || '';
if (event.start_at || event.start) {
const startDate = event.start_at || event.start;
document.getElementById('holidayStartDate').value = startDate.substring(0, 10);
}
if (event.end_at || event.end) {
const endDateStr = event.end_at || event.end;
const startDateStr = event.start_at || event.start;
if (endDateStr !== startDateStr) {
const endDate = new Date(endDateStr);
endDate.setDate(endDate.getDate() - 1); // Adjust for exclusive end
document.getElementById('holidayEndDate').value = endDate.toISOString().substring(0, 10);
}
}
// Load staff members
selectedStaff = [];
if (event.staff_members && event.staff_members.length > 0) {
event.staff_members.forEach(staff => {
selectedStaff.push({
id: parseInt(staff.id),
name: staff.display_name
});
});
}
updateSelectedStaffDisplay(); // Always update display, even if empty
} else {
document.getElementById('generalTitle').value = event.title || '';
if (event.location) document.getElementById('generalLocation').value = event.location;
}
// Details/Notes
const notesEl = document.getElementById('eventNotes');
if (notesEl) notesEl.value = event.description || event.notes || '';
}
function formatDate(date) {
return date.toLocaleDateString('en-GB', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
function formatTime(date) {
return date.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
// Staff selector functions for Annual Leave
let selectedStaff = [];
function addStaffMember() {
const selector = document.getElementById('staffSelector');
const selectedOption = selector.options[selector.selectedIndex];
if (!selectedOption || !selectedOption.value) {
return;
}
const staffId = parseInt(selectedOption.value);
const staffName = selectedOption.getAttribute('data-name');
// Check if already added
if (selectedStaff.find(s => s.id === staffId)) {
alert('This staff member is already added');
return;
}
// Add to array
selectedStaff.push({ id: staffId, name: staffName });
// Update display
updateSelectedStaffDisplay();
// Reset selector
selector.selectedIndex = 0;
}
function removeStaffMember(staffId) {
selectedStaff = selectedStaff.filter(s => s.id !== staffId);
updateSelectedStaffDisplay();
}
function updateSelectedStaffDisplay() {
const listContainer = document.getElementById('selectedStaffList');
const noMessage = document.getElementById('noStaffMessage');
const hiddenInput = document.getElementById('selectedStaffIds');
if (selectedStaff.length === 0) {
listContainer.innerHTML = '<span style="color: var(--text-muted); font-size: 0.9rem;" id="noStaffMessage">No staff selected</span>';
hiddenInput.value = '';
return;
}
// Build staff badges
let html = '';
selectedStaff.forEach(staff => {
html += `
<div style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; background: var(--accent-soft); border: 1px solid var(--accent); border-radius: 6px; font-size: 0.9rem;">
<span style="font-weight: 600;">${escapeHtml(staff.name)}</span>
<button type="button" onclick="removeStaffMember(${staff.id})" style="background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 0; font-size: 1.1rem; line-height: 1;" title="Remove">×</button>
</div>
`;
});
listContainer.innerHTML = html;
// Update hidden input with comma-separated IDs
hiddenInput.value = selectedStaff.map(s => s.id).join(',');
}
function clearSelectedStaff() {
selectedStaff = [];
updateSelectedStaffDisplay();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
<?php include __DIR__ . '/../partials/footer.php'; ?>