'Ticket Information', 'details' => 'Details', 'crm_links' => 'CRM Links', 'description_notes' => 'Description & Notes', ]; private const PANEL_ORDER = [ 'ticket_information' => 10, 'details' => 20, 'crm_links' => 30, 'description_notes' => 40, ]; const TYPE_OPTIONS = [ 'text' => 'Text', 'textarea' => 'Textarea', 'number' => 'Number', 'email' => 'Email', 'tel' => 'Phone', 'url' => 'URL', 'date' => 'Date', 'datetime' => 'Date & Time', 'select' => 'Dropdown', 'checkbox' => 'Checkbox', ]; // ── Public API ──────────────────────────────────────────────────────────── /** Data needed by the settings editor view. */ public function getEditorData(User $user): array { $fields = $this->getEffectiveFields($user); $grouped = []; foreach (array_keys(self::PANELS) as $panel) { $grouped[$panel] = []; } foreach ($fields as $field) { $field['type_options'] = self::TYPE_OPTIONS; $field['is_type_locked'] = !($field['can_change_type'] ?? false); $field['is_required_locked'] = !($field['can_change_required'] ?? true); $field['is_visibility_locked'] = !($field['can_change_visibility'] ?? true); $field['is_max_length_locked'] = !$field['is_custom']; $field['is_order_locked'] = !($field['can_change_order'] ?? true); $field['special'] = null; if (!isset($field['depends_on'])) $field['depends_on'] = null; if (!isset($field['depends_map'])) $field['depends_map'] = []; $panel = $field['panel']; if (!isset($grouped[$panel])) { $grouped[$panel] = []; } $grouped[$panel][] = $field; } foreach ($grouped as &$panelFields) { usort($panelFields, fn ($a, $b) => ($a['order'] ?? 0) <=> ($b['order'] ?? 0)); } unset($panelFields); return [ 'groupedFields' => $grouped, 'allTypeOptions' => self::TYPE_OPTIONS, 'panelOptions' => $this->getPanelLabels($user), ]; } /** Returns field keys flagged for API index display (max 4). */ public function getApiIndexFieldKeys(User $user): array { $keys = Arr::get($this->getStoredSettings($user), 'api_index_fields', []); return array_slice(array_values(array_filter((array) $keys)), 0, 4); } /** Returns visible fields that can act as "depends on" parent (select type). */ public function getSelectableFieldOptions(User $user): array { $options = []; foreach ($this->getEffectiveFields($user) as $field) { if ($field['visible'] && $field['type'] === 'select') { $options[$field['key']] = $field['label']; } } return $options; } /** Keys of all currently visible fields (used by DataTable column filtering). */ public function getVisibleFieldKeys(User $user): array { $keys = []; foreach ($this->getEffectiveFields($user) as $field) { if ($field['visible']) { $keys[] = $field['key']; } } return $keys; } /** Map of key => absolute sort position for DataTable column ordering. */ public function getFieldOrderMap(User $user): array { $map = []; foreach ($this->getEffectiveFields($user) as $field) { $panelBase = (self::PANEL_ORDER[$field['panel']] ?? 99) * 1000; $map[$field['key']] = $panelBase + ($field['order'] ?? 100); } return $map; } /** Map of key => current label (including any user overrides). */ public function getFieldLabelMap(User $user): array { $map = []; foreach ($this->getEffectiveFields($user) as $field) { $map[$field['key']] = $field['label']; } return $map; } /** Visible custom fields whose values live in custom_data JSON column. */ public function getVisibleCustomFieldsForDatatable(User $user): array { $fields = []; foreach ($this->getEffectiveFields($user) as $field) { if ($field['is_custom'] && $field['visible']) { $fields[] = $field; } } return $fields; } /** All visible fields for API responses (built-ins + custom). */ public function getVisibleFieldsForApi(User $user): array { $fields = []; foreach ($this->getEffectiveFields($user) as $field) { if ($field['visible']) { $fields[] = $field; } } return $fields; } /** Full definitions for the fields flagged as API index (max 4). */ public function getApiIndexFields(User $user): array { $flaggedKeys = $this->getApiIndexFieldKeys($user); if (empty($flaggedKeys)) { return []; } $result = []; foreach ($this->getEffectiveFields($user) as $field) { if (in_array($field['key'], $flaggedKeys, true) && $field['visible']) { $result[] = [ 'key' => $field['key'], 'label' => $field['label'], 'type' => $field['type'], 'is_custom' => $field['is_custom'], ]; } } return $result; } /** * Returns all effective fields merged with any saved overrides, * sorted by panel then order. */ public function getEffectiveFields(User $user): array { $defaults = $this->defaultFieldDefinitions(); $settings = $this->getStoredSettings($user); foreach ($defaults as $key => &$field) { $overrides = Arr::get($settings, "built_in.{$key}", []); if (!is_array($overrides)) { $overrides = []; } if ($field['can_change_label']) { $label = trim((string) Arr::get($overrides, 'label', $field['label'])); if ($label !== '') { $field['label'] = $label; } } if ($field['can_change_visibility']) { $field['visible'] = $this->normBool(Arr::get($overrides, 'visible', $field['visible'])); } if ($field['can_change_required']) { $field['required'] = $this->normBool(Arr::get($overrides, 'required', $field['required'])); if (!$field['visible']) { $field['required'] = false; } } if ($field['can_change_order']) { $field['order'] = $this->normOrder(Arr::get($overrides, 'order', $field['order'])); } if (Arr::has($overrides, 'panel')) { $field['panel'] = $this->normPanel($overrides['panel']); } $field['depends_on'] = $this->normDependsOn(Arr::get($overrides, 'depends_on', $field['depends_on'] ?? null)); $field['depends_map'] = (string) Arr::get($overrides, 'depends_map', $field['depends_map'] ?? ''); } unset($field); // Custom fields (stored in custom_data JSON column) $custom = $this->customFieldDefinitions(Arr::get($settings, 'custom', [])); $all = array_merge($defaults, $custom); uasort($all, function ($a, $b) { $pa = self::PANEL_ORDER[$a['panel']] ?? 99; $pb = self::PANEL_ORDER[$b['panel']] ?? 99; if ($pa !== $pb) { return $pa <=> $pb; } return ($a['order'] ?? 0) <=> ($b['order'] ?? 0); }); return $all; } /** * Builds the form dynamically from the current effective field configuration. * * @param Form $form * @param User $user * @param Ticket|null $model */ public function buildFormFields(Form $form, User $user, $model = null): void { $customData = []; if ($model instanceof Ticket && is_array($model->custom_data)) { $customData = $model->custom_data; } foreach ($this->getEffectiveFields($user) as $field) { if (!$field['visible']) { continue; } // ticket_num is auto-generated — never added to the create/edit form if ($field['key'] === 'ticket_num') { continue; } // ticket_owner_id is set only at creation (see TicketsController::afterStore), // never on update — therefore never rendered as an editable form field if ($field['key'] === 'ticket_owner_id') { continue; } if ($field['is_custom']) { $value = Arr::get($customData, $field['key']); $this->addCustomFieldToForm($form, $field, $value); continue; } $this->addBuiltInFieldToForm($form, $field); } $form->add('submit', 'submit', [ 'label' => trans('core::core.form.save'), 'attr' => ['class' => 'btn btn-primary m-t-15 waves-effect'], ]); } /** * Returns $showFields array compatible with ModuleCrudController::$showFields. */ public function getShowFields(User $user): array { $grouped = []; foreach ($this->getEffectiveFields($user) as $field) { if (!$field['visible']) { continue; } $panel = $field['panel']; $key = $field['key']; $formName = $field['is_custom'] ? 'custom_data[' . $key . ']' : $key; $options = [ 'label' => $field['label'], 'col-class' => $field['col_class'] ?? 'col-lg-6 col-md-6', ]; if ($field['is_custom']) { $options['type'] = 'custom'; $options['display_type'] = $this->mapShowType($field['type']); $options['custom_field_key'] = $key; } else { $options = array_merge($options, $this->resolveShowFieldOptions($key, $field)); } if (!isset($grouped[$panel])) { $grouped[$panel] = []; } $grouped[$panel][$formName] = $options; } return $grouped; } /** Validation rules for the current effective fields. */ public function getValidationRules(User $user): array { $rules = ['custom_data' => ['nullable', 'array']]; foreach ($this->getEffectiveFields($user) as $field) { if (!$field['visible'] || $field['is_custom']) { continue; } // Skip special non-standard fields if (in_array($field['key'], ['owned_by', 'ticket_num', 'ticket_owner_id'], true)) { continue; } $rule = $field['required'] ? ['required'] : ['nullable']; if ($field['type'] === 'email') { $rule[] = 'email'; } elseif ($field['type'] === 'date') { $rule[] = 'date'; } elseif ($field['type'] === 'datetime') { $rule[] = 'date'; } elseif ($field['type'] === 'number') { $rule[] = 'numeric'; } elseif (is_int($field['max_length'] ?? null)) { $rule[] = 'string'; $rule[] = 'max:' . $field['max_length']; } $rules[$field['key']] = $rule; } return $rules; } // ── Settings persistence ────────────────────────────────────────────────── public function saveSettings(User $user, array $input): bool { if (!$this->hasSettingsTable()) { return false; } $defaults = $this->defaultFieldDefinitions(); $builtInIn = Arr::get($input, 'built_in', []); $customIn = Arr::get($input, 'custom', []); if (!is_array($builtInIn)) $builtInIn = []; if (!is_array($customIn)) $customIn = []; // Persist built-in overrides $builtIn = []; foreach ($defaults as $key => $field) { $payload = Arr::get($builtInIn, $key, []); if (!is_array($payload)) $payload = []; $label = trim((string) Arr::get($payload, 'label', $field['label'])); if ($label === '') $label = $field['label']; $visible = $field['can_change_visibility'] ? $this->normBool(Arr::get($payload, 'visible', $field['visible'])) : $field['visible']; $required = $field['can_change_required'] ? $this->normBool(Arr::get($payload, 'required', $field['required'])) : $field['required']; if (!$visible) $required = false; $order = $field['can_change_order'] ? $this->normOrder(Arr::get($payload, 'order', $field['order'])) : $field['order']; $panel = $this->normPanel(Arr::get($payload, 'panel', $field['panel'])); $builtIn[$key] = compact('label', 'visible', 'required', 'order', 'panel') + [ 'depends_on' => $this->normDependsOn(Arr::get($payload, 'depends_on')), 'depends_map' => (string) Arr::get($payload, 'depends_map', ''), ]; } // Persist custom fields $custom = []; $reserved = array_keys($defaults); $usedKeys = []; $orderFallback = 1000; foreach ($customIn as $row) { if (!is_array($row)) continue; $key = $this->sanitizeKey((string) Arr::get($row, 'key', '')); if ($key === '' || in_array($key, $reserved, true) || isset($usedKeys[$key])) continue; $usedKeys[$key] = true; $label = trim((string) Arr::get($row, 'label', '')); if ($label === '') $label = ucwords(str_replace('_', ' ', $key)); $visible = $this->normBool(Arr::get($row, 'visible', true)); $required = $visible ? $this->normBool(Arr::get($row, 'required', false)) : false; $type = $this->normType(Arr::get($row, 'type', 'text')); $options = $this->normOptions(Arr::get($row, 'options', [])); $panel = $this->normPanel(Arr::get($row, 'panel', 'details')); $order = $this->normOrder(Arr::get($row, 'order', null) ?? $orderFallback++); $maxLength = $this->normMaxLength(Arr::get($row, 'max_length', null), $type); $custom[] = compact('key', 'label', 'visible', 'required', 'type', 'options', 'panel', 'order') + [ 'max_length' => $maxLength, 'depends_on' => $this->normDependsOn(Arr::get($row, 'depends_on')), 'depends_map' => (string) Arr::get($row, 'depends_map', ''), ]; } usort($custom, fn ($a, $b) => ($a['order'] ?? 0) <=> ($b['order'] ?? 0)); $custom = array_values($custom); $record = TicketFieldSetting::query() ->where('company_id', $user->company_id) ->first(); if (!$record) { $record = new TicketFieldSetting(); $record->company_id = $user->company_id; } $panelLabels = $this->normalizePanelLabels(Arr::get($input, 'panel_labels', [])); $record->settings = [ 'built_in' => $builtIn, 'custom' => $custom, 'api_index_fields' => $this->normApiIndexFields($input, $builtIn, $custom), 'panel_labels' => $panelLabels, ]; $record->save(); return true; } public function resetSettings(User $user): bool { if (!$this->hasSettingsTable()) { return false; } TicketFieldSetting::query() ->where('company_id', $user->company_id) ->delete(); return true; } // ── Default field definitions ───────────────────────────────────────────── public function defaultFieldDefinitions(): array { return [ // ── Ticket Information ──────────────────────────────────────────── 'name' => $this->field('name', [ 'label' => 'Name', 'panel' => 'ticket_information', 'type' => 'text', 'order' => 10, 'col_class' => 'col-lg-12', 'required' => true, ]), 'ticket_num' => $this->field('ticket_num', [ 'label' => 'Ticket #', 'panel' => 'ticket_information', 'type' => 'text', 'order' => 20, 'col_class' => 'col-lg-4', 'can_change_type' => false, 'can_change_required' => false, 'can_change_visibility' => false, 'required' => false, ]), 'due_date' => $this->field('due_date', [ 'label' => 'Due Date', 'panel' => 'ticket_information', 'type' => 'date', 'order' => 30, 'col_class' => 'col-lg-4', 'form_type' => 'dateType', 'can_change_type' => false, ]), 'due_time' => $this->field('due_time', [ 'label' => 'Due Time', 'panel' => 'ticket_information', 'type' => 'text', 'order' => 40, 'col_class' => 'col-lg-4', ]), 'owned_by' => $this->field('owned_by', [ 'label' => 'Assigned To', 'panel' => 'ticket_information', 'type' => 'assigned_to', 'order' => 50, 'col_class' => 'col-lg-6', 'form_type' => 'assigned_to', 'can_change_type' => false, 'can_change_required' => false, ]), 'ticket_owner_id' => $this->field('ticket_owner_id', [ 'label' => 'Created By', 'panel' => 'ticket_information', 'type' => 'manytoone', 'order' => 55, 'col_class' => 'col-lg-6', 'can_change_type' => false, 'can_change_required' => false, 'can_change_visibility' => false, ]), // ── Details ─────────────────────────────────────────────────────── 'ticket_priority_id' => $this->field('ticket_priority_id', [ 'label' => 'Priority', 'panel' => 'details', 'type' => 'select', 'order' => 10, 'col_class' => 'col-lg-6', 'form_type' => 'ticket_priority_select', 'can_change_type' => false, ]), 'ticket_status_id' => $this->field('ticket_status_id', [ 'label' => 'Status', 'panel' => 'details', 'type' => 'select', 'order' => 20, 'col_class' => 'col-lg-6', 'form_type' => 'ticket_status_select', 'can_change_type' => false, ]), 'ticket_severity_id' => $this->field('ticket_severity_id', [ 'label' => 'Severity', 'panel' => 'details', 'type' => 'select', 'order' => 30, 'col_class' => 'col-lg-6', 'form_type' => 'ticket_severity_select', 'can_change_type' => false, ]), 'ticket_category_id' => $this->field('ticket_category_id', [ 'label' => 'Category', 'panel' => 'details', 'type' => 'select', 'order' => 40, 'col_class' => 'col-lg-6', 'form_type' => 'ticket_category_select', 'can_change_type' => false, ]), // ── Description & Notes ─────────────────────────────────────────── 'description' => $this->field('description', [ 'label' => 'Description', 'panel' => 'description_notes', 'type' => 'textarea', 'order' => 10, 'col_class' => 'col-lg-12', 'form_type' => 'textarea', 'can_change_type' => false, ]), 'resolution' => $this->field('resolution', [ 'label' => 'Resolution', 'panel' => 'description_notes', 'type' => 'textarea', 'order' => 20, 'col_class' => 'col-lg-12', 'form_type' => 'textarea', 'can_change_type' => false, ]), 'notes' => $this->field('notes', [ 'label' => 'Notes', 'panel' => 'description_notes', 'type' => 'textarea', 'order' => 30, 'col_class' => 'col-lg-12', 'form_type' => 'textarea', 'can_change_type' => false, ]), // ── CRM Links ───────────────────────────────────────────────────── 'lead_id' => $this->field('lead_id', [ 'label' => 'Lead', 'panel' => 'crm_links', 'type' => 'manytoone', 'order' => 10, 'col_class' => 'col-lg-6', 'can_change_type' => false, 'can_change_required' => false, ]), 'contact_id' => $this->field('contact_id', [ 'label' => 'Contact', 'panel' => 'crm_links', 'type' => 'manytoone', 'order' => 20, 'col_class' => 'col-lg-6', 'can_change_type' => false, 'can_change_required' => false, ]), 'account_id' => $this->field('account_id', [ 'label' => 'Account', 'panel' => 'crm_links', 'type' => 'manytoone', 'order' => 30, 'col_class' => 'col-lg-6', 'can_change_type' => false, 'can_change_required' => false, ]), 'parent_id' => $this->field('parent_id', [ 'label' => 'Parent Ticket', 'panel' => 'crm_links', 'type' => 'manytoone', 'order' => 40, 'col_class' => 'col-lg-6', 'can_change_type' => false, 'can_change_required' => false, ]), ]; } // ── Helpers ─────────────────────────────────────────────────────────────── private function field(string $key, array $overrides): array { return array_merge([ 'key' => $key, 'is_custom' => false, 'panel' => 'details', 'type' => 'text', 'form_type' => null, 'label' => ucwords(str_replace('_', ' ', $key)), 'visible' => true, 'required' => false, 'order' => 100, 'col_class' => 'col-lg-6 col-md-6', 'max_length' => null, 'options' => [], 'depends_on' => null, 'depends_map' => '', 'can_change_label' => true, 'can_change_visibility' => true, 'can_change_required' => true, 'can_change_order' => true, 'can_change_type' => false, ], $overrides); } private function customFieldDefinitions(array $savedCustom): array { $fields = []; $order = 1000; foreach ($savedCustom as $row) { $key = $row['key'] ?? ''; if ($key === '') continue; $fields[$key] = [ 'key' => $key, 'is_custom' => true, 'label' => $row['label'] ?? ucwords(str_replace('_', ' ', $key)), 'panel' => $this->normPanel($row['panel'] ?? 'details'), 'type' => $this->normType($row['type'] ?? 'text'), 'form_type' => null, 'visible' => $this->normBool($row['visible'] ?? true), 'required' => $this->normBool($row['required'] ?? false), 'order' => $this->normOrder($row['order'] ?? $order++), 'col_class' => 'col-lg-6 col-md-6', 'max_length' => isset($row['max_length']) ? (int) $row['max_length'] ?: null : null, 'options' => $this->normOptions($row['options'] ?? []), 'depends_on' => $row['depends_on'] ?? null, 'depends_map' => $row['depends_map'] ?? '', 'can_change_label' => true, 'can_change_visibility' => true, 'can_change_required' => true, 'can_change_order' => true, 'can_change_type' => true, ]; } return $fields; } private function getStoredSettings(User $user): array { if (!$this->hasSettingsTable()) { return []; } $record = TicketFieldSetting::query() ->where('company_id', $user->company_id) ->first(); if (!$record || !is_array($record->settings)) { return []; } return $record->settings; } private function hasSettingsTable(): bool { return Schema::hasTable('ticket_field_settings'); } // ── Panel titles ────────────────────────────────────────────────────────── /** Effective panel titles (defaults merged with any saved custom titles). */ public function getPanelLabels(User $user): array { return array_merge(self::PANELS, $this->getPanelLabelOverrides($user)); } /** * Saved custom panel titles only — used by the shared Html::section renderer * so renamed titles appear on the real create/edit/show pages. */ public function getPanelLabelOverrides(User $user): array { $settings = $this->getStoredSettings($user); $stored = Arr::get($settings, 'panel_labels', []); if (!is_array($stored)) { return []; } $overrides = []; foreach (array_keys(self::PANELS) as $panel) { if (isset($stored[$panel]) && trim((string) $stored[$panel]) !== '') { $overrides[$panel] = trim((string) $stored[$panel]); } } return $overrides; } /** Normalise submitted panel_labels[] — known panels only, blanks dropped. */ private function normalizePanelLabels($input): array { $labels = []; if (is_array($input)) { foreach (array_keys(self::PANELS) as $panel) { $value = trim((string) Arr::get($input, $panel, '')); if ($value === '' || $value === self::PANELS[$panel]) { continue; } $labels[$panel] = mb_substr($value, 0, 120); } } return $labels; } // ── Form field builders ─────────────────────────────────────────────────── private function addBuiltInFieldToForm(Form $form, array $field): void { $key = $field['key']; $label = $field['label']; $type = $field['type']; $formType = $field['form_type'] ?? $this->inferFormType($type); $baseOpts = ['label' => $label]; if ($field['required']) { $baseOpts['rules'] = ['required']; } switch ($formType) { case 'ticket_priority_select': $form->add($key, 'select', array_merge($baseOpts, [ 'choices' => TicketPriority::all()->pluck('name', 'id')->toArray(), 'attr' => ['class' => 'select2 pmd-select2 form-control'], 'empty_value' => trans('core::core.empty_select'), ])); break; case 'ticket_status_select': $form->add($key, 'select', array_merge($baseOpts, [ 'choices' => TicketStatus::all()->pluck('name', 'id')->toArray(), 'attr' => ['class' => 'select2 pmd-select2 form-control'], 'empty_value' => trans('core::core.empty_select'), ])); break; case 'ticket_severity_select': $form->add($key, 'select', array_merge($baseOpts, [ 'choices' => TicketSeverity::all()->pluck('name', 'id')->toArray(), 'attr' => ['class' => 'select2 pmd-select2 form-control'], 'empty_value' => trans('core::core.empty_select'), ])); break; case 'ticket_category_select': $form->add($key, 'select', array_merge($baseOpts, [ 'choices' => TicketCategory::all()->pluck('name', 'id')->toArray(), 'attr' => ['class' => 'select2 pmd-select2 form-control'], 'empty_value' => trans('core::core.empty_select'), ])); break; case 'assigned_to': $form->add($key, 'select', array_merge($baseOpts, [ 'choices' => FormHelper::assignedToChoises(), 'attr' => ['class' => 'select2 pmd-select2 form-control'], 'label' => trans('core::core.form.assigned_to'), 'empty_value' => trans('core::core.empty_select'), 'selected' => FormHelper::assignSelectedFromModel($form->getModel()), ])); break; case 'textarea': $form->add($key, 'textarea', array_merge($baseOpts, [ 'attr' => ['rows' => 4], ])); break; case 'dateTimeType': $form->add($key, 'dateTimeType', $baseOpts); break; case 'dateType': $form->add($key, 'dateType', $baseOpts); break; case 'manytoone': $this->addManyToOneField($form, $key, $baseOpts); break; default: $attr = ['class' => 'form-control']; if ($field['max_length']) { $attr['maxlength'] = $field['max_length']; } $form->add($key, 'text', array_merge($baseOpts, ['attr' => $attr])); break; } } private function addManyToOneField(Form $form, string $key, array $baseOpts): void { switch ($key) { case 'lead_id': $form->add($key, 'manyToOne', array_merge($baseOpts, [ 'search_route' => route('leads.leads.index', ['mode' => 'modal']), 'relation' => 'lead', 'relation_field' => 'full_name', 'model' => $form->getModel(), 'attr' => ['class' => 'form-control manyToOne'], 'empty_value' => trans('core::core.empty_select'), ])); break; case 'contact_id': $form->add($key, 'manyToOne', array_merge($baseOpts, [ 'search_route' => route('contacts.contacts.index', ['mode' => 'modal']), 'relation' => 'contact', 'relation_field' => 'full_name', 'model' => $form->getModel(), 'attr' => ['class' => 'form-control manyToOne'], 'empty_value' => trans('core::core.empty_select'), ])); break; case 'account_id': $form->add($key, 'manyToOne', array_merge($baseOpts, [ 'search_route' => route('accounts.accounts.index', ['mode' => 'modal']), 'relation' => 'account', 'relation_field' => 'name', 'model' => $form->getModel(), 'attr' => ['class' => 'form-control manyToOne'], 'empty_value' => trans('core::core.empty_select'), ])); break; case 'parent_id': $form->add($key, 'manyToOne', array_merge($baseOpts, [ 'search_route' => route('tickets.tickets.index', ['mode' => 'modal']), 'relation' => 'parent', 'relation_field' => 'name', 'model' => $form->getModel(), 'attr' => ['class' => 'form-control manyToOne'], 'empty_value' => trans('core::core.empty_select'), ])); break; } } private function addCustomFieldToForm(Form $form, array $field, $value): void { $key = 'custom_data[' . $field['key'] . ']'; $label = $field['label']; $type = $field['type']; $baseOpts = ['label' => $label, 'value' => $value]; if ($field['required']) { $baseOpts['rules'] = ['required']; } switch ($type) { case 'select': $form->add($key, 'select', array_merge($baseOpts, [ 'choices' => $field['options'], 'attr' => ['class' => 'select2 pmd-select2 form-control'], 'empty_value' => '— select —', ])); break; case 'textarea': $form->add($key, 'textarea', array_merge($baseOpts, [ 'attr' => ['rows' => 4], ])); break; case 'date': $form->add($key, 'dateType', $baseOpts); break; case 'datetime': $form->add($key, 'dateTimeType', $baseOpts); break; case 'checkbox': $form->add($key, 'checkbox', $baseOpts); break; default: $inputType = in_array($type, ['email', 'tel', 'number', 'url']) ? $type : 'text'; $form->add($key, 'text', array_merge($baseOpts, [ 'attr' => ['class' => 'form-control', 'type' => $inputType], ])); break; } } // ── Show field type resolution ──────────────────────────────────────────── private function resolveShowFieldOptions(string $key, array $field): array { switch ($key) { case 'owned_by': return ['type' => 'assigned_to']; case 'ticket_owner_id': return ['type' => 'manyToOne', 'relation' => 'ticketOwner', 'column' => 'name']; case 'ticket_priority_id': return ['type' => 'manyToOne', 'relation' => 'ticketPriority', 'column' => 'name']; case 'ticket_status_id': return ['type' => 'manyToOne', 'relation' => 'ticketStatus', 'column' => 'name']; case 'ticket_severity_id': return ['type' => 'manyToOne', 'relation' => 'ticketSeverity', 'column' => 'name']; case 'ticket_category_id': return ['type' => 'manyToOne', 'relation' => 'ticketCategory', 'column' => 'name']; case 'lead_id': return [ 'type' => 'manyToOne', 'relation' => 'lead', 'column' => 'full_name', 'dont_translate' => true, 'route' => 'leads.leads.show', ]; case 'contact_id': return [ 'type' => 'manyToOne', 'relation' => 'contact', 'column' => 'full_name', 'dont_translate' => true, ]; case 'account_id': return [ 'type' => 'manyToOne', 'relation' => 'account', 'column' => 'name', 'dont_translate' => true, ]; case 'parent_id': return [ 'type' => 'manyToOne', 'relation' => 'parent', 'column' => 'name', 'dont_translate' => true, ]; case 'due_date': return ['type' => 'date']; case 'description': case 'resolution': case 'notes': return ['type' => 'text', 'col-class' => 'col-lg-12 col-md-12 col-sm-12']; default: return ['type' => $this->mapShowType($field['type'])]; } } // ── Normalisation helpers ───────────────────────────────────────────────── private function normBool($value): bool { if (is_bool($value)) return $value; if (is_string($value)) { return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); } return (bool) $value; } private function normOrder($value, int $default = 100): int { $v = (int) $value; return $v > 0 ? min($v, 9999) : $default; } private function normType($value): string { return array_key_exists((string) $value, self::TYPE_OPTIONS) ? (string) $value : 'text'; } private function normPanel($value): string { return array_key_exists((string) $value, self::PANELS) ? (string) $value : 'details'; } private function normOptions($value): array { if (is_string($value)) { $items = preg_split('/[\n\r,]+/', $value); $out = []; foreach ($items as $item) { $item = trim($item); if ($item !== '') $out[$item] = $item; } return $out; } if (!is_array($value)) return []; $out = []; foreach ($value as $k => $v) { $k = trim((string) $k); $v = trim((string) $v); if ($k !== '' && $v !== '') $out[$k] = $v; } return $out; } private function sanitizeKey(string $raw): string { $key = strtolower(preg_replace('/[^a-z0-9_]/', '', str_replace([' ', '-'], '_', $raw))); return strlen($key) <= 60 ? $key : substr($key, 0, 60); } private function inferFormType(string $type): string { return match ($type) { 'textarea' => 'textarea', 'datetime' => 'dateTimeType', 'date' => 'dateType', 'select' => 'select', 'manytoone' => 'manytoone', default => 'text', }; } private function normMaxLength($value, string $type): ?int { $textTypes = ['text', 'email', 'tel', 'url', 'textarea']; if (!in_array($type, $textTypes, true)) return null; $v = (int) $value; return $v > 0 ? min($v, 10000) : null; } private function normDependsOn($value): ?string { $v = trim((string) ($value ?? '')); return $v !== '' ? $v : null; } private function normApiIndexFields(array $input, array $builtIn, array $custom): array { $raw = Arr::get($input, 'api_index_fields', []); if (!is_array($raw)) $raw = []; $visibleKeys = []; foreach ($builtIn as $key => $bi) { if (!empty($bi['visible'])) $visibleKeys[] = $key; } foreach ($custom as $cf) { if (!empty($cf['visible'])) $visibleKeys[] = $cf['key']; } return array_slice( array_values(array_intersect(array_filter($raw, 'is_string'), $visibleKeys)), 0, 4 ); } private function mapShowType(string $type): string { return match ($type) { 'email' => 'email', 'date', 'datetime' => 'text', default => 'text', }; } }