# Custom Fields System — Technical Reference

## 1. Overview

The Custom Fields system allows each company to extend the built-in field definitions for
four CRM modules — **Leads, Contacts, Call Logs, and Tickets** — without touching code.
Administrators can rename built-in fields, hide them, reorder them, mark them required, and
add unlimited custom fields. Every change is reflected instantly in the Create, Edit, Show,
and DataTable (list) views.

---

## 2. Database Storage

### 2.1 Settings tables — one row per company

Each module stores its entire field configuration as a single JSON blob keyed by `company_id`:

| Table | Module | Company scope |
|---|---|---|
| `lead_user_field_settings` | Leads | `company_id` (nullable `user_id` for future per-user overrides) |
| `contact_user_field_settings` | Contacts | `company_id` |
| `call_log_field_settings` | Call Logs | `company_id` |
| `ticket_field_settings` | Tickets | `company_id` |

**`settings` JSON structure** (identical across all modules):

```json
{
  "built_in": {
    "field_key": {
      "label":       "Renamed Label",
      "visible":     true,
      "required":    false,
      "order":       10,
      "depends_on":  "parent_field_key",
      "depends_map": "ParentValue => Child1, Child2\nOtherValue => Child3"
    }
  },
  "custom": [
    {
      "key":        "my_custom_field",
      "label":      "My Custom Field",
      "type":       "select",
      "options":    { "A": "A", "B": "B" },
      "visible":    true,
      "required":   true,
      "order":      50,
      "panel":      "details",
      "max_length": null,
      "depends_on": "some_select_field",
      "depends_map": "A => X\nB => Y"
    }
  ],
  "api_index_fields": ["field_key_1", "field_key_2"]
}
```

### 2.2 Custom field value storage — per module record

Custom field *values* are stored in the module's own table, not in the settings table:

| Module | Table | Column | Format |
|---|---|---|---|
| Leads | `leads` | `custom_fields` (JSON) *or* a real VARCHAR column (auto-provisioned) | `{"my_field": "value"}` |
| Contacts | `contacts` | No dedicated custom column — custom values pass through the Kris form and are saved via normal fillable handling | (form-level) |
| Call Logs | `call_logs` | `custom_data` (JSON, nullable) | `{"my_field": "value"}` |
| Tickets | `tickets` | `custom_data` (JSON, nullable) | `{"my_field": "value"}` |

> **Lead specifics:** The Lead service can auto-provision a real `VARCHAR` column on `leads`
> the first time a custom field is saved (`provisionCustomFieldColumn`). This improves query
> performance for high-volume installations. The JSON fallback (`custom_fields`) is used when
> the column does not yet exist.

---

## 3. Field Settings Services

Each module has a dedicated **FieldSettingsService** that is the single source of truth for
field configuration. All four services share the same public API:

```
Modules\{Module}\Service\{Module}FieldSettingsService
```

### 3.1 Shared public API

| Method | Returns | Used by |
|---|---|---|
| `getSettingsEditorData(user)` / `getEditorData(user)` | Grouped fields + type options + panel options — drives the CRM Settings UI | Settings controller |
| `getEffectiveFields(user)` | All fields (built-in + custom) merged with saved overrides, sorted by panel + order | Internal use |
| `buildFormFields(form, user, model)` | Adds fields to a Kris `Form` object | Form classes (Create/Edit) |
| `getShowFields(user)` | Grouped array compatible with `ModuleCrudController::$showFields` | Main CRUD controllers |
| `getValidationRules(user)` | Laravel validation rule array | Request classes |
| `saveSettings(user, input)` | `bool` — persists `settings` JSON to DB | Settings controller `save()` |
| `resetSettings(user)` | `bool` — deletes the settings row (defaults take effect) | Settings controller `reset()` |
| `getApiIndexFieldKeys(user)` | Up to 4 field keys flagged for API index | API endpoints |
| `getVisibleFieldKeys(user)` | All currently visible field keys | DataTable `getColumns()` |
| `getFieldOrderMap(user)` | `key => sort_order` map | DataTable column sorting |
| `getFieldLabelMap(user)` | `key => label` map | DataTable column titles |
| `getVisibleCustomFieldsForDatatable(user)` | Visible custom fields that come from `custom_data` JSON | DataTable `query()` + `getColumns()` |
| `getSelectableFieldOptions(user)` | Visible `select`-type fields → used to populate "Depends On" dropdowns | Settings UI |

