'Call Information', 'log_details' => 'Log Details', 'follow_up' => 'Follow-Up', 'crm_links' => 'CRM Links', ]; private const PANEL_ORDER = [ 'call_information' => 10, 'log_details' => 20, 'follow_up' => 30, 'crm_links' => 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) { // Normalise to the same property set the leads crm.blade.php expects $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']; // only custom fields have editable max_length $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'], ['call_date', 'phone_number', 'direction', 'queue'])) { continue; } 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']); } // depends_on / depends_map are always overridable on built-in fields $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 CallLog|null $model */ public function buildFormFields(Form $form, User $user, $model = null): void { $customData = []; if ($model instanceof CallLog && is_array($model->custom_data)) { $customData = $model->custom_data; } foreach ($this->getEffectiveFields($user) as $field) { if (!$field['visible']) { continue; } $key = $field['key']; $label = $field['label']; $type = $field['type']; if ($field['is_custom']) { $value = Arr::get($customData, $key); $this->addCustomFieldToForm($form, $field, $value); continue; } // Built-in field — delegate to the appropriate form builder helper $this->addBuiltInFieldToForm($form, $field, $label, $type); } $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']; // The panel key must match the name used when adding the field to // the Kris form builder — otherwise the view's $form->{$fieldName} // lookup returns null and form_end() renders the field at the bottom. $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; // bare key for value lookup in show view } elseif (in_array($key, ['lead_id', 'contact_id'])) { $options['type'] = 'manyToOne'; $options['relation'] = $key === 'lead_id' ? 'lead' : 'contact'; $options['column'] = 'full_name'; $options['dont_translate'] = true; } else { $options['type'] = $this->mapShowType($field['type']); } 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'] || in_array($field['key'], ['call_date', 'phone_number', 'direction', 'queue'])) { 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', 'log_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 = CallLogFieldSetting::query() ->where('company_id', $user->company_id) ->first(); if (!$record) { $record = new CallLogFieldSetting(); $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; } CallLogFieldSetting::query() ->where('company_id', $user->company_id) ->delete(); return true; } // ── Default field definitions ───────────────────────────────────────────── public function defaultFieldDefinitions(): array { return [ // ── Call Information ────────────────────────────────────────────── 'phone_number' => $this->field('phone_number', [ 'label' => 'Phone Number', 'panel' => 'call_information', 'type' => 'tel', 'order' => 10, 'col_class' => 'col-lg-4', 'form_type' => 'hiddenType', 'hide_in_create' => true, 'system_generated' => true, ]), 'direction' => $this->field('direction', [ 'label' => 'Direction', 'panel' => 'call_information', 'type' => 'select', 'order' => 20, 'col_class' => 'col-lg-4', 'options' => ['inbound' => 'Inbound', 'outbound' => 'Outbound', 'missed' => 'Missed'], 'form_type' => 'hiddenType', 'hide_in_create' => true, 'system_generated' => true, ]), 'call_date' => $this->field('call_date', [ 'label' => 'Call Date', 'panel' => 'call_information', 'type' => 'datetime', 'order' => 30, 'col_class' => 'col-lg-4', 'form_type' => 'hiddenType', 'hide_in_create' => true, 'system_generated' => true, ]), // ── Log Details ─────────────────────────────────────────────────── 'subject' => $this->field('subject', [ 'label' => 'Subject', 'panel' => 'log_details', 'type' => 'text', 'order' => 10, 'col_class' => 'col-lg-12', ]), 'status' => $this->field('status', [ 'label' => 'Status', 'panel' => 'log_details', 'type' => 'select', 'order' => 20, 'col_class' => 'col-lg-4', 'form_type' => 'select', 'options' => $this->loadDictOptions(CallLogStatus::class, [ 'New', 'In Progress', 'Follow-Up Scheduled', 'Pending Response', 'Closed - Won', 'Closed - Lost', 'Not Interested', ]), ]), 'queue' => $this->field('queue', [ 'label' => 'Queue', 'panel' => 'log_details', 'type' => 'text', 'order' => 25, 'col_class' => 'col-lg-4', 'form_type' => 'hiddenType', 'hide_in_create' => true, 'system_generated' => true, ]), 'source' => $this->field('source', [ 'label' => 'Source', 'panel' => 'log_details', 'type' => 'select', 'order' => 30, 'col_class' => 'col-lg-4', 'form_type' => 'select', 'options' => $this->loadDictOptions(CallLogSource::class, [ 'Softphone', 'Campaign', 'Inbound Web', 'Referral', 'WhatsApp', 'Email', 'Manual', 'Other', ]), ]), 'outcome' => $this->field('outcome', [ 'label' => 'Outcome', 'panel' => 'log_details', 'type' => 'select', 'order' => 40, 'col_class' => 'col-lg-4', 'form_type' => 'select', 'options' => $this->loadDictOptions(CallLogOutcome::class, [ 'Answered - Interested', 'Answered - Not Interested', 'Left Voicemail', 'No Answer', 'Callback Requested', 'Follow-Up Needed', 'Sale Closed', 'Other', ]), ]), 'notes' => $this->field('notes', [ 'label' => 'Notes', 'panel' => 'log_details', 'type' => 'textarea', 'order' => 50, 'col_class' => 'col-lg-12', 'form_type' => 'textarea', ]), // ── Follow-Up ───────────────────────────────────────────────────── 'follow_up_date' => $this->field('follow_up_date', [ 'label' => 'Follow-Up Date', 'panel' => 'follow_up', 'type' => 'date', 'order' => 10, 'col_class' => 'col-lg-4', 'form_type' => 'dateType', ]), 'follow_up_note' => $this->field('follow_up_note', [ 'label' => 'Follow-Up Note', 'panel' => 'follow_up', 'type' => 'text', 'order' => 20, 'col_class' => 'col-lg-8', ]), // ── 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, ]), ]; } // ── Helpers ─────────────────────────────────────────────────────────────── /** * Load select options from a dictionary table. * Falls back to $defaults when the table is empty or does not exist yet. * Options are keyed and valued by the name string so existing call log * records (which store the name, not an ID) remain compatible. * * @param class-string $modelClass * @param string[] $defaults */ private function loadDictOptions(string $modelClass, array $defaults): array { try { $names = $modelClass::orderBy('name')->pluck('name')->toArray(); } catch (\Throwable $e) { $names = []; } if (empty($names)) { $names = $defaults; } $options = []; foreach ($names as $name) { $options[$name] = $name; } return $options; } private function field(string $key, array $overrides): array { return array_merge([ 'key' => $key, 'is_custom' => false, 'panel' => 'log_details', 'type' => 'text', 'form_type' => null, // null → inferred from type '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'] ?? 'log_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 = CallLogFieldSetting::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('call_log_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, string $label, string $type): void { $key = $field['key']; $formType = $field['form_type'] ?? $this->inferFormType($type); $baseOpts = ['label' => $label]; if ($field['required']) { $baseOpts['rules'] = ['required']; } switch ($formType) { case 'hiddenType': // Not rendered in forms — value is set automatically by CDR sync break; 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 'dateTimeType': $form->add($key, 'dateTimeType', $baseOpts); break; case 'dateType': $form->add($key, 'dateType', $baseOpts); break; case 'manytoone': if ($key === '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'), ])); } else { $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; 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 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; } } // ── 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 : 'log_details'; } private function normOptions($value): array { // Accept textarea-format string (one per line or comma-separated) 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', }; } }