diff --git a/api/ticket_custom_fields.php b/api/ticket_custom_fields.php new file mode 100644 index 0000000..93f028a --- /dev/null +++ b/api/ticket_custom_fields.php @@ -0,0 +1,87 @@ + false, 'error' => 'Method not allowed']); +} + +$data = json_decode(file_get_contents('php://input'), true); +$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : ''; +$values = is_array($data['values'] ?? null) ? $data['values'] : []; + +if ($ticketId === '') { + http_response_code(400); + apiRespond(['success' => false, 'error' => 'ticket_id required']); +} + +$ticketModel = new TicketModel($conn); +$ticket = $ticketModel->getTicketById($ticketId); +if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) { + http_response_code(404); + apiRespond(['success' => false, 'error' => 'Ticket not found']); +} + +$fieldModel = new CustomFieldModel($conn); +$definitions = $fieldModel->getAllDefinitions($ticket['category'], true); + +$errors = []; +$toSave = []; +foreach ($definitions as $def) { + $fieldId = (int)$def['field_id']; + $raw = $values[$fieldId] ?? ($values[(string)$fieldId] ?? null); + + if ($def['field_type'] === 'checkbox') { + $normalized = !empty($raw) ? '1' : '0'; + } else { + $normalized = is_scalar($raw) ? trim((string)$raw) : ''; + } + + if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') { + $errors[] = $def['field_label'] . ' is required'; + continue; + } + + if ($def['field_type'] === 'select' && $normalized !== '') { + $allowedOptions = $def['field_options']['options'] ?? []; + if (!in_array($normalized, $allowedOptions, true)) { + $errors[] = $def['field_label'] . ' has an invalid selection'; + continue; + } + } + + if ($def['field_type'] === 'number' && $normalized !== '' && !is_numeric($normalized)) { + $errors[] = $def['field_label'] . ' must be a number'; + continue; + } + + $toSave[$fieldId] = $normalized; +} + +if (!empty($errors)) { + http_response_code(422); + apiRespond(['success' => false, 'error' => implode('; ', $errors)]); +} + +$fieldModel->setValues($ticketId, $toSave); + +require_once dirname(__DIR__) . '/models/AuditLogModel.php'; +(new AuditLogModel($conn))->log($userId, 'update', 'ticket', $ticketId, [ + 'reason' => 'custom fields updated', +]); + +apiRespond(['success' => true]); diff --git a/controllers/TicketController.php b/controllers/TicketController.php index a03bd76..2825d29 100644 --- a/controllers/TicketController.php +++ b/controllers/TicketController.php @@ -7,6 +7,7 @@ require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/UserModel.php'; require_once dirname(__DIR__) . '/models/WorkflowModel.php'; require_once dirname(__DIR__) . '/models/TemplateModel.php'; +require_once dirname(__DIR__) . '/models/CustomFieldModel.php'; require_once dirname(__DIR__) . '/helpers/UrlHelper.php'; require_once dirname(__DIR__) . '/helpers/NotificationHelper.php'; @@ -18,6 +19,7 @@ class TicketController private $userModel; private $workflowModel; private $templateModel; + private $customFieldModel; private $conn; public function __construct($conn) @@ -29,6 +31,7 @@ class TicketController $this->userModel = new UserModel($conn); $this->workflowModel = new WorkflowModel($conn); $this->templateModel = new TemplateModel($conn); + $this->customFieldModel = new CustomFieldModel($conn); } public function view($id) @@ -60,6 +63,11 @@ class TicketController // Get allowed status transitions for this ticket $allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']); + // Custom fields applicable to this ticket's category, with any + // already-saved values for it + $customFieldDefs = $this->customFieldModel->getAllDefinitions($ticket['category'], true); + $customFieldValues = $this->customFieldModel->getValuesForTicket($id); + // Make $conn available to view for visibility groups $conn = $this->conn; @@ -73,6 +81,12 @@ class TicketController $currentUser = $GLOBALS['currentUser'] ?? null; $userId = $currentUser['user_id'] ?? null; + // All active custom field definitions (every category, plus + // category-less ones) — the create form renders them all and toggles + // visibility client-side as the Category select changes, since the + // ticket doesn't exist yet to scope the query to one category. + $allCustomFieldDefs = $this->customFieldModel->getAllDefinitions(null, true); + // Check if form was submitted if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Validate CSRF token @@ -131,6 +145,38 @@ class TicketController return; } + // Custom fields applicable to the submitted category — validate + // is_required server-side (the form is novalidate, and a field + // hidden by the client-side category toggle must not silently + // bypass a requirement that applies to the category actually + // submitted). + $submittedCustomFields = is_array($_POST['custom_fields'] ?? null) ? $_POST['custom_fields'] : []; + $applicableFieldDefs = array_filter( + $allCustomFieldDefs, + fn($def) => $def['category'] === null || $def['category'] === $ticketData['category'] + ); + $customFieldsToSave = []; + foreach ($applicableFieldDefs as $def) { + $fieldId = (int)$def['field_id']; + $raw = $submittedCustomFields[$fieldId] ?? null; + $normalized = $def['field_type'] === 'checkbox' + ? (!empty($raw) ? '1' : '0') + : (is_scalar($raw) ? trim((string)$raw) : ''); + + if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') { + $error = $def['field_label'] . ' is required'; + $templates = $this->templateModel->getAllTemplates(); + $allUsers = $this->userModel->getAllUsers(); + $conn = $this->conn; + include dirname(__DIR__) . '/views/CreateTicketView.php'; + return; + } + + if ($normalized !== '') { + $customFieldsToSave[$fieldId] = $normalized; + } + } + // Create ticket with user tracking $result = $this->ticketModel->createTicket($ticketData, $userId); @@ -144,6 +190,12 @@ class TicketController require_once dirname(__DIR__) . '/models/StatsModel.php'; (new StatsModel($this->conn))->invalidateCache(); + // Persist custom field values for the fields applicable to + // this ticket's category + if (!empty($customFieldsToSave)) { + $this->customFieldModel->setValues($result['ticket_id'], $customFieldsToSave); + } + // Auto-link as duplicate if requested from create form $linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? ''); if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) { diff --git a/views/CreateTicketView.php b/views/CreateTicketView.php index 7714d4c..178516f 100644 --- a/views/CreateTicketView.php +++ b/views/CreateTicketView.php @@ -124,7 +124,7 @@ include __DIR__ . '/layout_header.php';
- @@ -211,6 +211,55 @@ include __DIR__ . '/layout_header.php';
+ + +
+ +
Additional Fields
+
+ +
+ + + + + + + + + + > + + > + + > + +
+ +

