BROOKO icon
BROOKO UK NETWORK
Where code meets creativity & adventure
File viewer

calendar_api.php

Type
php
Size
31.44 KB
Modified
15 May
calendar_api.php 31.44 KB
<?php
require_once __DIR__ . '/../bootstrap.php';

header('Content-Type: application/json; charset=utf-8');

global $pdo;

function json_error($message, $code = 400) {
    http_response_code($code);
    echo json_encode(['error' => $message]);
    exit;
}

function parse_payload() {
    $contentType = $_SERVER['CONTENT_TYPE'] ?? '';
    // Handles: application/json, application/json; charset=UTF-8, etc.
    if (stripos($contentType, 'application/json') !== false) {
        $raw = file_get_contents('php://input');
        $data = json_decode($raw, true);
        return is_array($data) ? $data : [];
    }
    return $_POST;
}

function normalize_hex($color) {
    $color = trim((string)$color);
    if ($color === '') return '#ff8c1a';
    if ($color[0] !== '#') $color = '#' . $color;
    if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
        return '#ff8c1a';
    }
    return strtolower($color);
}

function normalize_job_number($value) {
    // Keep compatibility with older data: just trim + store as-is
    return trim((string)$value);
}


function dt_from_input($value, $allDay) {
    if ($value === null || $value === '') return null;
    try {
        if ($allDay) {
            // YYYY-MM-DD
            $dt = new DateTime($value . ' 00:00:00', new DateTimeZone('UTC'));
        } else {
            // Accept: YYYY-MM-DDTHH:MM (datetime-local), ISO strings, etc.
            $v = str_replace('T', ' ', $value);
            if (strlen($v) === 16) $v .= ':00';
            $dt = new DateTime($v, new DateTimeZone('UTC'));
        }
        return $dt;
    } catch (Throwable $e) {
        return null;
    }
}

// API-safe auth: do NOT redirect to login, return JSON so the UI can display the issue.
if (!isLoggedIn()) {
    json_error('Not authenticated. Please refresh the page and log in again.', 401);
}

