'Text', 'textarea' => 'Textarea', 'email' => 'Email', 'tel' => 'Phone', 'number' => 'Number', 'url' => 'URL', 'date' => 'Date', 'datetime' => 'Date & Time', 'select' => 'Dropdown', 'checkbox' => 'Checkbox', 'assigned_to' => 'Assigned To', 'manytoone' => 'Lookup', 'manytomany' => 'Multi Select', 'file' => 'File', ]; private const PANEL_ORDER = [ 'lead_information' => 10, 'lead_contact_data' => 20, 'lead_company' => 30, 'address_information' => 40, 'social' => 50, 'description' => 60, ]; private $hasSettingsTable = null; private $hasCustomFieldsColumn = null; public function getSettingsEditorData(User $user) { $fields = $this->getEffectiveFields($user); $grouped = []; foreach (self::EDITABLE_PANELS as $panel) { $grouped[$panel] = []; } foreach ($fields as $field) { $field['type_options'] = $this->getTypeOptionsForAllowedTypes($field['allowed_types']); $field['is_type_locked'] = !$field['can_change_type']; $field['is_required_locked'] = !$field['can_change_required']; $field['is_visibility_locked'] = !$field['can_change_visibility']; $field['is_max_length_locked'] = !$field['can_change_max_length']; $field['is_order_locked'] = !Arr::get($field, 'can_change_order', true); $panel = $field['panel']; if (!isset($grouped[$panel])) { $grouped[$panel] = []; } $grouped[$panel][] = $field; } // Sort fields within each panel by order foreach ($grouped as $panel => &$panelFields) { usort($panelFields, function ($a, $b) { return ($a['order'] ?? 0) <=> ($b['order'] ?? 0); }); } unset($panelFields); return [ 'groupedFields' => $grouped, 'allTypeOptions' => self::TYPE_OPTIONS, 'panelOptions' => $this->getPanelLabels($user), ]; } public function getSelectableFieldOptions(User $user): array { $options = []; foreach ($this->getEffectiveFields($user) as $field) { if ($this->isSelectableField($field)) { $options[$field['key']] = $field['label']; } } return $options; } public function getVisibleCustomFieldsForIndex(User $user) { $fields = []; foreach ($this->getEffectiveFields($user) as $field) { if (!$field['is_custom'] || !$field['visible']) { continue; } $fields[] = $field; } return $fields; } /** * Return all visible fields in CRM settings order, for the API. */ public function getVisibleFieldsForApi(User $user) { $result = []; foreach ($this->getEffectiveFields($user) as $field) { if (!$field['visible']) { continue; } $result[] = $field; } return $result; } /** * Return all custom field keys defined via CRM settings (visible or not). * * @param User $user * @return string[] */ public function getCustomFieldKeys(User $user): array { $keys = []; foreach ($this->getEffectiveFields($user) as $field) { if ($field['is_custom']) { $keys[] = $field['key']; } } return $keys; } /** * Return field definitions that expect file uploads (custom fields only). */ public function getFileUploadFields(User $user): array { $fields = []; foreach ($this->getEffectiveFields($user) as $field) { if (($field['type'] ?? null) !== 'file') { continue; } if (!empty($field['special'])) { continue; } $fields[] = $field; } return $fields; } /** * Return all column keys ever auto-provisioned via CRM settings. * Keys present here but absent from current custom settings are orphaned * (deleted from settings) and are hidden from API responses. * * @param User $user * @return string[] */ public function getProvisionedColumnKeys(User $user): array { $settings = $this->getStoredSettings($user); $keys = Arr::get($settings, 'provisioned_columns', []); return is_array($keys) ? array_values($keys) : []; } public function getColumnBackedFieldKeys(User $user): array { $keys = []; foreach ($this->getEffectiveFields($user) as $field) { if (!empty($field['uses_column'])) { $keys[] = $field['storage_key']; } } return array_values(array_unique(array_filter($keys))); } /** * Return custom field definitions that are NOT backed by a real provisioned * column, i.e. their values live inside the `custom_fields` JSON column. * These need special handling during CSV import (packing into the JSON). * * @param User $user * @return array */ public function getJsonBackedCustomFields(User $user): array { $definitions = []; foreach ($this->getEffectiveFields($user) as $field) { if ($field['is_custom'] && empty($field['uses_column'])) { $definitions[$field['key']] = [ 'type' => $field['type'], 'options' => $field['options'] ?? [], ]; } } return $definitions; } /** * Add a nullable string column to the leads table for a custom field. */ private function provisionCustomFieldColumn(string $key): void { Schema::table('leads', function (Blueprint $table) use ($key) { $table->string($key)->nullable(); }); } /** * Migrate any existing custom_fields JSON value for $key to the newly created real column. * Runs once after a column is first provisioned so no data is lost. */ private function migrateCustomFieldJsonToColumn(string $key): void { DB::table('leads') ->whereNotNull('custom_fields') ->lazyById() ->each(function ($row) use ($key) { $data = is_string($row->custom_fields) ? json_decode($row->custom_fields, true) : null; if (is_array($data) && array_key_exists($key, $data) && $data[$key] !== null) { DB::table('leads')->where('id', $row->id)->update([$key => $data[$key]]); } }); } /** * Return the field keys flagged for API index display (max 4). */ public function getApiIndexFieldKeys(User $user) { $settings = $this->getStoredSettings($user); $keys = Arr::get($settings, 'api_index_fields', []); if (!is_array($keys)) { $keys = []; } return array_slice(array_values(array_filter($keys)), 0, 4); } /** * Return full field definitions for the flagged API index fields. */ public function getApiIndexFields(User $user) { $flaggedKeys = $this->getApiIndexFieldKeys($user); if (empty($flaggedKeys)) { return []; } $allFields = $this->getEffectiveFields($user); $result = []; foreach ($flaggedKeys as $key) { foreach ($allFields as $field) { if ($field['key'] === $key && $field['visible']) { $result[] = [ 'key' => $field['key'], 'label' => $field['label'], 'type' => $field['type'], 'is_custom' => $field['is_custom'], 'special' => $field['special'], 'uses_column' => $field['uses_column'] ?? false, ]; break; } } } return $result; } public function getVisibleFieldKeys(User $user) { $keys = []; foreach ($this->getEffectiveFields($user) as $field) { if ($field['visible']) { $keys[] = $field['key']; } } return $keys; } public function getFieldOrderMap(User $user) { $map = []; $panelOrder = self::PANEL_ORDER; foreach ($this->getEffectiveFields($user) as $field) { $panelBase = ($panelOrder[$field['panel']] ?? 99) * 1000; $map[$field['key']] = $panelBase + $field['order']; } return $map; } /** Map of key => current label (including any user overrides). */ public function getFieldLabelMap(User $user) { $map = []; foreach ($this->getEffectiveFields($user) as $field) { $map[$field['key']] = $field['label']; } return $map; } public function getShowFields(User $user) { $groupedPanels = []; foreach ($this->getEffectiveFields($user) as $field) { if (!$field['visible']) { continue; } $panel = $field['panel']; $fieldName = $field['form_name']; $options = [ 'col-class' => $field['col_class'], 'label' => $field['label'], ]; if (!empty($field['hide_in_form'])) { $options['hide_in_form'] = true; } if (!empty($field['hide_in_show'])) { $options['hide_in_show'] = true; } if (!empty($field['hide_label'])) { $options['hide_label'] = true; } if ($field['is_custom']) { $options['type'] = 'custom'; $options['display_type'] = $this->mapShowType($field['type']); $options['custom_field_key'] = $field['storage_key']; } elseif ($field['special'] !== null) { $options = array_merge($options, $this->specialShowOptions($field)); } else { $options['type'] = $this->mapShowType($field['type']); } if (!isset($groupedPanels[$panel])) { $groupedPanels[$panel] = []; } $groupedPanels[$panel][$fieldName] = $options; } $sorted = []; foreach (self::PANEL_ORDER as $panel => $order) { if (!empty($groupedPanels[$panel])) { $sorted[$panel] = $groupedPanels[$panel]; } } foreach ($groupedPanels as $panel => $fields) { if (!isset($sorted[$panel])) { $sorted[$panel] = $fields; } } return $sorted; } public function buildFormFields(Form $form, User $user, $model = null) { $customValues = []; if ($model instanceof Lead && is_array($model->custom_fields)) { $customValues = $model->custom_fields; } foreach ($this->getEffectiveFields($user) as $field) { if (!$field['visible']) { // Phone / Mobile (Caller-ID) fields are hidden for users whose // "Show CID (Phone)" (show_cid) permission is disabled. They // must still be submitted as hidden inputs so a number that // arrives through the softphone "GoTo Profile" flow (e.g. // ?phone=...) is persisted with the Lead without ever being // displayed to the user. if (in_array($field['form_name'], ['phone', 'mobile'], true)) { $form->add($field['form_name'], 'hidden', [ 'value' => $this->readModelValue($field['form_name'], $model), // Never expose these in the bulk "Mass update" modal // (they would otherwise be submitted as empty values // and clear the stored numbers). 'removeFromMassUpdate' => true, ]); } continue; } if (!empty($field['hide_in_form'])) { continue; } if ($field['special'] !== null) { $this->addSpecialField($form, $field, $user, $model); continue; } $value = null; if ($field['is_custom']) { if (!empty($field['uses_column']) && $model !== null) { $value = $model->{$field['storage_key']} ?? null; } else { $value = Arr::get($customValues, $field['storage_key']); } if (($field['type'] ?? null) === 'manytomany' && is_string($value) && $value !== '') { $decoded = json_decode($value, true); if (is_array($decoded)) { $value = $decoded; } } } $this->addSimpleField($form, $field, $value); } } public function getValidationRules(User $user) { $rules = []; $hasVisibleCustomFields = false; foreach ($this->getEffectiveFields($user) as $field) { if (!$field['visible']) { continue; } if ($field['is_custom'] && empty($field['uses_column'])) { $hasVisibleCustomFields = true; } if ($field['special'] !== null) { $rules = array_merge($rules, $this->specialValidationRules($field)); continue; } $rules[$field['validation_key']] = $this->simpleValidationRules($field); if (($field['type'] ?? null) === 'manytomany') { $allowed = $this->normalizeOptions($field['options'] ?? []); $rules[$field['validation_key']][] = function ($attribute, $value, $fail) use ($allowed, $field) { if ($value === null) { return; } $items = Arr::flatten(is_array($value) ? $value : [$value]); foreach ($items as $item) { if (!is_scalar($item) || trim((string) $item) === '') { $fail($field['label'] . ' contains an invalid selection.'); return; } if (!empty($allowed) && !in_array((string) $item, $allowed, true)) { $fail($field['label'] . ' contains an invalid selection.'); return; } } }; } } if ($hasVisibleCustomFields && !isset($rules['custom_fields'])) { $rules['custom_fields'] = ['nullable', 'array']; } return $rules; } public function normalizeMultiValueInput(User $user, array $input): array { foreach ($this->getEffectiveFields($user) as $field) { $expectsArray = ($field['type'] ?? null) === 'manytomany' || ($field['special'] ?? null) === 'multiselect_choice'; if (!$expectsArray) { continue; } $validationKey = $field['validation_key'] ?? $field['key']; $value = Arr::get($input, $validationKey); if ($value === null) { continue; } $normalized = is_array($value) ? Arr::flatten($value) : [$value]; $normalized = array_values(array_filter(array_map(function ($item) { if (is_scalar($item)) { return trim((string) $item); } return null; }, $normalized), static function ($item) { return $item !== null && $item !== ''; })); Arr::set($input, $validationKey, $normalized); } return $input; } public function saveSettings(User $user, array $input) { if (!$this->hasSettingsTable()) { return false; } $defaults = $this->defaultFieldDefinitions($user); $builtInInput = Arr::get($input, 'built_in', []); $customInput = Arr::get($input, 'custom', []); if (!is_array($builtInInput)) { $builtInInput = []; } if (!is_array($customInput)) { $customInput = []; } $builtIn = []; foreach ($defaults as $key => $field) { $payload = Arr::get($builtInInput, $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->normalizeBool(Arr::get($payload, 'visible', $field['visible'])) : $field['visible']; $required = $field['can_change_required'] ? $this->normalizeBool(Arr::get($payload, 'required', $field['required'])) : $field['required']; if (!$visible) { $required = false; } $type = $field['can_change_type'] ? $this->normalizeType(Arr::get($payload, 'type', $field['type']), $field['allowed_types'], $field['type']) : $field['type']; $maxLength = $field['can_change_max_length'] ? $this->normalizeMaxLength(Arr::get($payload, 'max_length', $field['max_length']), $type) : $field['max_length']; $order = $field['can_change_order'] ? $this->normalizeOrder(Arr::get($payload, 'order', $field['order']), $field['order']) : $field['order']; $panel = $this->normalizePanel(Arr::get($payload, 'panel', $field['panel'])); $builtIn[$key] = [ 'label' => $label, 'visible' => $visible, 'required' => $required, 'type' => $type, 'max_length' => $maxLength, 'order' => $order, 'panel' => $panel, ]; } $custom = []; $reservedKeys = array_keys($defaults); $used = []; $customOrderFallback = 2000; if ($this->supportsCustomFields()) { foreach ($customInput as $row) { if (!is_array($row)) { continue; } $rawKey = Arr::get($row, 'key', Arr::get($row, 'name', '')); $key = $this->sanitizeCustomKey($rawKey); if ($key === '' || in_array($key, $reservedKeys, true) || isset($used[$key])) { continue; } $used[$key] = true; $label = trim((string) Arr::get($row, 'label', '')); if ($label === '') { $label = Str::title(str_replace('_', ' ', $key)); } $visible = $this->normalizeBool(Arr::get($row, 'visible', true)); $required = $visible ? $this->normalizeBool(Arr::get($row, 'required', false)) : false; $type = $this->normalizeType(Arr::get($row, 'type', 'text'), array_keys(self::TYPE_OPTIONS), 'text'); $maxLength = $this->normalizeMaxLength(Arr::get($row, 'max_length', null), $type); $options = $this->normalizeOptions(Arr::get($row, 'options', [])); if (!$this->supportsOptionsForType($type)) { $options = []; } $panel = $this->normalizePanel(Arr::get($row, 'panel', self::DEFAULT_CUSTOM_PANEL)); $order = $this->normalizeOrder(Arr::get($row, 'order', null), $customOrderFallback++); $custom[] = [ 'key' => $key, 'label' => $label, 'visible' => $visible, 'required' => $required, 'type' => $type, 'max_length' => $maxLength, 'options' => $options, 'panel' => $panel, 'order' => $order, '_depends_on_raw' => Arr::get($row, 'depends_on'), '_depends_map_raw' => Arr::get($row, 'depends_map'), ]; } usort($custom, function ($a, $b) { $sort = ($a['order'] ?? 0) <=> ($b['order'] ?? 0); if ($sort !== 0) { return $sort; } return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? '')); }); $custom = array_values($custom); } $selectableKeys = $this->collectSelectableKeys($defaults, $builtIn, $custom); foreach ($builtIn as $key => &$entry) { $payload = Arr::get($builtInInput, $key, []); $dependsOn = $this->normalizeDependencyParent( Arr::get($payload, 'depends_on'), $selectableKeys, $key ); $dependsMap = $this->normalizeDependencyMap(Arr::get($payload, 'depends_map')); $special = $defaults[$key]['special'] ?? null; if (!$this->isSelectableDefinition($entry['type'] ?? null, $special)) { $dependsOn = null; $dependsMap = []; } $entry['depends_on'] = $dependsOn; $entry['depends_map'] = $dependsMap; } unset($entry); foreach ($custom as &$entry) { $dependsOn = $this->normalizeDependencyParent( $entry['_depends_on_raw'] ?? null, $selectableKeys, $entry['key'] ); $dependsMap = $this->normalizeDependencyMap($entry['_depends_map_raw'] ?? null); if (!$this->isSelectableDefinition($entry['type'] ?? null, $entry['special'] ?? null)) { $dependsOn = null; $dependsMap = []; } $entry['depends_on'] = $dependsOn; $entry['depends_map'] = $dependsMap; unset($entry['_depends_on_raw'], $entry['_depends_map_raw']); } unset($entry); $record = LeadUserFieldSetting::query() ->where('company_id', $user->company_id) ->whereNull('user_id') ->first(); if (!$record) { $record = new LeadUserFieldSetting(); $record->company_id = $user->company_id; } $apiIndexFields = Arr::get($input, 'api_index_fields', []); if (!is_array($apiIndexFields)) { $apiIndexFields = []; } $apiIndexFields = array_values(array_filter($apiIndexFields, 'is_string')); // Only allow API index flags on visible fields $visibleKeys = []; foreach ($builtIn as $key => $bi) { if (!empty($bi['visible'])) { $visibleKeys[] = $key; } } foreach ($custom as $cf) { if (!empty($cf['visible'])) { $visibleKeys[] = $cf['key']; } } $apiIndexFields = array_slice(array_values(array_intersect($apiIndexFields, $visibleKeys)), 0, 4); // Track and auto-provision DB columns for custom fields. // Retroactively registers columns that existed before this system was active. $previousSettings = is_array($record->settings ?? null) ? $record->settings : []; $provisionedColumns = array_values(Arr::get($previousSettings, 'provisioned_columns', [])); if (!is_array($provisionedColumns)) { $provisionedColumns = []; } // Register any previously-saved custom field keys that already have a DB column foreach (array_column(Arr::get($previousSettings, 'custom', []), 'key') as $prevKey) { if ($prevKey !== '' && Schema::hasColumn('leads', $prevKey) && !in_array($prevKey, $provisionedColumns, true)) { $provisionedColumns[] = $prevKey; } } // Broader retroactive check: any leads column that isn't a known built-in field // (handles columns created before this system was in place) $builtInKeys = array_keys($this->defaultFieldDefinitions($user)); $alwaysBuiltIn = [ 'id', 'created_at', 'updated_at', 'deleted_at', 'company_id', 'owned_by_type', 'owned_by_id', 'created_by', 'custom_fields', 'full_name', 'addr_country', ]; $knownColumns = array_merge($builtInKeys, $alwaysBuiltIn); $tableColumns = Schema::getColumnListing('leads'); foreach ($tableColumns as $col) { if (!in_array($col, $knownColumns, true) && !in_array($col, $provisionedColumns, true)) { $provisionedColumns[] = $col; } } // Auto-create columns for newly-added custom fields foreach ($custom as $fieldDef) { $key = $fieldDef['key']; if ($this->shouldProvisionColumnForType($fieldDef['type'] ?? 'text') && !Schema::hasColumn('leads', $key)) { $this->provisionCustomFieldColumn($key); $this->migrateCustomFieldJsonToColumn($key); } if ($this->shouldProvisionColumnForType($fieldDef['type'] ?? 'text') && !in_array($key, $provisionedColumns, true)) { $provisionedColumns[] = $key; } } $panelLabels = $this->normalizePanelLabels(Arr::get($input, 'panel_labels', [])); $record->settings = [ 'built_in' => $builtIn, 'custom' => $custom, 'api_index_fields' => $apiIndexFields, 'provisioned_columns' => array_values(array_unique($provisionedColumns)), 'panel_labels' => $panelLabels, ]; $record->save(); // Invalidate schema cache so new columns are visible immediately $this->hasCustomFieldsColumn = null; return true; } public function resetSettings(User $user): bool { if (!$this->hasSettingsTable()) { return false; } LeadUserFieldSetting::query() ->where('company_id', $user->company_id) ->whereNull('user_id') ->delete(); $this->hasCustomFieldsColumn = null; return true; } private function getEffectiveFields(User $user) { $definitions = $this->defaultFieldDefinitions($user); $settings = $this->getStoredSettings($user); foreach ($definitions as $key => &$field) { $overrides = Arr::get($settings, 'built_in.' . $key, []); if (!is_array($overrides)) { $overrides = []; } $label = trim((string) Arr::get($overrides, 'label', $field['label'])); if ($label !== '') { $field['label'] = $label; } if ($field['can_change_visibility']) { $field['visible'] = $this->normalizeBool(Arr::get($overrides, 'visible', $field['visible'])); } if ($field['can_change_required']) { $field['required'] = $this->normalizeBool(Arr::get($overrides, 'required', $field['required'])); } if ($field['can_change_type']) { $field['type'] = $this->normalizeType( Arr::get($overrides, 'type', $field['type']), $field['allowed_types'], $field['type'] ); } if ($field['can_change_max_length']) { $field['max_length'] = $this->normalizeMaxLength( Arr::get($overrides, 'max_length', $field['max_length']), $field['type'] ); } if ($field['can_change_order']) { $field['order'] = $this->normalizeOrder( Arr::get($overrides, 'order', $field['order']), $field['order'] ); } if (Arr::has($overrides, 'panel')) { $field['panel'] = $this->normalizePanel($overrides['panel']); } $field['depends_on'] = Arr::get($overrides, 'depends_on'); $field['depends_map'] = $this->normalizeDependencyMap(Arr::get($overrides, 'depends_map', [])); if (!$field['visible']) { $field['required'] = false; } } unset($field); $custom = $this->supportsCustomFields() ? $this->customFieldDefinitions($settings, array_keys($definitions)) : []; $all = array_merge($definitions, $custom); $all = $this->normalizeFieldDependencies($all); $panelOrder = self::PANEL_ORDER; uasort($all, function ($a, $b) use ($panelOrder) { $panelA = $panelOrder[$a['panel']] ?? 99; $panelB = $panelOrder[$b['panel']] ?? 99; if ($panelA !== $panelB) { return $panelA <=> $panelB; } return ($a['order'] ?? 0) <=> ($b['order'] ?? 0); }); // Enforce show_cid: users with this flag disabled cannot see or modify phone/mobile. if (! $user->show_cid) { foreach (['phone', 'mobile'] as $cidField) { if (isset($all[$cidField])) { $all[$cidField]['visible'] = false; $all[$cidField]['required'] = false; $all[$cidField]['can_change_visibility'] = false; } } } return $all; } private function defaultFieldDefinitions(User $user) { $phoneRequired = $this->normalizeBool(CompanySettings::get('lead_settings_phone_required', false)); $isAdvanced = $user->can('crm.advanced'); $fields = [ 'first_name' => $this->makeBuiltInField('first_name', [ 'label' => trans('leads::leads.form.first_name'), 'panel' => 'lead_information', 'col_class' => 'col-lg-4', 'required' => true, 'max_length' => 255, 'allowed_types' => ['text'], 'can_change_type' => false, 'can_change_required' => false, 'can_change_visibility' => false, 'can_change_max_length' => false, 'order' => 10, ]), 'last_name' => $this->makeBuiltInField('last_name', [ 'label' => trans('leads::leads.form.last_name'), 'panel' => 'lead_information', 'col_class' => 'col-lg-4', 'max_length' => 255, 'allowed_types' => ['text'], 'can_change_type' => false, 'order' => 20, ]), 'full_name' => $this->makeBuiltInField('full_name', [ 'label' => trans('leads::leads.form.full_name'), 'panel' => 'lead_information', 'col_class' => 'col-lg-4', 'hide_in_form' => true, 'max_length' => 255, 'allowed_types' => ['text'], 'can_change_type' => false, 'can_change_required' => false, 'order' => 30, ]), 'phone' => $this->makeBuiltInField('phone', [ 'label' => trans('leads::leads.form.phone'), 'panel' => $isAdvanced ? 'lead_contact_data' : 'lead_information', 'col_class' => $isAdvanced ? 'col-lg-6 col-md-6 col-sm-6 col-xs-6 clear-left' : 'col-lg-6 col-md-6 col-sm-6 col-xs-6', 'required' => $phoneRequired, 'type' => 'tel', 'allowed_types' => ['text', 'tel'], 'max_length' => 50, 'order' => 40, ]), 'mobile' => $this->makeBuiltInField('mobile', [ 'label' => trans('leads::leads.form.mobile'), 'panel' => $isAdvanced ? 'lead_contact_data' : 'lead_information', 'col_class' => 'col-lg-6 col-md-6 col-sm-6 col-xs-6', 'type' => 'tel', 'allowed_types' => ['text', 'tel'], 'max_length' => 50, 'order' => 50, ]), ]; if (!$isAdvanced) { return $fields; } $fields += [ 'capture_date' => $this->makeBuiltInField('capture_date', [ 'label' => trans('leads::leads.form.capture_date'), 'panel' => 'lead_information', 'col_class' => 'col-lg-4', 'type' => 'datetime', 'allowed_types' => ['datetime', 'date'], 'can_change_max_length' => false, 'order' => 60, ]), 'birth_date' => $this->makeBuiltInField('birth_date', [ 'label' => trans('leads::leads.form.birth_date'), 'panel' => 'lead_information', 'col_class' => 'col-lg-4', 'type' => 'date', 'allowed_types' => ['date', 'datetime'], 'can_change_max_length' => false, 'order' => 70, ]), 'email' => $this->makeBuiltInField('email', [ 'label' => trans('leads::leads.form.email'), 'panel' => 'lead_contact_data', 'type' => 'email', 'allowed_types' => ['text', 'email'], 'max_length' => 255, 'order' => 80, ]), 'secondary_email' => $this->makeBuiltInField('secondary_email', [ 'label' => trans('leads::leads.form.secondary_email'), 'panel' => 'lead_contact_data', 'type' => 'email', 'allowed_types' => ['text', 'email'], 'max_length' => 255, 'order' => 90, ]), 'fax' => $this->makeBuiltInField('fax', [ 'label' => trans('leads::leads.form.fax'), 'panel' => 'lead_contact_data', 'type' => 'tel', 'allowed_types' => ['text', 'tel'], 'max_length' => 50, 'order' => 100, ]), 'annual_revenue' => $this->makeBuiltInField('annual_revenue', [ 'label' => trans('leads::leads.form.annual_revenue'), 'panel' => 'lead_information', 'col_class' => 'col-lg-4', 'type' => 'number', 'allowed_types' => ['number', 'text'], 'can_change_max_length' => false, 'order' => 110, ]), 'website' => $this->makeBuiltInField('website', [ 'label' => trans('leads::leads.form.website'), 'panel' => 'lead_company', 'type' => 'url', 'allowed_types' => ['text', 'url'], 'max_length' => 255, 'order' => 120, ]), 'no_of_employees' => $this->makeBuiltInField('no_of_employees', [ 'label' => trans('leads::leads.form.no_of_employees'), 'panel' => 'lead_company', 'type' => 'number', 'allowed_types' => ['number', 'text'], 'can_change_max_length' => false, 'order' => 130, ]), 'skype' => $this->makeBuiltInField('skype', [ 'label' => trans('leads::leads.form.skype'), 'panel' => 'social', 'col_class' => 'col-lg-4', 'max_length' => 255, 'order' => 140, ]), 'lead_company' => $this->makeBuiltInField('lead_company', [ 'label' => trans('leads::leads.form.lead_company'), 'panel' => 'lead_company', 'max_length' => 255, 'order' => 150, ]), 'job_title' => $this->makeBuiltInField('job_title', [ 'label' => trans('leads::leads.form.job_title'), 'panel' => 'lead_company', 'max_length' => 255, 'order' => 160, ]), 'twitter' => $this->makeBuiltInField('twitter', [ 'label' => trans('leads::leads.form.twitter'), 'panel' => 'social', 'col_class' => 'col-lg-4', 'max_length' => 255, 'order' => 170, ]), 'facebook' => $this->makeBuiltInField('facebook', [ 'label' => trans('leads::leads.form.facebook'), 'panel' => 'social', 'col_class' => 'col-lg-4', 'max_length' => 255, 'order' => 180, ]), 'description' => $this->makeBuiltInField('description', [ 'label' => trans('leads::leads.form.description'), 'panel' => 'description', 'col_class' => 'col-lg-12 col-md-12 col-sm-12', 'type' => 'textarea', 'allowed_types' => ['textarea', 'text'], 'max_length' => 5000, 'order' => 190, ]), 'addr_street' => $this->makeBuiltInField('addr_street', [ 'label' => trans('leads::leads.form.addr_street'), 'panel' => 'address_information', 'col_class' => 'col-lg-4 col-md-4 col-sm-6 col-xs-6 clear-left', 'max_length' => 255, 'order' => 200, ]), 'addr_state' => $this->makeBuiltInField('addr_state', [ 'label' => trans('leads::leads.form.addr_state'), 'panel' => 'address_information', 'col_class' => 'col-lg-4 col-md-4 col-sm-6 col-xs-6', 'max_length' => 255, 'order' => 210, ]), 'addr_city' => $this->makeBuiltInField('addr_city', [ 'label' => trans('leads::leads.form.addr_city'), 'panel' => 'address_information', 'col_class' => 'col-lg-4 col-md-4 col-sm-6 col-xs-6', 'max_length' => 255, 'order' => 220, ]), 'addr_zip' => $this->makeBuiltInField('addr_zip', [ 'label' => trans('leads::leads.form.addr_zip'), 'panel' => 'address_information', 'col_class' => 'col-lg-4 col-md-4 col-sm-6 col-xs-6 clear-left', 'max_length' => 20, 'order' => 230, ]), 'addr_country_id' => $this->makeBuiltInField('addr_country_id', [ 'label' => trans('leads::leads.form.addr_country_id'), 'panel' => 'address_information', 'col_class' => 'col-lg-4 col-md-4 col-sm-6 col-xs-6', 'special' => 'country_select', 'type' => 'manytoone', 'allowed_types' => ['manytoone'], 'can_change_type' => false, 'can_change_max_length' => false, 'order' => 240, ]), 'lead_status_id' => $this->makeBuiltInField('lead_status_id', [ 'label' => trans('leads::leads.form.lead_status_id'), 'panel' => 'lead_information', 'col_class' => 'col-lg-4', 'special' => 'lead_status_select', 'type' => 'manytoone', 'allowed_types' => ['manytoone'], 'can_change_type' => false, 'can_change_max_length' => false, 'order' => 250, ]), 'referral_id' => $this->makeBuiltInField('referral_id', [ 'label' => trans('leads::leads.form.referral_id'), 'panel' => 'lead_information', 'col_class' => 'col-lg-4 col-md-4 col-sm-6 col-xs-6', 'special' => 'referral_many_to_one', 'type' => 'manytoone', 'allowed_types' => ['manytoone'], 'can_change_type' => false, 'can_change_max_length' => false, 'order' => 260, ]), 'language_id' => $this->makeBuiltInField('language_id', [ 'label' => trans('leads::leads.form.language_id'), 'panel' => 'lead_information', 'col_class' => 'col-lg-4', 'special' => 'language_select', 'type' => 'manytoone', 'allowed_types' => ['manytoone'], 'can_change_type' => false, 'can_change_max_length' => false, 'order' => 270, ]), 'lead_source_id' => $this->makeBuiltInField('lead_source_id', [ 'label' => trans('leads::leads.form.lead_source_id'), 'panel' => 'lead_information', 'col_class' => 'col-lg-4', 'special' => 'lead_source_select', 'type' => 'manytoone', 'allowed_types' => ['manytoone'], 'can_change_type' => false, 'can_change_max_length' => false, 'order' => 280, ]), 'lead_industry_id' => $this->makeBuiltInField('lead_industry_id', [ 'label' => trans('leads::leads.form.lead_industry_id'), 'panel' => 'lead_information', 'col_class' => 'col-lg-4', 'special' => 'lead_industry_select', 'type' => 'manytoone', 'allowed_types' => ['manytoone'], 'can_change_type' => false, 'can_change_max_length' => false, 'order' => 290, ]), 'lead_rating_id' => $this->makeBuiltInField('lead_rating_id', [ 'label' => trans('leads::leads.form.lead_rating_id'), 'panel' => 'lead_information', 'col_class' => 'col-lg-4', 'special' => 'lead_rating_select', 'type' => 'manytoone', 'allowed_types' => ['manytoone'], 'can_change_type' => false, 'can_change_max_length' => false, 'order' => 300, ]), 'multiSelects' => $this->makeBuiltInField('multiSelects', [ 'label' => trans('leads::leads.form.multiSelects'), 'panel' => 'lead_information', 'col_class' => 'col-lg-8', 'special' => 'multiselect_choice', 'type' => 'manytomany', 'allowed_types' => ['manytomany'], 'can_change_type' => false, 'can_change_max_length' => false, 'order' => 310, ]), ]; $fields['owned_by'] = $this->makeBuiltInField('owned_by', [ 'label' => trans('core::core.form.assigned_to'), 'panel' => 'lead_information', 'col_class' => 'col-lg-4', 'special' => 'owner_select', 'type' => 'assigned_to', 'allowed_types' => ['assigned_to'], 'can_change_type' => false, 'can_change_max_length' => false, 'order' => 320, ]); return $fields; } private function customFieldDefinitions(array $settings, array $reservedKeys) { $definitions = []; $rows = Arr::get($settings, 'custom', []); $position = 2000; $used = []; if (!is_array($rows)) { return $definitions; } foreach ($rows as $row) { if (!is_array($row)) { continue; } $key = $this->sanitizeCustomKey(Arr::get($row, 'key', '')); if ($key === '' || in_array($key, $reservedKeys, true) || isset($used[$key])) { continue; } $used[$key] = true; $type = $this->normalizeType(Arr::get($row, 'type', 'text'), array_keys(self::TYPE_OPTIONS), 'text'); $label = trim((string) Arr::get($row, 'label', '')); if ($label === '') { $label = Str::title(str_replace('_', ' ', $key)); } $visible = $this->normalizeBool(Arr::get($row, 'visible', true)); $required = $visible ? $this->normalizeBool(Arr::get($row, 'required', false)) : false; $maxLength = $this->normalizeMaxLength(Arr::get($row, 'max_length', null), $type); $options = $this->normalizeOptions(Arr::get($row, 'options', [])); if (!$this->supportsOptionsForType($type)) { $options = []; } $panel = $this->normalizePanel(Arr::get($row, 'panel', self::DEFAULT_CUSTOM_PANEL)); $order = $this->normalizeOrder(Arr::get($row, 'order', null), $position++); $usesColumn = $this->shouldProvisionColumnForType($type) && Schema::hasColumn('leads', $key); $definitions['custom:' . $key] = [ 'id' => 'custom:' . $key, 'key' => $key, 'storage_key' => $key, 'form_name' => $usesColumn ? $key : 'custom_fields[' . $key . ']', 'validation_key' => $usesColumn ? $key : 'custom_fields.' . $key, 'uses_column' => $usesColumn, 'panel' => $panel, 'label' => $label, 'type' => $type, 'allowed_types' => array_keys(self::TYPE_OPTIONS), 'required' => $required, 'visible' => $visible, 'max_length' => $maxLength, 'options' => $options, 'depends_on' => Arr::get($row, 'depends_on'), 'depends_map' => $this->normalizeDependencyMap(Arr::get($row, 'depends_map', [])), 'col_class' => 'col-lg-6 col-md-6 col-sm-6 col-xs-12', 'order' => $order, 'is_custom' => true, 'special' => null, 'can_change_type' => true, 'can_change_required' => true, 'can_change_visibility' => true, 'can_change_max_length' => true, 'can_change_order' => true, ]; } return $definitions; } /** * Pull a simple field value off the form model. The model can be a Lead * entity, another object, or a plain array (create screens are pre-filled * from the request, e.g. the softphone "GoTo Profile" flow sends * ?phone=...). * * @param mixed $model * @return mixed */ private function readModelValue(string $key, $model) { if (is_array($model)) { return $model[$key] ?? null; } if (is_object($model)) { return $model->{$key} ?? null; } return null; } private function makeBuiltInField($key, array $overrides = []) { return array_merge([ 'id' => $key, 'key' => $key, 'storage_key' => $key, 'form_name' => $key, 'validation_key' => $key, 'panel' => 'lead_information', 'label' => $key, 'type' => 'text', 'allowed_types' => ['text'], 'required' => false, 'visible' => true, 'max_length' => 255, 'col_class' => 'col-lg-6 col-md-6 col-sm-6 col-xs-6', 'order' => 0, 'is_custom' => false, 'special' => null, 'can_change_type' => true, 'can_change_required' => true, 'can_change_visibility' => true, 'can_change_max_length' => true, 'can_change_order' => true, 'hide_in_form' => false, 'hide_in_show' => false, 'hide_label' => false, ], $overrides); } private function getStoredSettings(User $user) { if (!$this->hasSettingsTable()) { return []; } $record = LeadUserFieldSetting::query() ->where('company_id', $user->company_id) ->whereNull('user_id') ->first(); $settings = $record ? $record->settings : []; return is_array($settings) ? $settings : []; } private function hasSettingsTable() { if ($this->hasSettingsTable === null) { $this->hasSettingsTable = Schema::hasTable('lead_user_field_settings'); } return $this->hasSettingsTable; } private function supportsCustomFields() { if ($this->hasCustomFieldsColumn === null) { $this->hasCustomFieldsColumn = Schema::hasColumn('leads', 'custom_fields'); } return $this->hasCustomFieldsColumn; } private function shouldProvisionColumnForType(string $type): bool { return !in_array($type, ['manytomany', 'file'], true); } private function applyDependencyAttributes(array $field, array $attr): array { $attr['data-field-key'] = $field['key']; if (!empty($field['depends_on'])) { $attr['data-depends-on'] = $field['depends_on']; $attr['data-depends-map'] = json_encode($field['depends_map'] ?? []); } return $attr; } private function addSimpleField(Form $form, array $field, $value = null) { $formType = $this->mapFormType($field['type']); $options = [ 'label' => $field['label'], ]; $attr = []; if ($field['required']) { $attr['required'] = 'required'; } if ($field['max_length'] !== null && $this->supportsMaxLength($field['type'])) { $attr['maxlength'] = $field['max_length']; } if ($formType === 'textarea') { $attr['rows'] = 3; } if ($field['type'] === 'assigned_to') { $options['choices'] = FormHelper::assignedToChoises(); $options['empty_value'] = trans('core::core.empty_select'); $attr['class'] = trim('select2 pmd-select2 form-control ' . ($attr['class'] ?? '')); $attr = $this->applyDependencyAttributes($field, $attr); } if ($field['type'] === 'manytomany') { $formType = 'choice'; $options['choices'] = $this->formatSelectChoices($field['options'] ?? []); $options['attr'] = $this->applyDependencyAttributes($field, ['class' => 'select2 pmd-select2 form-control']); $options['expanded'] = false; $options['multiple'] = true; if ($value !== null) { $options['selected'] = is_array($value) ? $value : [$value]; } $form->add($field['form_name'], $formType, $options); return; } if ($formType === 'select') { if (!isset($options['choices'])) { $options['choices'] = $this->formatSelectChoices($field['options'] ?? []); } if (!isset($options['empty_value'])) { $options['empty_value'] = trans('core::core.empty_select'); } $attr['class'] = trim('select2 pmd-select2 form-control ' . ($attr['class'] ?? '')); $attr = $this->applyDependencyAttributes($field, $attr); } if (!empty($attr)) { $options['attr'] = $attr; } if ($formType === 'checkbox' && $value !== null) { $options['checked'] = (bool) $value; } if ($value !== null && $formType !== 'file') { $options['value'] = $value; } $form->add($field['form_name'], $formType, $options); } private function addSpecialField(Form $form, array $field, User $user, $model = null) { switch ($field['special']) { case 'country_select': $form->add($field['form_name'], 'select', [ 'choices' => Country::whereIsActive(1)->pluck('name', 'id')->toArray(), 'attr' => $this->applyDependencyAttributes($field, ['class' => 'select2 pmd-select2 form-control']), 'label' => $field['label'], 'empty_value' => trans('core::core.empty_select'), ]); break; case 'lead_status_select': $form->add($field['form_name'], 'select', [ 'choices' => LeadStatus::where('company_id', $user->company_id)->pluck('name', 'id')->toArray(), 'attr' => $this->applyDependencyAttributes($field, ['class' => 'select2 pmd-select2 form-control']), 'label' => $field['label'], 'empty_value' => trans('core::core.empty_select'), ]); break; case 'lead_source_select': $form->add($field['form_name'], 'select', [ 'choices' => LeadSource::where('company_id', $user->company_id)->pluck('name', 'id')->toArray(), 'attr' => $this->applyDependencyAttributes($field, ['class' => 'select2 pmd-select2 form-control']), 'label' => $field['label'], 'empty_value' => trans('core::core.empty_select'), ]); break; case 'lead_industry_select': $form->add($field['form_name'], 'select', [ 'choices' => LeadIndustry::where('company_id', $user->company_id)->pluck('name', 'id')->toArray(), 'attr' => $this->applyDependencyAttributes($field, ['class' => 'select2 pmd-select2 form-control']), 'label' => $field['label'], 'empty_value' => trans('core::core.empty_select'), ]); break; case 'lead_rating_select': $form->add($field['form_name'], 'select', [ 'choices' => LeadRating::where('company_id', $user->company_id)->pluck('name', 'id')->toArray(), 'attr' => $this->applyDependencyAttributes($field, ['class' => 'select2 pmd-select2 form-control']), 'label' => $field['label'], 'empty_value' => trans('core::core.empty_select'), ]); break; case 'language_select': $form->add($field['form_name'], 'select', [ 'choices' => CustomerLanguage::all()->pluck('name', 'id')->toArray(), 'attr' => $this->applyDependencyAttributes($field, ['class' => 'select2 pmd-select2 form-control']), 'label' => $field['label'], 'empty_value' => trans('core::core.empty_select'), ]); break; case 'referral_many_to_one': $form->add($field['form_name'], 'manyToOne', [ 'search_route' => route('leads.leads.index', ['mode' => 'modal']), 'relation' => 'referral', 'relation_field' => 'full_name', 'model' => $model, 'modal_title' => 'leads::leads.choose', 'attr' => ['class' => 'form-control manyToOne'], 'label' => $field['label'], 'empty_value' => trans('core::core.empty_select'), ]); break; case 'owner_select': if ($user->hasPermissionTo('leads.assign')) { $form->add($field['form_name'], 'select', [ 'choices' => FormHelper::assignedToChoises(), 'attr' => $this->applyDependencyAttributes($field, ['class' => 'select2 pmd-select2 form-control']), 'label' => $field['label'], 'empty_value' => trans('core::core.empty_select'), 'selected' => FormHelper::assignSelectedFromModel($model), ]); } elseif ($model instanceof Lead && $model->owner !== null) { $form->add($field['form_name'], 'static', [ 'label' => $field['label'], 'value' => $model->owner->name, 'attr' => ['class' => 'form-control-static'], ]); } break; case 'multiselect_choice': $selected = []; if ($model && is_object($model) && method_exists($model, 'multiSelects')) { $selected = $model->multiSelects()->pluck('leads_multi_select.id')->toArray(); } $form->add($field['form_name'], 'choice', [ 'choices' => LeadMultiSelect::where('company_id', $user->company_id)->pluck('name', 'id')->toArray(), 'attr' => $this->applyDependencyAttributes($field, ['class' => 'select2 pmd-select2 form-control']), 'label' => $field['label'], 'expanded' => false, 'multiple' => true, 'selected' => $selected, ]); break; } } private function specialShowOptions(array $field) { switch ($field['special']) { case 'country_select': return [ 'type' => 'manyToOne', 'relation' => 'addrCountry', 'column' => 'name', ]; case 'lead_status_select': return [ 'type' => 'manyToOne', 'relation' => 'leadStatus', 'column' => 'name', ]; case 'lead_source_select': return [ 'type' => 'manyToOne', 'relation' => 'leadSource', 'column' => 'name', ]; case 'lead_industry_select': return [ 'type' => 'manyToOne', 'relation' => 'leadIndustry', 'column' => 'name', ]; case 'lead_rating_select': return [ 'type' => 'manyToOne', 'relation' => 'leadRating', 'column' => 'name', ]; case 'language_select': return [ 'type' => 'manyToOne', 'relation' => 'language', 'column' => 'name', ]; case 'referral_many_to_one': return [ 'type' => 'manyToOne', 'relation' => 'referral', 'column' => 'full_name', 'dont_translate' => true, ]; case 'owner_select': return [ 'type' => 'assigned_to', ]; case 'multiselect_choice': return [ 'type' => 'manyToMany', 'relation' => 'multiSelects', 'column' => 'name', ]; } return ['type' => 'text']; } private function specialValidationRules(array $field) { $nullableOrRequired = $field['required'] ? ['required'] : ['nullable']; switch ($field['special']) { case 'country_select': return [ $field['validation_key'] => array_merge($nullableOrRequired, ['integer', 'exists:bap_country,id']), ]; case 'lead_status_select': return [ $field['validation_key'] => array_merge($nullableOrRequired, ['integer', 'exists:leads_dict_status,id']), ]; case 'lead_source_select': return [ $field['validation_key'] => array_merge($nullableOrRequired, ['integer', 'exists:leads_dict_source,id']), ]; case 'lead_industry_select': return [ $field['validation_key'] => array_merge($nullableOrRequired, ['integer', 'exists:leads_dict_industry,id']), ]; case 'lead_rating_select': return [ $field['validation_key'] => array_merge($nullableOrRequired, ['integer', 'exists:leads_dict_rating,id']), ]; case 'language_select': return [ $field['validation_key'] => array_merge($nullableOrRequired, ['integer', 'exists:customer_dict_language,id']), ]; case 'referral_many_to_one': return [ $field['validation_key'] => array_merge($nullableOrRequired, ['integer', 'exists:leads,id']), ]; case 'owner_select': return [ $field['validation_key'] => array_merge($nullableOrRequired, ['string', 'max:255']), ]; case 'multiselect_choice': // The per item rules are validated server-side only. // JSValidation maps the wildcard rule ("multiSelects.*") onto the //