### 3.2 Field definition anatomy

Every field (built-in or custom) is represented as a PHP array:

```php
[
    'key'                   => 'phone_number',
    'is_custom'             => false,
    'panel'                 => 'call_information',
    'type'                  => 'tel',          // text|textarea|number|email|tel|url|date|datetime|select|checkbox|assigned_to|manytoone|manytomany
    'form_type'             => null,           // overrides the Kris form builder type
    'label'                 => 'Phone Number',
    'visible'               => true,
    'required'              => false,
    'order'                 => 10,
    'col_class'             => 'col-lg-4',
    'max_length'            => null,
    'options'               => [],             // for select types: ['key' => 'label']
    'depends_on'            => null,
    'depends_map'           => '',             // string (CL/TK) or array (Lead/Contact)
    'can_change_label'      => true,
    'can_change_visibility' => true,
    'can_change_required'   => true,
    'can_change_order'      => true,
    'can_change_type'       => false,
    // editor-layer additions (added by getEditorData):
    'is_type_locked'        => true,
    'is_required_locked'    => false,
    'is_visibility_locked'  => false,
    'is_max_length_locked'  => true,
    'is_order_locked'       => false,
    'type_options'          => [ 'text' => 'Text', ... ],
]
```

---

## 4. Shared CRM Settings UI

### 4.1 File locations

```
Modules/Platform/Core/Resources/views/crm-settings/
├── crm.blade.php                          ← Shared page: layout + tab nav
└── partials/
    └── _module_field_editor.blade.php     ← Shared form + JavaScript

config/crm_field_settings.php             ← Tab registry (route → label)
```

### 4.2 Tab registration

Tabs are auto-built from `config/crm_field_settings.php`. Any module that registers a route
and adds an entry here will automatically appear as a tab — no Blade changes needed:

```php
return [
    'tabs' => [
        ['route' => 'settings.crm.lead_fields',     'label' => 'Lead Fields'],
        ['route' => 'settings.crm.contact_fields',  'label' => 'Contact Fields'],
        ['route' => 'settings.crm.calllogs_fields', 'label' => 'Call Log Fields'],
        ['route' => 'settings.crm.ticket_fields',   'label' => 'Ticket Fields'],
        // Add new module here — zero Blade changes needed
    ],
];
```

### 4.3 Module configuration (`$mfeConfig`)

Each module's field-settings controller returns `view('core::crm-settings.crm')` with an
`$mfeConfig` array that parameterises the shared partial:

```php
'mfeConfig' => [
    'mfe_prefix'           => 'cl',           // unique CSS/JS class prefix per module
    'mfe_form_id'          => 'crm_calllog_field_settings_form',
    'mfe_action'           => route('settings.crm.calllogs_fields.save'),
    'mfe_reset_form_id'    => 'crm_calllog_reset_form',
    'mfe_reset_action'     => route('settings.crm.calllogs_fields.reset'),
    'mfe_save_label'       => 'Save Call Log Settings',
    'mfe_reset_btn_id'     => 'js-cl-reset-btn',
    'mfe_reset_confirm'    => 'Reset all call log field customizations...',
    'mfe_description'      => 'Configure which fields appear on the ...',
    'mfe_dependency_types' => ['select'],
    'mfe_option_types'     => ['select'],
]
```

The `mfe_prefix` keeps all DOM IDs and JavaScript class selectors unique even when multiple
modules share the same page. The JS IIFE in the partial uses `var prefix = '{{ $mfe_prefix }}'`
and a `cls(name)` helper to build selectors like `.js-cl-type`, `.js-cl-key`, etc.

### 4.4 JavaScript features (in `_module_field_editor.blade.php`)