Fields shown depend on the selected Category.

+
+
+ +
@@ -316,6 +365,15 @@ include __DIR__ . '/layout_header.php'; .catch(function () { /* silent — duplicate check is non-critical */ }); } + // ── Custom fields: show only the selected category's fields ── + function toggleCustomFields() { + var category = document.getElementById('category').value; + document.querySelectorAll('.custom-field-group').forEach(function (group) { + var fieldCategory = group.getAttribute('data-custom-field-category'); + group.classList.toggle('is-hidden', fieldCategory !== '' && fieldCategory !== category); + }); + } + // ── Visibility groups toggle ────────────────────────────── var visibilityHints = { 'public': 'Everyone who is logged in can view this ticket.', @@ -387,9 +445,11 @@ include __DIR__ . '/layout_header.php'; switch (target.getAttribute('data-action')) { case 'load-template': loadTemplate(); break; case 'toggle-visibility-groups': toggleVisibilityGroups(); break; + case 'toggle-custom-fields': toggleCustomFields(); break; } }); + toggleCustomFields(); if (window.lt) lt.keys.initDefaults(); }()); diff --git a/views/TicketView.php b/views/TicketView.php index 9839ef0..f84c4fd 100644 --- a/views/TicketView.php +++ b/views/TicketView.php @@ -397,6 +397,12 @@ document.addEventListener('DOMContentLoaded', function() { role="tab" data-tab="dependencies-panel" aria-selected="false" aria-controls="dependencies-panel"> Dependencies + + +
+ + +
+
+ +
Custom Fields
+
+ + +
+ + + + + + + + + + + + + + +
+ + +
+
+
+ + @@ -1013,6 +1073,46 @@ document.addEventListener('DOMContentLoaded', function () { }); } + // Save custom fields button + var saveCustomFieldsBtn = document.getElementById('saveCustomFieldsBtn'); + if (saveCustomFieldsBtn) { + saveCustomFieldsBtn.addEventListener('click', function () { + var panel = document.getElementById('custom-fields-panel'); + var msg = document.getElementById('customFieldsMsg'); + var values = {}; + panel.querySelectorAll('[name^="custom_fields["]').forEach(function (el) { + var m = el.name.match(/custom_fields\[(\d+)\]/); + if (!m) return; + var fieldId = m[1]; + if (el.type === 'checkbox') { + values[fieldId] = el.checked ? '1' : '0'; + } else { + values[fieldId] = el.value; + } + }); + + saveCustomFieldsBtn.disabled = true; + msg.classList.add('is-hidden'); + + lt.api.post('/api/ticket_custom_fields.php', { + ticket_id: window.ticketData.id, + values: values + }).then(function (data) { + saveCustomFieldsBtn.disabled = false; + if (data.success) { + lt.toast.success('Custom fields saved', 3000); + } else { + msg.textContent = data.error || 'Failed to save custom fields'; + msg.className = 'lt-msg lt-msg-danger lt-mb-md'; + } + }).catch(function (error) { + saveCustomFieldsBtn.disabled = false; + msg.textContent = 'Failed to save custom fields: ' + error.message; + msg.className = 'lt-msg lt-msg-danger lt-mb-md'; + }); + }); + } + // Settings save/cancel // Load user preference toggles on settings modal open (function() {