# FieldEngine

> Provides custom fields infrastructure. Models use `HasCustomFields` trait.
> Searchable fields are real columns in the model migration (see `_core/database.md`).

---

## Migrations

### `custom_fields`
```php
Schema::create('custom_fields', function (Blueprint $table) {
    $table->id();
    $table->string('slug')->unique();
    $table->string('entity_type');          // 'lead', 'deal', 'contact'
    $table->string('label');
    $table->string('type');                 // text|number|date|select|multiselect|boolean
    $table->json('options')->nullable();    // for select/multiselect
    $table->string('group_name')->nullable();           // section header in DynamicFields (e.g. "Education Background")
    $table->boolean('is_required')->default(false);
    $table->boolean('is_system')->default(false);
    $table->boolean('is_searchable')->default(false);  // doc only — real column exists in model table
    $table->string('feature_pack_slug')->nullable();
    $table->string('seeder_version')->nullable();
    $table->integer('sort_order')->default(0);
    $table->timestamps();
});
```

### `custom_field_values`
```php
Schema::create('custom_field_values', function (Blueprint $table) {
    $table->id();
    $table->foreignId('custom_field_id')->constrained('custom_fields')->cascadeOnDelete();
    $table->morphs('entity');               // entity_type + entity_id
    $table->text('value')->nullable();
    $table->timestamps();
    $table->unique(['custom_field_id', 'entity_type', 'entity_id'], 'cfv_unique');
});
```

---

## Trait: `HasCustomFields`

```php
// CoreApp/app/Traits/HasCustomFields.php
namespace CoreApp\Traits;

use CoreApp\Models\FieldEngine\CustomField;
use CoreApp\Models\FieldEngine\CustomFieldValue;

trait HasCustomFields
{
    public function customFieldValues(): MorphMany
    {
        return $this->morphMany(CustomFieldValue::class, 'entity');
    }

    public function getCustomField(string $slug): mixed
    {
        if (array_key_exists($slug, $this->attributes)) {
            return $this->attributes[$slug];
        }
        return $this->customFieldValues->firstWhere('customField.slug', $slug)?->value;
    }

    /** Write single value — atomic dual-write wrapped in transaction */
    public function setCustomField(string $slug, mixed $value): void
    {
        $field = CustomField::where('slug', $slug)->first();
        if (!$field) return;

        DB::transaction(function () use ($field, $slug, $value) {
            $this->customFieldValues()->updateOrCreate(
                ['custom_field_id' => $field->id],
                ['value' => $value]
            );
            if ($field->is_searchable && in_array($slug, $this->getFillable())) {
                $this->updateQuietly([$slug => $value]); // updateQuietly avoids re-triggering workflow events
            }
        });
    }

    /**
     * Batch write — 1 query to load schema, then transaction for all writes.
     * Use this on form save instead of calling setCustomField() per field (avoids N+1).
     *
     * @param array<string, mixed> $data  slug => value pairs
     */
    public function setCustomFields(array $data): void
    {
        if (empty($data)) return;

        $fields = CustomField::whereIn('slug', array_keys($data))->get()->keyBy('slug');

        DB::transaction(function () use ($data, $fields) {
            foreach ($data as $slug => $value) {
                $field = $fields->get($slug);
                if (!$field) continue;

                $this->customFieldValues()->updateOrCreate(
                    ['custom_field_id' => $field->id],
                    ['value' => $value]
                );
                if ($field->is_searchable && in_array($slug, $this->getFillable())) {
                    $this->updateQuietly([$slug => $value]);
                }
            }
        });
    }
}
```

---

## Service: `CustomFieldResolver`

```php
// CoreApp/app/Services/FieldEngine/CustomFieldResolver.php
class CustomFieldResolver
{
    public function forEntity(string $entityType): Collection
    {
        // Cache schema per entity type — custom fields change only on pack re-seed, not per-request
        return cache()->remember("custom_fields:{$entityType}", 300, fn () =>
            CustomField::where('entity_type', $entityType)
                ->orderBy('sort_order')
                ->get()
        );
    }

    public function groupedForForm(string $entityType): array
    {
        return $this->forEntity($entityType)->map(fn ($f) => [
            'slug'       => $f->slug,
            'label'      => $f->label,
            'type'       => $f->type,
            'group_name' => $f->group_name,  // required for DynamicFields section grouping
            'options'    => $f->options,
            'required'   => $f->is_required,
            'searchable' => $f->is_searchable,
        ])->all();
    }

    /** Call after FeaturePackSeeder runs to bust cached schemas */
    public function bustCache(string $entityType): void
    {
        cache()->forget("custom_fields:{$entityType}");
    }
}
```

Bind in `CoreAppServiceProvider::register()`:
```php
$this->app->singleton(CustomFieldResolver::class);
```

---

## React Component: `DynamicFields`

File: `resources/js/components/crm/dynamic-fields.tsx`

```tsx
interface Field {
    slug: string;
    label: string;
    type: 'text' | 'number' | 'date' | 'select' | 'multiselect' | 'boolean';
    options?: string[];
    required?: boolean;
}

interface Props {
    fields: Field[];
    values: Record<string, unknown>;
    onChange: (slug: string, value: unknown) => void;
    errors?: Record<string, string>;
}

export default function DynamicFields({ fields, values, onChange, errors = {} }: Props) {
    return (
        <div className="space-y-4">
            {fields.map((field) => (
                <div key={field.slug}>
                    <label className="block text-sm font-medium text-gray-700">
                        {field.label}{field.required && <span className="text-red-500 ml-1">*</span>}
                    </label>
                    {field.type === 'select' && (
                        <select value={String(values[field.slug] ?? '')}
                            onChange={e => onChange(field.slug, e.target.value)}
                            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
                            <option value="">— Select —</option>
                            {field.options?.map(o => <option key={o} value={o}>{o}</option>)}
                        </select>
                    )}
                    {field.type === 'boolean' && (
                        <input type="checkbox" checked={Boolean(values[field.slug])}
                            onChange={e => onChange(field.slug, e.target.checked)}
                            className="mt-1 h-4 w-4 rounded border-gray-300" />
                    )}
                    {['text', 'number', 'date'].includes(field.type) && (
                        <input type={field.type} value={String(values[field.slug] ?? '')}
                            onChange={e => onChange(field.slug, e.target.value)}
                            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm" />
                    )}
                    {errors[field.slug] && (
                        <p className="mt-1 text-sm text-red-600">{errors[field.slug]}</p>
                    )}
                </div>
            ))}
        </div>
    );
}
```