- **Type-aware Choices column**: enables/disables the options textarea based on the selected
  field type (`optionTypes` array from `$mfe_option_types`)
- **Dependency Builder**: visual rule editor — "IF ParentValue ALLOW Child1, Child2" — syncs
  to a hidden `dep-map-raw` textarea on submit
- **API Index counter**: enforces a maximum of 4 API-indexed fields; disables extra checkboxes
- **Auto-label fill**: auto-populates the Label input from the Field Name key on blur
- **Submit guards**: force-enables all disabled inputs before form submission; validates that
  option-type fields have at least one choice

---

## 5. Field Lifecycle — From Settings to UI

```
┌──────────────────────────────────────────────────────────────────┐
│                     CRM SETTINGS PAGE                            │
│  (core::crm-settings.crm + _module_field_editor partial)         │
│                                                                  │
│  Admin edits: visibility · order · label · required ·            │
│              choices · depends_on · depends_map ·                │
│              max_length · api_index                              │
│                      ↓ POST (save)                               │
│  {Module}FieldSettingsController::save()                         │
│      → {Module}FieldSettingsService::saveSettings()              │
│          → Normalise & validate all input                        │
│          → Write single JSON row to {module}_field_settings      │
│             table (keyed by company_id)                          │
└──────────────────────────────────────────────────────────────────┘
                              ↓
            ┌─────────────────────────────────┐
            │    {module}_field_settings DB   │
            │  company_id | settings (JSON)   │
            └─────────────────────────────────┘
                              ↓  read on every request
            ┌─────────────────────────────────────────────────────┐
            │     {Module}FieldSettingsService::getEffectiveFields │
            │                                                     │
            │  1. Load defaultFieldDefinitions() (hardcoded)      │
            │  2. Load saved overrides from DB                    │
            │  3. Merge: apply label/visibility/order/required     │
            │  4. Load custom field definitions from settings.     │
            │     custom[]                                        │
            │  5. Sort by panel_order × 1000 + field_order        │
            └─────────────────────────────────────────────────────┘
                              ↓
   ┌──────────────┬───────────────────────┬────────────────────────┐
   │              │                       │                        │
   ↓              ↓                       ↓                        ↓
CREATE/EDIT    SHOW VIEW             DATATABLE               VALIDATION
   │              │                   (List)                       │
   │    buildFormFields()        getColumns()             getValidationRules()
   │    → Kris FormBuilder       → filter by visible      → ['field' => ['required']]
   │      adds each visible      → sort by order          used in
   │      field to the form      → apply labels           CreateRequest /
   │    Custom fields use        → add custom__ cols      UpdateRequest
   │    custom_data[key]         via JSON_EXTRACT          ::rules()
   │    input name               query()
   │              │                   │
   ↓              ↓                   ↓
 POST            Render            AJAX JSON
 store() /      Html::renderField  to DataTables.js
 update()       for each visible   (client renders
                field in panel     columns + rows)
   │
   ↓
 Custom field values saved:
 · Leads   → custom_fields JSON column (or real VARCHAR col)
 · CallLogs→ custom_data JSON column
 · Tickets → custom_data JSON column
```

---

## 6. Dynamic Form Rendering (Create / Edit)

### 6.1 Form class

Each module has a Kris `Form` class that delegates entirely to the service:

```php
// Modules/Tickets/Http/Forms/TicketForm.php
class TicketForm extends Form
{
    public function buildForm(): void
    {
        $service = app(TicketFieldSettingsService::class);
        $service->buildFormFields($this, auth()->user(), $this->model);
    }
}
```

### 6.2 What `buildFormFields()` does

```
getEffectiveFields(user)
    for each visible field:
        if is_custom:
            addCustomFieldToForm()   → adds input named custom_data[field_key]
        else:
            addBuiltInFieldToForm()  → adds the appropriate Kris field type
                switch form_type:
                    'select'                → add(key, 'select', [choices => ...])
                    'textarea'              → add(key, 'textarea')
                    'dateType'              → add(key, 'dateType')
                    'dateTimeType'          → add(key, 'dateTimeType')
                    'manytoone'             → add(key, 'manyToOne', [relation => ...])
                    'assigned_to'           → add(key, 'select', [choices => users])
                    'ticket_priority_select'→ add(key, 'select', [choices => DB])
                    default                 → add(key, 'text')
    add('submit', 'submit')
```