// --- GET endpoints ---
$action = $_GET['action'] ?? null;
if ($_SERVER['REQUEST_METHOD'] === 'GET') {

    if ($action === 'events' || $action === 'list') {
        if (!hasPermission('calendar.view')) json_error('Access denied', 403);

        $start = $_GET['start'] ?? null;
        $end   = $_GET['end'] ?? null;
        $category = (isset($_GET['category']) && $_GET['category'] !== '') ? (int)$_GET['category'] : null;
        $vanId = (isset($_GET['van_id']) && $_GET['van_id'] !== '') ? (int)$_GET['van_id'] : null;

        $where = [];
        $params = [];

        // Range filtering (FullCalendar gives an exclusive range end)
        if ($start && $end) {
            try {
                $rs = new DateTime($start, new DateTimeZone('UTC'));
                $re = new DateTime($end, new DateTimeZone('UTC'));
                // End times are stored as **exclusive** for all-day events (end = next day 00:00).
                // Using >= leaks events that end exactly at the range start into the next day.
                $where[] = '(e.start_at < ? AND (e.end_at IS NULL OR e.end_at > ?))';
                $params[] = $re->format('Y-m-d H:i:s');
                $params[] = $rs->format('Y-m-d H:i:s');
            } catch (Throwable $e) {
                // ignore range filter if parsing fails
            }
        }

        if ($category) {
            $where[] = 'e.category_id = ?';
            $params[] = $category;
        }

        if ($vanId) {
            $where[] = 'e.van_id = ?';
            $params[] = $vanId;
        }

        $sql = "
            SELECT
                e.id, e.title, e.description, e.location,
                e.start_at, e.end_at, e.all_day,
                e.category_id, e.meta,
                e.run_number, e.van_id, e.van_plate_temp,
                e.driver_user_id, e.driver_name_other,
                e.porter_user_id, e.porter_name_other,
                e.is_run_event,
                e.meeting_with, e.meeting_status,
                c.name AS category_name,
                c.color AS category_color,
                v.tag_color AS van_color,
                v.plate_full AS van_plate_full,
                v.plate_short AS van_plate_short,
                driver.display_name AS driver_display_name,
                porter.display_name AS porter_display_name
            FROM calendar_events e
            LEFT JOIN calendar_categories c ON c.id = e.category_id
            LEFT JOIN vans v ON v.id = e.van_id
            LEFT JOIN users driver ON driver.id = e.driver_user_id
            LEFT JOIN users porter ON porter.id = e.porter_user_id
        ";

        if (!empty($where)) {
            $sql .= ' WHERE ' . implode(' AND ', $where);
        }
        $sql .= ' ORDER BY e.start_at ASC';

        try {
            $stmt = $pdo->prepare($sql);
            $stmt->execute($params);
            $rows = $stmt->fetchAll();
        } catch (Throwable $e) {
            json_error('Calendar tables not found or query failed', 500);
        }

        $events = [];
        foreach ($rows as $r) {
            // Color logic: Runs use van color, all others use category color
            if ($r['is_run_event'] == 1 && $r['van_color']) {
                $color = $r['van_color'];
            } else {
                $color = $r['category_color'] ?: '#ff8c1a';
            }
            
            $meta = $r['meta'] ? json_decode($r['meta'], true) : [];
            if (!is_array($meta)) $meta = [];
            
            $events[] = [
                'id' => (string)$r['id'],
                'title' => $r['title'],
                'start' => date('c', strtotime($r['start_at'])),
                'end' => $r['end_at'] ? date('c', strtotime($r['end_at'])) : null,
                'allDay' => ((int)$r['all_day'] === 1),
                'backgroundColor' => $color,
                'borderColor' => $color,
                'extendedProps' => [
                    'description' => $r['description'],
                    'location' => $r['location'],
                    'category_id' => $r['category_id'],
                    'category_name' => $r['category_name'],
                    'van_plate' => $r['van_plate_full'] ?: $r['van_plate_temp'],
                    'job_number' => $r['run_number'],
                    'run_number' => $r['run_number'],
                    'driver' => $r['driver_display_name'] ?: $r['driver_name_other'],
                    'porter' => $r['porter_display_name'] ?: $r['porter_name_other'],
                    'meeting_with' => $r['meeting_with'] ?? null,
                    'meeting_status' => $r['meeting_status'] ?? null,
                    'custom_fields' => $meta
                ]
            ];
        }

        echo json_encode($events);
        exit;
    }

    if ($action === 'categories') {
        if (!hasPermission('calendar.view')) json_error('Access denied', 403);
        try {
            $stmt = $pdo->query("SELECT id, name, color, sort_order FROM calendar_categories ORDER BY sort_order ASC, name ASC");
            $categories = $stmt->fetchAll();
            
            // Load custom fields for each category
            foreach ($categories as &$cat) {
                $fieldStmt = $pdo->prepare("SELECT id, field_name, field_label, field_type, field_options, required, sort_order FROM calendar_category_fields WHERE category_id = ? ORDER BY sort_order ASC");
                $fieldStmt->execute([$cat['id']]);
                $cat['fields'] = $fieldStmt->fetchAll();
            }
            
            echo json_encode(['categories' => $categories]);
            exit;
        } catch (Throwable $e) {
            json_error('Calendar tables not found', 500);
        }
    }
    
    if ($action === 'get') {
        if (!hasPermission('calendar.view')) json_error('Access denied', 403);
        
        $id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
        if ($id <= 0) json_error('Invalid event ID');
        
        try {
            $stmt = $pdo->prepare("
                SELECT
                    e.id, e.title, e.description, e.location,
                    e.start_at, e.end_at, e.all_day,
                    e.category_id, e.meta,
                    e.run_number, e.van_id, e.van_plate_temp,
                    e.driver_user_id, e.driver_name_other,
                    e.porter_user_id, e.porter_name_other,
                    e.is_run_event,
                    e.meeting_with, e.meeting_by_user_id, e.meeting_status,
                    c.name AS category_name,
                    c.color AS category_color,
                    v.tag_color AS van_color,
                    v.plate_full AS van_plate_full,
                    v.plate_short AS van_plate_short,
                    driver.display_name AS driver_display_name,
                    porter.display_name AS porter_display_name,
                    meeting_by.display_name AS meeting_by_name
                FROM calendar_events e
                LEFT JOIN calendar_categories c ON c.id = e.category_id
                LEFT JOIN vans v ON v.id = e.van_id
                LEFT JOIN users driver ON driver.id = e.driver_user_id
                LEFT JOIN users porter ON porter.id = e.porter_user_id
                LEFT JOIN users meeting_by ON meeting_by.id = e.meeting_by_user_id
                WHERE e.id = ?
            ");
            $stmt->execute([$id]);
            $event = $stmt->fetch(PDO::FETCH_ASSOC);
            
            if (!$event) {
                json_error('Event not found', 404);
            }
            
            // Format dates
            $event['start'] = date('Y-m-d', strtotime($event['start_at']));
            $event['end'] = $event['end_at'] ? date('Y-m-d', strtotime($event['end_at'])) : null;
            $event['notes'] = $event['description'];
            
            // Add formatted names for display
            $event['driver_name'] = $event['driver_display_name'] ?: $event['driver_name_other'];
            $event['porter_name'] = $event['porter_display_name'] ?: $event['porter_name_other'];
            
            // Load annual leave staff assignments if applicable
            try {
                $staffStmt = $pdo->prepare("
                    SELECT u.id, u.display_name 
                    FROM annual_leave_staff als
                    JOIN users u ON u.id = als.user_id
                    WHERE als.event_id = ?
                    ORDER BY u.display_name
                ");
                $staffStmt->execute([$id]);
                $staffMembers = $staffStmt->fetchAll(PDO::FETCH_ASSOC);
                $event['staff_members'] = $staffMembers;
                $event['staff_ids'] = array_column($staffMembers, 'id');
            } catch (Throwable $e) {
                $event['staff_members'] = [];
                $event['staff_ids'] = [];
            }
            
            echo json_encode(['success' => true, 'event' => $event]);
            exit;
        } catch (Throwable $e) {
            json_error('Failed to load event', 500);
        }
    }

    json_error('Unknown action', 400);
}

// --- POST endpoints ---
$payload = parse_payload();
$action = $payload['action'] ?? '';

// CSRF validation for all writes (local to the calendar module)
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
$expected = $_SESSION['calendar_csrf_token'] ?? '';
if (!$expected || !is_string($csrf) || !hash_equals($expected, $csrf)) {
    json_error('CSRF check failed. Please refresh the page and try again.', 403);
}

try {
    // Unified save action for runs (handles both create and update)
    if ($action === 'save') {
        $id = isset($payload['id']) && $payload['id'] ? (int)$payload['id'] : 0;
        
        if ($id > 0) {
            // Update existing
            if (!hasPermission('calendar.edit')) json_error('Access denied', 403);
            
            $title = trim((string)($payload['title'] ?? ''));
            $allDay = (int)($payload['all_day'] ?? 1) === 1;
            $start = dt_from_input($payload['start'] ?? null, $allDay);
            $end = dt_from_input($payload['end'] ?? null, $allDay);
            
            if ($title === '') json_error('Title is required');
            if (!$start) json_error('Start is required');
            
            if ($allDay && $end) {
                $end->modify('+1 day');
            }
            
            $categoryId = (isset($payload['category_id']) && $payload['category_id'] !== '') ? (int)$payload['category_id'] : null;
            $location = trim((string)($payload['location'] ?? ''));
            $notes = trim((string)($payload['notes'] ?? ''));
            
            $runNumber = normalize_job_number($payload['run_number'] ?? '');
            $vanId = (isset($payload['van_id']) && $payload['van_id'] !== '' && $payload['van_id'] !== 'temp') ? (int)$payload['van_id'] : null;
            $vanPlateTemp = trim((string)($payload['van_plate_temp'] ?? ''));
            $driverUserId = (isset($payload['driver_user_id']) && $payload['driver_user_id'] !== '' && $payload['driver_user_id'] !== 'other') ? (int)$payload['driver_user_id'] : null;
            $driverNameOther = trim((string)($payload['driver_name_other'] ?? ''));
            $porterUserId = (isset($payload['porter_user_id']) && $payload['porter_user_id'] !== '' && $payload['porter_user_id'] !== 'other') ? (int)$payload['porter_user_id'] : null;
            $porterNameOther = trim((string)($payload['porter_name_other'] ?? ''));
            $isRunEvent = (int)($payload['is_run_event'] ?? 0) === 1 ? 1 : 0;
            
            // Meeting fields
            $meetingWith = trim((string)($payload['meeting_with'] ?? ''));
            $meetingByUserId = (isset($payload['meeting_by_user_id']) && $payload['meeting_by_user_id'] !== '') ? (int)$payload['meeting_by_user_id'] : null;
            $meetingStatus = trim((string)($payload['meeting_status'] ?? 'pending'));
            if (!in_array($meetingStatus, ['pending', 'attended', 'no_show', 'cancelled'])) {
                $meetingStatus = 'pending';
            }
            
            $stmt = $pdo->prepare("UPDATE calendar_events SET title=?, description=?, location=?, start_at=?, end_at=?, all_day=?, category_id=?, run_number=?, van_id=?, van_plate_temp=?, driver_user_id=?, driver_name_other=?, porter_user_id=?, porter_name_other=?, is_run_event=?, meeting_with=?, meeting_by_user_id=?, meeting_status=?, updated_by=? WHERE id=?");
            $stmt->execute([
                $title,
                $notes ?: null,
                $location ?: null,
                $start->format('Y-m-d H:i:s'),
                $end ? $end->format('Y-m-d H:i:s') : null,
                $allDay ? 1 : 0,
                $categoryId,
                $runNumber ?: null,
                $vanId,
                $vanPlateTemp ?: null,
                $driverUserId,
                $driverNameOther ?: null,
                $porterUserId,
                $porterNameOther ?: null,
                $isRunEvent,
                $meetingWith ?: null,
                $meetingByUserId,
                $meetingStatus,
                (int)($_SESSION['user_id'] ?? 0) ?: null,
                $id
            ]);
            
            // Handle annual leave staff assignments
            if (isset($payload['staff_ids'])) {
                $staffIds = is_array($payload['staff_ids']) ? $payload['staff_ids'] : explode(',', $payload['staff_ids']);
                $staffIds = array_filter(array_map('intval', $staffIds));
                
                // Delete existing assignments
                $pdo->prepare("DELETE FROM annual_leave_staff WHERE event_id = ?")->execute([$id]);
                
                // Insert new assignments
                if (!empty($staffIds)) {
                    $stmt = $pdo->prepare("INSERT IGNORE INTO annual_leave_staff (event_id, user_id) VALUES (?, ?)");
                    foreach ($staffIds as $userId) {
                        $stmt->execute([$id, $userId]);
                    }
                }
            }
            
            logActivity('calendar.edit', 'calendar_event', $id, 'Updated job: ' . $title);
            echo json_encode(['success' => true, 'id' => $id]);
            exit;
        } else {
            // Create new
            if (!hasPermission('calendar.create')) json_error('Access denied', 403);
            
            $title = trim((string)($payload['title'] ?? ''));
            $allDay = (int)($payload['all_day'] ?? 1) === 1;
            $start = dt_from_input($payload['start'] ?? null, $allDay);
            $end = dt_from_input($payload['end'] ?? null, $allDay);
            
            if ($title === '') json_error('Title is required');
            if (!$start) json_error('Start is required');
            
            if ($allDay && $end) {
                $end->modify('+1 day');
            }
            
            $categoryId = (isset($payload['category_id']) && $payload['category_id'] !== '') ? (int)$payload['category_id'] : null;
            $location = trim((string)($payload['location'] ?? ''));
            $notes = trim((string)($payload['notes'] ?? ''));
            
            $runNumber = normalize_job_number($payload['run_number'] ?? '');
            $vanId = (isset($payload['van_id']) && $payload['van_id'] !== '' && $payload['van_id'] !== 'temp') ? (int)$payload['van_id'] : null;
            $vanPlateTemp = trim((string)($payload['van_plate_temp'] ?? ''));
            $driverUserId = (isset($payload['driver_user_id']) && $payload['driver_user_id'] !== '' && $payload['driver_user_id'] !== 'other') ? (int)$payload['driver_user_id'] : null;
            $driverNameOther = trim((string)($payload['driver_name_other'] ?? ''));
            $porterUserId = (isset($payload['porter_user_id']) && $payload['porter_user_id'] !== '' && $payload['porter_user_id'] !== 'other') ? (int)$payload['porter_user_id'] : null;
            $porterNameOther = trim((string)($payload['porter_name_other'] ?? ''));
            $isRunEvent = (int)($payload['is_run_event'] ?? 0) === 1 ? 1 : 0;
            
            // Meeting fields
            $meetingWith = trim((string)($payload['meeting_with'] ?? ''));
            $meetingByUserId = (isset($payload['meeting_by_user_id']) && $payload['meeting_by_user_id'] !== '') ? (int)$payload['meeting_by_user_id'] : null;
            $meetingStatus = trim((string)($payload['meeting_status'] ?? 'pending'));
            if (!in_array($meetingStatus, ['pending', 'attended', 'no_show', 'cancelled'])) {
                $meetingStatus = 'pending';
            }
            
            $stmt = $pdo->prepare("INSERT INTO calendar_events (title, description, location, start_at, end_at, all_day, category_id, run_number, van_id, van_plate_temp, driver_user_id, driver_name_other, porter_user_id, porter_name_other, is_run_event, meeting_with, meeting_by_user_id, meeting_status, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
            $stmt->execute([
                $title,
                $notes ?: null,
                $location ?: null,
                $start->format('Y-m-d H:i:s'),
                $end ? $end->format('Y-m-d H:i:s') : null,
                $allDay ? 1 : 0,
                $categoryId,
                $runNumber ?: null,
                $vanId,
                $vanPlateTemp ?: null,
                $driverUserId,
                $driverNameOther ?: null,
                $porterUserId,
                $porterNameOther ?: null,
                $isRunEvent,
                $meetingWith ?: null,
                $meetingByUserId,
                $meetingStatus,
                (int)($_SESSION['user_id'] ?? 0) ?: null,
                (int)($_SESSION['user_id'] ?? 0) ?: null
            ]);
            
            $newId = (int)$pdo->lastInsertId();
            
            // Handle annual leave staff assignments
            if (isset($payload['staff_ids']) && !empty($payload['staff_ids'])) {
                $staffIds = is_array($payload['staff_ids']) ? $payload['staff_ids'] : explode(',', $payload['staff_ids']);
                $staffIds = array_filter(array_map('intval', $staffIds));
                
                // Delete existing assignments
                $pdo->prepare("DELETE FROM annual_leave_staff WHERE event_id = ?")->execute([$newId]);
                
                // Insert new assignments
                if (!empty($staffIds)) {
                    $stmt = $pdo->prepare("INSERT IGNORE INTO annual_leave_staff (event_id, user_id) VALUES (?, ?)");
                    foreach ($staffIds as $userId) {
                        $stmt->execute([$newId, $userId]);
                    }
                }
            }
            
            logActivity('calendar.create', 'calendar_event', $newId, 'Created job: ' . $title);
            echo json_encode(['success' => true, 'id' => $newId]);
            exit;
        }
    }
    
    if ($action === 'create_event') {
        if (!hasPermission('calendar.create')) json_error('Access denied', 403);

        $title = trim((string)($payload['title'] ?? ''));
        $allDay = (int)($payload['all_day'] ?? 0) === 1;
        $start = dt_from_input($payload['start'] ?? null, $allDay);
        $endIn = $payload['end'] ?? null;
        $end = dt_from_input($endIn, $allDay);
        $endExclusive = (int)($payload['end_exclusive'] ?? 0) === 1;

        if ($title === '') json_error('Title is required');
        if (!$start) json_error('Start is required');

        // For all-day end dates from forms: store exclusive end (end + 1 day at 00:00)
        if ($allDay && $end && !$endExclusive) {
            $end->modify('+1 day');
        }

        $categoryId = (isset($payload['category_id']) && $payload['category_id'] !== '') ? (int)$payload['category_id'] : null;
        $location = trim((string)($payload['location'] ?? ''));
        $description = trim((string)($payload['notes'] ?? $payload['description'] ?? ''));
        
        // Handle custom fields
        $customFields = isset($payload['custom_fields']) && is_array($payload['custom_fields']) ? $payload['custom_fields'] : [];
        
        // Validate required custom fields if category is set
        if ($categoryId) {
            try {
                $fieldStmt = $pdo->prepare("SELECT field_name, field_label, required FROM calendar_category_fields WHERE category_id = ?");
                $fieldStmt->execute([$categoryId]);
                $fields = $fieldStmt->fetchAll();
                
                foreach ($fields as $field) {
                    if ($field['required'] && empty($customFields[$field['field_name']])) {
                        json_error($field['field_label'] . ' is required');
                    }
                }
            } catch (Throwable $e) {
                // Field validation table might not exist yet
            }
        }
        
        $metaJson = !empty($customFields) ? json_encode($customFields) : null;
        
        // Handle run-based fields
        $runNumber = normalize_job_number($payload['run_number'] ?? '');
        $vanId = (isset($payload['van_id']) && $payload['van_id'] !== '') ? (int)$payload['van_id'] : null;
        $vanPlateTemp = trim((string)($payload['van_plate_temp'] ?? ''));
        $driverUserId = (isset($payload['driver_user_id']) && $payload['driver_user_id'] !== '' && $payload['driver_user_id'] !== 'other') ? (int)$payload['driver_user_id'] : null;
        $driverNameOther = trim((string)($payload['driver_name_other'] ?? ''));
        $porterUserId = (isset($payload['porter_user_id']) && $payload['porter_user_id'] !== '' && $payload['porter_user_id'] !== 'other') ? (int)$payload['porter_user_id'] : null;
        $porterNameOther = trim((string)($payload['porter_name_other'] ?? ''));
        $isRunEvent = (int)($payload['is_run_event'] ?? 0) === 1 ? 1 : 0;

        $stmt = $pdo->prepare("INSERT INTO calendar_events (title, description, location, start_at, end_at, all_day, category_id, meta, run_number, van_id, van_plate_temp, driver_user_id, driver_name_other, porter_user_id, porter_name_other, is_run_event, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
        $stmt->execute([
            $title,
            $description ?: null,
            $location ?: null,
            $start->format('Y-m-d H:i:s'),
            $end ? $end->format('Y-m-d H:i:s') : null,
            $allDay ? 1 : 0,
            $categoryId,
            $metaJson,
            $runNumber ?: null,
            $vanId,
            $vanPlateTemp ?: null,
            $driverUserId,
            $driverNameOther ?: null,
            $porterUserId,
            $porterNameOther ?: null,
            $isRunEvent,
            (int)($_SESSION['user_id'] ?? 0) ?: null,
            (int)($_SESSION['user_id'] ?? 0) ?: null
        ]);

        $id = (int)$pdo->lastInsertId();
        logActivity('calendar.create', 'calendar_event', $id, 'Created event: ' . $title);

        echo json_encode(['success' => true, 'id' => $id]);
        exit;
    }

    if ($action === 'update_event') {
        if (!hasPermission('calendar.edit')) json_error('Access denied', 403);

        $id = (int)($payload['id'] ?? 0);
        if ($id <= 0) json_error('Invalid event id');

        $title = trim((string)($payload['title'] ?? ''));
        $allDay = (int)($payload['all_day'] ?? 0) === 1;
        $start = dt_from_input($payload['start'] ?? null, $allDay);
        $end = dt_from_input($payload['end'] ?? null, $allDay);
        $endExclusive = (int)($payload['end_exclusive'] ?? 0) === 1;

        if ($title === '') json_error('Title is required');
        if (!$start) json_error('Start is required');

        if ($allDay && $end && !$endExclusive) {
            $end->modify('+1 day');
        }

        $categoryId = (isset($payload['category_id']) && $payload['category_id'] !== '') ? (int)$payload['category_id'] : null;
        $location = trim((string)($payload['location'] ?? ''));
        $description = trim((string)($payload['notes'] ?? $payload['description'] ?? ''));
        
        // Handle custom fields
        $customFields = isset($payload['custom_fields']) && is_array($payload['custom_fields']) ? $payload['custom_fields'] : [];
        
        // Validate required custom fields if category is set
        if ($categoryId) {
            try {
                $fieldStmt = $pdo->prepare("SELECT field_name, field_label, required FROM calendar_category_fields WHERE category_id = ?");
                $fieldStmt->execute([$categoryId]);
                $fields = $fieldStmt->fetchAll();
                
                foreach ($fields as $field) {
                    if ($field['required'] && empty($customFields[$field['field_name']])) {
                        json_error($field['field_label'] . ' is required');
                    }
                }
            } catch (Throwable $e) {
                // Field validation table might not exist yet
            }
        }
        
        $metaJson = !empty($customFields) ? json_encode($customFields) : null;
        
        // Handle run-based fields
        $runNumber = normalize_job_number($payload['run_number'] ?? '');
        $vanId = (isset($payload['van_id']) && $payload['van_id'] !== '') ? (int)$payload['van_id'] : null;
        $vanPlateTemp = trim((string)($payload['van_plate_temp'] ?? ''));
        $driverUserId = (isset($payload['driver_user_id']) && $payload['driver_user_id'] !== '' && $payload['driver_user_id'] !== 'other') ? (int)$payload['driver_user_id'] : null;
        $driverNameOther = trim((string)($payload['driver_name_other'] ?? ''));
        $porterUserId = (isset($payload['porter_user_id']) && $payload['porter_user_id'] !== '' && $payload['porter_user_id'] !== 'other') ? (int)$payload['porter_user_id'] : null;
        $porterNameOther = trim((string)($payload['porter_name_other'] ?? ''));
        $isRunEvent = (int)($payload['is_run_event'] ?? 0) === 1 ? 1 : 0;

        $stmt = $pdo->prepare("UPDATE calendar_events SET title=?, description=?, location=?, start_at=?, end_at=?, all_day=?, category_id=?, meta=?, run_number=?, van_id=?, van_plate_temp=?, driver_user_id=?, driver_name_other=?, porter_user_id=?, porter_name_other=?, is_run_event=?, updated_by=? WHERE id=?");
        $stmt->execute([
            $title,
            $description ?: null,
            $location ?: null,
            $start->format('Y-m-d H:i:s'),
            $end ? $end->format('Y-m-d H:i:s') : null,
            $allDay ? 1 : 0,
            $categoryId,
            $metaJson,
            $runNumber ?: null,
            $vanId,
            $vanPlateTemp ?: null,
            $driverUserId,
            $driverNameOther ?: null,
            $porterUserId,
            $porterNameOther ?: null,
            $isRunEvent,
            (int)($_SESSION['user_id'] ?? 0) ?: null,
            $id
        ]);

        logActivity('calendar.edit', 'calendar_event', $id, 'Updated event: ' . $title);
        echo json_encode(['success' => true]);
        exit;
    }

    if ($action === 'delete' || $action === 'delete_event') {
        if (!hasPermission('calendar.delete')) json_error('Access denied', 403);
        $id = (int)($payload['id'] ?? 0);
        if ($id <= 0) json_error('Invalid event id');

        $title = null;
        try {
            $st = $pdo->prepare('SELECT title FROM calendar_events WHERE id=?');
            $st->execute([$id]);
            $title = $st->fetchColumn();
        } catch (Throwable $e) {}

        $stmt = $pdo->prepare('DELETE FROM calendar_events WHERE id=?');
        $stmt->execute([$id]);
        logActivity('calendar.delete', 'calendar_event', $id, 'Deleted event: ' . ($title ?: ('#'.$id)));
        echo json_encode(['success' => true]);
        exit;
    }

    if ($action === 'create_category') {
        if (!hasPermission('calendar.categories.manage')) json_error('Access denied', 403);
        $name = trim((string)($payload['name'] ?? ''));
        if ($name === '') json_error('Name is required');
        $color = normalize_hex($payload['color'] ?? '#ff8c1a');
        $sort = (int)($payload['sort_order'] ?? 0);

        $stmt = $pdo->prepare('INSERT INTO calendar_categories (name, color, sort_order, created_by) VALUES (?,?,?,?)');
        $stmt->execute([$name, $color, $sort, (int)($_SESSION['user_id'] ?? 0) ?: null]);
        $id = (int)$pdo->lastInsertId();
        logActivity('calendar.category_create', 'calendar_category', $id, 'Created category: ' . $name);
        echo json_encode(['success' => true, 'id' => $id]);
        exit;
    }

    if ($action === 'update_category') {
        if (!hasPermission('calendar.categories.manage')) json_error('Access denied', 403);
        $id = (int)($payload['id'] ?? 0);
        if ($id <= 0) json_error('Invalid category id');
        $name = trim((string)($payload['name'] ?? ''));
        if ($name === '') json_error('Name is required');
        $color = normalize_hex($payload['color'] ?? '#ff8c1a');
        $sort = (int)($payload['sort_order'] ?? 0);

        $stmt = $pdo->prepare('UPDATE calendar_categories SET name=?, color=?, sort_order=? WHERE id=?');
        $stmt->execute([$name, $color, $sort, $id]);
        logActivity('calendar.category_edit', 'calendar_category', $id, 'Updated category: ' . $name);
        echo json_encode(['success' => true]);
        exit;
    }

    if ($action === 'delete_category') {
        if (!hasPermission('calendar.categories.manage')) json_error('Access denied', 403);
        $id = (int)($payload['id'] ?? 0);
        if ($id <= 0) json_error('Invalid category id');

        $title = null;
        try {
            $st = $pdo->prepare('SELECT name FROM calendar_categories WHERE id=?');
            $st->execute([$id]);
            $title = $st->fetchColumn();
        } catch (Throwable $e) {}

        $stmt = $pdo->prepare('DELETE FROM calendar_categories WHERE id=?');
        $stmt->execute([$id]);
        logActivity('calendar.category_delete', 'calendar_category', $id, 'Deleted category: ' . ($title ?: ('#'.$id)));
        echo json_encode(['success' => true]);
        exit;
    }

    json_error('Unknown action', 400);

} catch (Throwable $e) {
    json_error('Server error: ' . $e->getMessage(), 500);
}