---

## 7. Dynamic Show View Rendering

The main CRUD controller overrides `resolveShowFieldsForCurrentUser()` via
`overrideShowFieldsForCurrentUser()`:

```php
// Modules/Tickets/Http/Controllers/TicketsController.php
protected function overrideShowFieldsForCurrentUser(): array
{
    return app(TicketFieldSettingsService::class)
        ->getShowFields(Auth::user());
}
```

`getShowFields()` returns panels → fields in a format compatible with
`ModuleCrudController::$showFields`. The platform's `Html::renderField()` macro then renders
each field according to its `type` key (`manyToOne`, `assigned_to`, `text`, `date`, `custom`,
etc.).

Custom fields have `type: 'custom'` and store their value via a `custom_field_key` that the
view uses to look up `$entity->custom_data[$key]` (or `$entity->custom_fields[$key]` for
Leads).

---

## 8. Dynamic DataTable Rendering

### 8.1 Column filtering and ordering

```php
// inside {Module}Datatable::getColumns()
$visibleKeys = $service->getVisibleFieldKeys($user);   // ['name', 'status', ...]
$orderMap    = $service->getFieldOrderMap($user);       // ['name' => 10010, ...]
$labelMap    = $service->getFieldLabelMap($user);       // ['name' => 'Subject', ...]

foreach (self::availableColumns() as $colKey => $colDef) {
    $fieldKey = self::$columnToFieldMap[$colKey] ?? $colKey;
    if (in_array($fieldKey, $visibleKeys)) {
        $colDef['title'] = $labelMap[$fieldKey] ?? $colDef['title'];
        $filtered[$colKey] = $colDef;
    }
}
uksort($filtered, fn($a,$b) => $orderMap[map($a)] <=> $orderMap[map($b)]);
```

### 8.2 Custom field columns (JSON extraction)

For each visible custom field, the DataTable:

1. **`query()`** adds a `SELECT` expression:
   ```sql
   JSON_UNQUOTE(JSON_EXTRACT(call_logs.custom_data, '$.my_field')) AS `custom__my_field`
   ```

2. **`getColumns()`** adds a column definition with `data: 'custom__my_field'`.

3. **`dataTable()`** adds a `filterColumn()` callback so the field is searchable:
   ```php
   $dataTable->filterColumn('custom__my_field', function ($q, $keyword) {
       $q->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(...)) LIKE ?", ["%$keyword%"]);
   });
   ```

---

## 9. Validation

Validation rules are generated dynamically at request time:

```php
// Modules/Tickets/Http/Requests/TicketsRequest.php
public function rules(): array
{
    // Base: ['name' => 'required']
    // Extended by service for dynamic fields:
    return array_merge(
        ['name' => 'required'],
        app(TicketFieldSettingsService::class)
            ->getValidationRules(Auth::user())
    );
}
```

`getValidationRules()` iterates `getEffectiveFields()` and emits rules only for **visible,
non-custom** built-in fields:

```
visible=true  → ['required'] or ['nullable']
type=email    → + ['email']
type=date     → + ['date']
type=number   → + ['numeric']
max_length>0  → + ['string', 'max:N']
```

Custom field values arrive nested under `custom_data[key]` and are validated as
`'custom_data' => ['nullable', 'array']`.

---

## 10. Save / Reset Flow

```
POST /settings/crm-settings/{module}-fields
    ↓
{Module}FieldSettingsController::save(Request $request)
    ↓
{Module}FieldSettingsService::saveSettings($user, $request->all())
    ┌──────────────────────────────────────────────────────────┐
    │ 1. Parse built_in[] overrides                           │
    │    • Enforce visibility/required/label/order constraints │
    │    • Normalise depends_on / depends_map strings          │
    │ 2. Parse custom[] rows                                  │
    │    • sanitizeKey() — strip invalid characters            │
    │    • Deduplicate keys; skip reserved (built-in) names    │
    │    • normOptions() — textarea → ['key'=>'key'] map       │
    │    • normPanel() — validate against PANELS constant      │
    │    • Sort custom[] by order                              │
    │ 3. normApiIndexFields() — limit to 4, filter visible     │
    │ 4. Upsert single row in {module}_field_settings          │
    │    (updateOrCreate by company_id)                        │
    └──────────────────────────────────────────────────────────┘
    ↓ redirect back with flash message

POST /settings/crm-settings/{module}-fields/reset
    ↓
{Module}FieldSettingsService::resetSettings($user)
    → DELETE FROM {module}_field_settings WHERE company_id = ?
    → All defaults take effect immediately (no settings row = defaults)
```

---

## 11. Adding a New CRM Module

Adding a new module (e.g. Accounts) requires only four steps:

1. **Create `AccountFieldSettingsService`** — extend the same pattern, define `PANELS`,
   `TYPE_OPTIONS`, `PANEL_ORDER`, `defaultFieldDefinitions()`.

2. **Create `AccountFieldSettingsController`** — three actions: `index`, `save`, `reset`.
   ```php
   return view('core::crm-settings.crm', $data + ['mfeConfig' => [
       'mfe_prefix'  => 'ac',
       'mfe_action'  => route('settings.crm.account_fields.save'),
       // ...
   ]]);
   ```

3. **Register routes** under `settings.crm.account_fields.*`.

4. **Add one line to `config/crm_field_settings.php`**:
   ```php
   ['route' => 'settings.crm.account_fields', 'label' => 'Account Fields'],
   ```

No Blade, no JavaScript, no DataTable changes required.

---

## 12. File Map

```
config/
  crm_field_settings.php              Tab registry

Modules/Platform/Core/Resources/views/crm-settings/
  crm.blade.php                       Shared CRM settings page (tab nav + layout)
  partials/
    _module_field_editor.blade.php    Shared field editor form + inline JavaScript

Modules/Api/Http/Controllers/Base/
  CrmFieldApiTrait.php                Shared API helpers: field metadata, options,
                                      dependency rules, value resolution

Modules/Api/Http/Controllers/
  LeadsApiController.php              Lead API  (index, fields, get, store, update, destroy)
  ContactsApiController.php           Contact API
  CallLogsApiController.php           Call Log API (index, fields, get, store, update, destroy)
  TicketsApiController.php            Ticket API  (index, fields, get, store, update, destroy)

Modules/Api/Http/Requests/
  LeadApiRequest.php / UpdateLeadApiRequest.php
  CallLogApiRequest.php / UpdateCallLogApiRequest.php
  TicketApiRequest.php  / UpdateTicketApiRequest.php

Modules/{Module}/
  Service/
    {Module}FieldSettingsService.php  All field logic (single source of truth)
  Http/Controllers/Settings/
    {Module}FieldSettingsController.php   index / save / reset
  Http/Forms/
    {Module}Form.php                  Delegates buildForm() to service
  Http/Requests/
    {Module}Request.php               Uses service->getValidationRules()
  Http/Controllers/
    {Module}Controller.php            overrideShowFieldsForCurrentUser()
  Datatables/
    {Module}Datatable.php             getColumns() + query() use service
  Database/Migrations/
    *_create_{module}_field_settings.php   Settings table + custom_data column
  Entities/
    {Module}FieldSetting.php          Eloquent model for settings row
```

---

## 13. REST API Integration

### 13.1 Available endpoints

All four modules expose a consistent REST API under `/api/{module}/`:

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/leads/` | Paginated list with API index fields |
| GET | `/api/leads/fields` | Full field schema from CRM settings |
| GET | `/api/leads/get/{id}` | Record + field metadata + current values |
| POST | `/api/leads/create` | Create with custom field support |
| POST | `/api/leads/update/{id}` | Update with custom field support |
| DELETE | `/api/leads/delete/{id}` | Delete |
| GET | `/api/contacts/` | — same pattern — |
| GET | `/api/calllogs/` | — same pattern — |
| GET | `/api/tickets/` | — same pattern — |

### 13.2 `GET /api/{module}/fields` — field schema

Returns the complete field configuration driven by CRM Settings. Response:

```json
{
  "status": true,
  "data": {
    "fields": [
      {
        "key":        "subject",
        "label":      "Call Subject",
        "type":       "text",
        "input_type": "text",
        "required":   true,
        "nullable":   false,
        "visible":    true,
        "is_custom":  false,
        "panel":      "log_details",
        "order":      1,
        "max_length": null,
        "validation": ["required"],
        "options":    null
      },
      {
        "key":       "call_rating",
        "label":     "Call Rating",
        "type":      "select",
        "is_custom": true,
        "options": [
          { "id": "Good",    "name": "Good"    },
          { "id": "Bad",     "name": "Bad"     },
          { "id": "Neutral", "name": "Neutral" }
        ]
      }
    ],
    "field_map": { "subject": { ... }, "call_rating": { ... } },
    "rules": []
  }
}
```

Fields are **automatically hidden** when their `visible` flag is set to `false` in CRM Settings.
Labels are **automatically overridden** when the admin renames a field.
Custom fields appear **automatically** once added in CRM Settings.

### 13.3 `GET /api/{module}/get/{id}` — record with metadata

Returns the record data PLUS the full field schema and the current field values:

```json
{
  "data": {
    "call_log": { "id": 5, "subject": "...", "custom_data": { "call_rating": "Good" } },
    "fields": [
      { "key": "subject", "value": "Support call", "display_value": "Support call", ... },
      { "key": "call_rating", "value": "Good", "display_value": "Good", "is_custom": true, ... }
    ],
    "field_map": { ... },
    "rules": [ ... ]
  }
}
```

### 13.4 `GET /api/{module}/` — list with API index fields

Each record in the paginated list includes only the fields that the admin flagged as "API Index" in CRM Settings (up to 4):

```json
{
  "data": [{
    "current_page": 1,
    "data": [
      {
        "id": 5,
        "index_fields": [
          { "key": "subject",     "label": "Call Subject", "value": "Support call" },
          { "key": "call_rating", "label": "Call Rating",  "value": "Good" }
        ]
      }
    ],
    "total": 42
  }]
}
```

If no fields are flagged as API index, `index_fields` will be an empty array.

### 13.5 Create / Update with custom fields

Send custom field values nested under `custom_data`:

```json
POST /api/calllogs/create
{
  "phone_number": "0100000000",
  "direction":    "inbound",
  "call_date":    "2026-07-16 10:00:00",
  "subject":      "Support call",
  "custom_data": {
    "call_rating": "Good"
  }
}
```

Custom data is stored in the `custom_data` JSON column and returned in subsequent `get` and `list` responses.

### 13.6 Shared `CrmFieldApiTrait`

`Modules/Api/Http/Controllers/Base/CrmFieldApiTrait.php` provides all the helper
methods used by the API controllers:

| Method | Purpose |
|--------|---------|
| `buildFieldEntry(entity, field, rules, optionsMap)` | Full metadata + value + display value for `get/` |
| `buildFieldSchema(field, rules, optionsMap)` | Schema only (no value) for `fields/` |
| `buildApiDependencyRules(fields, optionsMap)` | Dependency rule list for data-driven clients |
| `normalizeApiValidationRules($rules)` | Normalize string/array rule to `['required',...]` |
| `normalizeApiCustomSelectOptions($options)` | Convert textarea/array options to `[{id,name}]` |
| `normalizeApiChoiceMap($choices)` | Convert key→value map to `[{id,name}]` |

Each controller implements two module-specific methods:
- `resolveApiFieldValue(entity, field)` — get the raw stored value
- `resolveApiDisplayValue(entity, field, value, optionsMap)` — get the human-readable label

