# Lead Module

> CRM module. Module path: `CrmApp/Lead/`
> Depends on: FieldEngine, PipelineEngine, ActivityEngine, WorkflowEngine (all Phase 1)

---

## Migration

File: `CrmApp/Lead/database/migrations/2025_01_01_000001_create_leads_table.php`

```sql
CREATE TABLE leads (
    id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    uid           VARCHAR(16) UNIQUE NOT NULL,           -- display ID e.g. LEAD-00001
    owner_id      BIGINT UNSIGNED,                       -- FK users.id
    branch_id     BIGINT UNSIGNED,
    stage_id      BIGINT UNSIGNED,                       -- FK pipeline_stages.id
    pipeline_id   BIGINT UNSIGNED,                       -- FK pipelines.id
    status        TINYINT UNSIGNED DEFAULT 40,            -- see LeadStatusEnum
    name          VARCHAR(191) NOT NULL,
    email         VARCHAR(191) NULL,
    phone         VARCHAR(32)  NULL,
    source        VARCHAR(64)  NULL,                     -- walk-in, web, referral ...
    notes         TEXT NULL,
    -- Hybrid storage: searchable custom fields as REAL columns
    preferred_country     VARCHAR(128) NULL,
    preferred_study_level VARCHAR(64)  NULL,
    score                 DECIMAL(5,2) DEFAULT 0.00,
    -- Conversion tracking
    converted_at  TIMESTAMP NULL,
    contact_id    BIGINT UNSIGNED NULL,                  -- set after conversion
    deal_id       BIGINT UNSIGNED NULL,
    -- Timestamps
    created_at    TIMESTAMP NULL,
    updated_at    TIMESTAMP NULL,
    deleted_at    TIMESTAMP NULL,
    -- Explicit indexes
    INDEX idx_leads_owner   (owner_id),
    INDEX idx_leads_branch  (branch_id),
    INDEX idx_leads_stage   (stage_id),
    INDEX idx_leads_status  (status),
    INDEX idx_leads_created (created_at),                    -- admin default sort
    INDEX idx_leads_scope   (owner_id, branch_id, status),  -- composite for scoped queries
    FULLTEXT INDEX ft_leads_search (name, email, uid)        -- replaces LIKE '%..%' in search()
);

-- FK behavior (declare in PHP migration Blueprint):
-- stage_id:    ->nullable()->nullOnDelete()  — stage soft-deleted → lead.stage_id = null
-- pipeline_id: ->constrained()->restrict()  — cannot delete a pipeline with active leads
-- owner_id:    ->nullable()->nullOnDelete()  — user deleted → lead unassigned (owner_id = null)
-- branch_id:   ->nullable()->nullOnDelete()  — branch deleted → lead.branch_id = null
-- Note: pipeline_stages must use SoftDeletes to avoid hard FK violation
```

---

## Model

```php
namespace CrmApp\Lead\Models;

use CoreApp\Traits\{HasCustomFields, HasPipeline, HasActivityTimeline, FiresWorkflowEvents};
use EloquentFilter\Filterable;

class Lead extends Model
{
    use Filterable, SoftDeletes, HasCustomFields, HasPipeline, HasActivityTimeline, FiresWorkflowEvents;

    protected $fillable = [
        'uid', 'owner_id', 'branch_id', 'stage_id', 'pipeline_id', 'status',
        'name', 'email', 'phone', 'source', 'notes',
        'preferred_country', 'preferred_study_level', 'score',
        'converted_at', 'contact_id', 'deal_id',
    ];

    public function modelFilter(): string { return LeadFilter::class; }

    protected static function booted(): void
    {
        // UID assigned AFTER insert so it uses the auto-increment id — no race condition
        // updateQuietly() skips updated event so FiresWorkflowEvents does not re-fire
        static::created(function (Lead $lead) {
            if (!$lead->uid) {
                $lead->updateQuietly(['uid' => 'LEAD-' . str_pad($lead->id, 8, '0', STR_PAD_LEFT)]);
            }
        });
    }
}
```

---

## `LeadFilter`

File: `CrmApp/Lead/app/ModelFilters/LeadFilter.php`

```php
use App\ModelFilters\CommonFilter;

class LeadFilter extends ModelFilter
{
    use CommonFilter;  // status(), sortBy(), createdAtStart(), createdAtEnd()

    public function search(string $v): self
    {
        // FULLTEXT index on (name, email, uid) — replaces LIKE '%..%' full-table scan
        // Boolean mode + '*' suffix for prefix matching (e.g. "john" matches "johnson")
        // MySQL ft_min_word_len default = 4; set innodb_ft_min_token_size = 2 in my.cnf for short names
        return $this->whereRaw('MATCH(name, email, uid) AGAINST(? IN BOOLEAN MODE)', [$v . '*']);
    }

    // Searchable custom fields — direct column (no join on custom_field_values)
    public function preferredCountry(string $v): self { return $this->where('preferred_country', $v); }
    public function preferredStudyLevel(string $v): self { return $this->where('preferred_study_level', $v); }
    public function ownerId(int|string $v): self { return $this->where('owner_id', $v); }
    public function stageId(int|string $v): self { return $this->where('stage_id', $v); }
    public function source(string $v): self { return $this->where('source', $v); }
}
```

---

## `LeadService`

```php
class LeadService
{
    // Scope visibility by role
    public function baseQuery(array $params)
    {
        $user  = auth()->user();
        $query = Lead::filter($params);

        if ($user->hasRole('admin')) {
            return $query;                                     // sees all
        }
        if ($user->hasRole('manager')) {
            return $query->where('branch_id', $user->branch_id);
        }
        return $query->where('owner_id', $user->id);           // sales/counselor/agent
    }

    // Status transition guard — prevents arbitrary status writes bypassing business rules
    public function transitionStatus(Lead $lead, LeadStatusEnum $newStatus): void
    {
        if ($lead->converted_at !== null) {
            throw new \CrmApp\Lead\Exceptions\InvalidStatusTransitionException(
                "Converted leads cannot change status."
            );
        }
        $lead->update(['status' => $newStatus->value]);
    }

    // Summary for stat cards — cached per scope to avoid full-table aggregates on every page load
    public function summary(array $params): array
    {
        $user      = auth()->user();
        $scopeKey  = $user->hasRole('admin') ? 'admin' : ($user->hasRole('manager') ? "mgr:{$user->branch_id}" : "own:{$user->id}");
        $cacheKey  = 'lead_summary:' . $scopeKey;

        return cache()->remember($cacheKey, 60, function () use ($params) {
            $q = $this->baseQuery($params);
            return [
                'total'     => (clone $q)->count(),
                'new'       => (clone $q)->where('status', LeadStatusEnum::NEW->value)->count(),
                'converted' => (clone $q)->whereNotNull('converted_at')->count(),
                'avg_score' => (clone $q)->avg('score'),
            ];
        });
    }

    // Convert lead to contact + target (R3 — transaction, event after commit)
    //
    // Target type is resolved from EntityRegistry: lead → converts_to → ?
    //   - default (general / education / real-estate / garments): Deal
    //   - pharma (when SampleVisit ships): SampleVisit
    //
    // The target must implement HasPipeline + ConvertibleTargetInterface so this
    // service can populate it generically without knowing concrete column names.
    public function convert(Lead $lead): array
    {
        $registry    = app(\CoreApp\Services\EntityEngine\EntityRegistry::class);
        $targetClass = $registry->convertsTo('lead')
            ?? throw new \RuntimeException(
                "No 'converts_to' relationship registered for lead. Run CrmEntityTypeSeeder."
            );

        return DB::transaction(function () use ($lead, $targetClass) {
            // Re-read with exclusive lock inside transaction — prevents concurrent convert race
            $lead = Lead::lockForUpdate()->findOrFail($lead->id);
            if ($lead->converted_at) {
                throw new \CrmApp\Lead\Exceptions\AlreadyConvertedException;
            }
            $contact = Contact::firstOrCreate(
                ['email' => $lead->email],
                ['name' => $lead->name, 'phone' => $lead->phone]
            );

            // Build target via factory method — every convert target implements
            // ConvertibleTargetInterface::fromLead(Lead, Contact): self
            // For Deal this maps to: name, contact_id, owner_id, branch_id, pipeline_id, stage_id, status.
            // For pharma's SampleVisit this would map to its own columns — same call site.
            $target = $targetClass::fromLead($lead, $contact);

            $lead->update([
                'converted_at' => now(),
                'contact_id'   => $contact->id,
                'deal_id'      => $target instanceof \CrmApp\Deal\Models\Deal ? $target->id : null,
                'status'       => LeadStatusEnum::CONVERTED->value,
            ]);

            // Fire event AFTER commit
            DB::afterCommit(fn () => event(new \CrmApp\Lead\Events\LeadConverted($lead, $contact, $target)));

            return ['contact' => $contact, 'target' => $target];
        });
    }
}
```

### `ConvertibleTargetInterface`

Lives in CoreApp because it's the contract any convert target must satisfy:

```php
// CoreApp/app/Contracts/ConvertibleTargetInterface.php
namespace CoreApp\Contracts;

use CrmApp\Lead\Models\Lead;
use App\Models\Contact;

interface ConvertibleTargetInterface
{
    /**
     * Build a new convert target from a Lead + Contact.
     * Implementations decide what columns to copy and what defaults to apply
     * (default pipeline, default stage, default status).
     */
    public static function fromLead(Lead $lead, Contact $contact): self;
}
```

`Deal::fromLead()` implementation:
```php
public static function fromLead(Lead $lead, Contact $contact): self
{
    $pipeline = app(\CoreApp\Services\PipelineEngine\PipelineService::class)->defaultForType('deal');
    return self::create([
        'name'        => $lead->name,
        'contact_id'  => $contact->id,
        'owner_id'    => $lead->owner_id,
        'branch_id'   => $lead->branch_id,
        'pipeline_id' => $pipeline->id,
        'stage_id'    => $pipeline->stages()->orderBy('sort_order')->first()->id,
        'status'      => \CrmApp\Deal\Enums\DealStatusEnum::OPEN->value,
    ]);
}
```

> **Why not pass through hardcoded `Deal::create()`?** Because the platform thesis requires that pharma's "Doctor Lead → Sample Visit" works without changing `LeadService`. The registry consult + factory method puts the convert mapping inside the *target* model where it belongs, leaving `LeadService::convert()` polymorphic. v1 ships only Deal as a target — but the seam is exercised, not dead.

---

## `LeadServiceInterface`

```php
// CrmApp/Lead/app/Contracts/LeadServiceInterface.php
namespace CrmApp\Lead\Contracts;

interface LeadServiceInterface
{
    public function baseQuery(array $params): \Illuminate\Database\Eloquent\Builder;
    public function convert(Lead $lead, array $opts): array;
    public function summary(array $params): array;
}
```

Bind in `CrmLeadServiceProvider::register()`:
```php
$this->app->bind(LeadServiceInterface::class, LeadService::class);
```

---

## `LeadPolicy`

```php
public function viewAny(User $user): bool { return true; }
public function view(User $user, Lead $lead): bool
{
    if ($user->hasRole('admin')) return true;
    if ($user->hasRole('manager')) return $user->branch_id === $lead->branch_id;
    return $user->id === $lead->owner_id;
}
// update/delete follow same pattern
```

---

## Routes

File: `CrmApp/Lead/routes/tenant.php`
```php
Route::middleware(['web', InitializeTenancyByDomain::class, PreventAccessFromCentralDomains::class])
    ->group(function () {
        Route::middleware(['auth', 'verified'])->group(function () {
            // bulk-action BEFORE resource (CLAUDE.md rule)
            Route::post('leads/bulk-action', [LeadController::class, 'bulkAction'])->name('leads.bulk-action');
            Route::post('leads/{lead}/convert', [LeadController::class, 'convert'])->name('leads.convert');
            Route::resource('leads', LeadController::class)->names('leads');
        });
    });
```

---

## Controller sketch

```php
class LeadController extends Controller
{
    public function index(Request $request, LeadService $service): Response|JsonResponse
    {
        $params = $request->all();
        $items  = $service->baseQuery($params)->with(['stage', 'owner'])->simplePaginate(25);

        if ($request->wantsJson()) {
            return response()->json([
                'data'    => LeadResource::collection($items->items()),
                'meta'    => simple_pagination_meta($items),
                'summary' => $service->summary($params),
                'status'  => 'success',
            ]);
        }

        return Inertia::render('Lead/Index', [
            'leadData' => [
                'data'        => LeadResource::collection($items->items()),
                'meta'        => simple_pagination_meta($items),
                'queryParams' => $params,
                'summary'     => $service->summary($params),
            ],
        ]);
    }

    public function convert(Lead $lead, LeadService $service): RedirectResponse
    {
        $this->authorize('update', $lead);
        $service->convert($lead);
        return redirect()->route('leads.index')->with('success', 'Lead converted.');
    }

    public function bulkAction(BulkActionRequest $request, LeadService $service): RedirectResponse
    {
        $service->bulkStatusUpdate($request->status, $request->ids);
        return redirect()->route('leads.index')->with('success', 'Updated.');
    }
}
```

---

## `LeadResource`

```php
public function toArray($request): array
{
    return [
        'id'            => $this->id,
        'uid'           => $this->uid,
        'name'          => $this->name,
        'email'         => $this->email,
        'phone'         => $this->phone,
        'source'        => $this->source,
        'status'        => $this->status,
        'status_label'  => LeadStatusEnum::from($this->status)->label(),
        'stage'         => $this->whenLoaded('stage', fn () => [
            'id' => $this->stage->id, 'name' => $this->stage->name,
        ]),
        'owner'         => $this->whenLoaded('owner', fn () => [
            'id' => $this->owner->id, 'name' => $this->owner->name,
        ]),
        'preferred_country'     => $this->preferred_country,
        'preferred_study_level' => $this->preferred_study_level,
        'score'                 => $this->score,
        'converted_at'  => $this->converted_at?->toDateTimeString(),
        'custom_fields' => $this->customFieldValues->keyBy('slug')->map->value,
        'created_at'    => $this->created_at->toDateTimeString(),
    ];
}
```

---

## Pages

| Page | Path |
|------|------|
| Index | `CrmApp/Lead/resources/assets/js/pages/Lead/Index.tsx` |
| Create | `CrmApp/Lead/resources/assets/js/pages/Lead/Create.tsx` |
| Edit | `CrmApp/Lead/resources/assets/js/pages/Lead/Edit.tsx` |
| Show | `CrmApp/Lead/resources/assets/js/pages/Lead/Show.tsx` |

---

## Cross-Domain UI Adaptation

> Based on the Figma design. Every page has three layers: **label rebranding** (useLabel), **pipeline-driven status** (seeded stages), and **dynamic field sections** (DynamicFields + group_name from pack JSON). Zero profile-specific `if` statements in component code.

---

### Layer 1 — Label rebranding (page title, buttons, breadcrumbs)

```tsx
const leadLabel    = useLabel('lead');     // "Application" on education
const contactLabel = useLabel('contact');  // "Student" on education
const dealLabel    = useLabel('deal');     // "Enrollment" on education

// Page header
<h1>{leadLabel.singular} Details</h1>           // "Lead Details" / "Application Details"

// Action button
<Button>Convert to {contactLabel.singular}</Button>  // "Convert to Contact" / "Convert to Student"

// Breadcrumbs
[{ title: 'Home' }, { title: 'CRM' }, { title: leadLabel.plural }, { title: lead.name }]

// Index stat cards
<StatisticsCard title={`Total ${leadLabel.plural}`} ... />  // "Total Leads" / "Total Applications"

// Create button
<Button>Create {leadLabel.singular} +</Button>
```

---

### Layer 2 — Pipeline-driven status dropdown

The **"Contacted ▾"** dropdown in the page header is NOT a hardcoded enum. It reads from the active pipeline's stages:

```tsx
// Controller passes pipeline stages as Inertia prop
'stages' => PipelineStage::where('pipeline_id', $lead->pipeline_id)
    ->orderBy('sort_order')->get(['id', 'name', 'color'])

// Frontend renders a stage-picker dropdown
<StagePicker
    stages={stages}           // [{id:1, name:"Enquiry"}, {id:2, name:"Counselled"}, ...]
    currentStageId={lead.stage_id}
    onChange={(stageId) => router.post(route('leads.move-stage', lead.id), { stage_id: stageId })}
/>
```

| Profile | Stage dropdown shows |
|---------|---------------------|
| General | New · Contacted · Qualified · Proposal Sent · Won · Lost |
| Education | Enquiry · Counselled · Applied · Admitted · Rejected |
| Real Estate | New Enquiry · Site Visit · Valuation · Negotiation · Sold · Lost |
| Pharma | Prospect · Detailing · Sampling · Prescribing · Loyal · Inactive |
| Garments | Enquiry · Sample Request · Price Negotiation · Contract · Closed · Lost |

Zero component changes — stages come from `pipeline_stages` seeded by the pack.

---

### Layer 3 — Dynamic field sections (the left panel below Overview)

The left panel has two parts:

**Fixed "Overview" section** — always rendered from common migration columns, identical across all profiles:

```
Full Name       → lead.name
Job Title       → custom field (group: "Overview", seeded by all packs)
Phone           → lead.phone
Email           → lead.email
Website         → custom field (group: "Overview", seeded by all packs)
Date            → lead.created_at
Lead Owner      → lead.owner (avatar + name + role)
```

**Dynamic sections below Overview** — rendered by `<DynamicFields>` grouped by `group_name`:

```tsx
// Controller passes custom field schema + values
'customFields' => CustomFieldResolver::forEntity('lead'),   // schema per profile
'customValues' => $lead->customFieldValues->keyBy('slug'),  // saved values

// Component groups by group_name and renders a section per group
<DynamicFields
    fields={customFields}    // [{slug, label, type, group_name, options, ...}]
    values={customValues}
    mode="view"
/>
```

What `DynamicFields` renders per profile:

**General CRM** (`default_crm_pack`):
```
── Lead Classification ──
Lead Source      [select: Website/Walk-in/Referral/Social/Email]
Campaign         [text]

── Organization Information ──
Company          [text]
Industry         [select]
No. of Employees [number]
Yearly Revenue   [select]
VAT Number       [text]

── Additional Information ──
Address          [text]
Tags             [multi-tag]
Social           [links: FB/LinkedIn/WA/TG]
Time Zone        [select]
```

**Education CRM** (`education_pack`):
```
── Education Background ──
Gender · Date of Birth · Last Education · Last Academic Institute
Passing Year · IELTS / Others · Profession (Current)

── Preferences ──
Country (Preferred) · Preferred Country (Additional) [multiselect]
Preferred Study Level · Preferred Course
Preferred Subject Area · Additional Subject Area
```

**Real Estate CRM** (`real_estate_pack`):
```
── Property Requirements ──
Property Type [Apartment/Villa/Commercial/Land/Townhouse]
Purpose [Buy/Rent/Invest] · Budget Min · Budget Max
Location Preference · Bedrooms · Furnished [Yes/No/Partially]
```

**Pharma CRM** (`pharma_pack`):
```
── Doctor Profile ──
Specialization · Hospital / Clinic · Territory
Monthly Rx Potential · Products of Interest [multiselect]
```

**Garments CRM** (`garments_pack`):
```
── Buyer Requirements ──
Product Category [Woven/Knit/Denim/Sweater/…]
Destination Country · Annual Volume (pcs)
Certifications Needed [multiselect] · Brand / Retailer
```

The pack JSON is the only thing that changes. `DynamicFields` renders whatever groups are seeded — no component edits needed for new profiles.

---

### Show.tsx layout spec (based on Figma)

```
┌─────────────────────────────────────────────────────────────────────────┐
│ HEADER                                                                  │
│  "{leadLabel.singular} Details"      [Stage ▾]  [Convert to {contact}] [Edit] [⋮] │
└─────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────┐  ┌──────────────────────────────────────────┐
│ LEFT PANEL (35%)         │  │ RIGHT PANEL — ACTIVITY TIMELINE (65%)    │
│                          │  │                                          │
│ ┌────────────────────┐   │  │ [Activity][Note][Task][Email][Call]      │
│ │ Avatar  Name       │   │  │ [Comments][Attachments][More]  [Recent▾] │
│ │ Job Title•Company  │   │  │                                          │
│ │ email              │   │  │ ── May 9, 2025 ──                        │
│ └────────────────────┘   │  │ 🔴 Note Added          10:24 AM         │
│                          │  │    Hamim added "Lead Information"        │
│ [Note][Task][Chat][Email]│  │                                          │
│ [More]                   │  │ 🟢 Task Added          09:33 AM         │
│                          │  │    Admin assigned "Contact Demo"         │
│ Overview                 │  │                                          │
│  Full Name   Shei Chen   │  │ 🔵 Contact Changes     08:00 AM         │
│  Job Title   Head of…    │  │    Contact Demo updated: is_login        │
│  Phone       +880-…      │  │                                          │
│  Email       shei@…      │  │ 🟡 Reminder Added      09:33 AM         │
│  Website     www.…       │  │    "Prepare Walkthrough Slides"          │
│  Date        Mar 17      │  │                                          │
│  Lead Owner  Alex Rivera │  │ ✉️  Email Sent          09:33 AM         │
│                          │  │    Nahid sent "Meeting Confirmation"     │
│ ── Dynamic sections ──   │  │                                          │
│ (from DynamicFields)     │  │ ── May 5, 2025 ──                        │
│  varies per profile      │  │  ...                                     │
└──────────────────────────┘  └──────────────────────────────────────────┘
```

### Index.tsx layout spec (based on Figma)

```
Header: "{leadLabel.plural}"   [Status ▾]  [Import]  [Create {leadLabel.singular} +]

┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────────┐
│Total     │ │Total     │ │Total Paid    │ │Total Due     │
│{plural}  │ │Converted │ │Amount        │ │Amount        │
│1,500     │ │250       │ │৳5,80,000     │ │৳30,000       │
└──────────┘ └──────────┘ └──────────────┘ └──────────────┘

[10 ▾] [Bulk Actions ▾] [Reset ×]              [Search…] [Filter]

Table columns:
  ☐ | Lead Name+avatar | Company | Phone | Rating badge | Lead Type | Status | Source | Lead Owner | Actions
```

> **Rating badge** (Warm/Hot/Cold) maps to `score` column: score ≥ 70 = Hot (red), 40–69 = Warm (orange), < 40 = Cold (blue). Rebranding: "Lead Name" column header uses `leadLabel.singular + ' Name'`.

---

## `LeadStatusEnum`

```php
enum LeadStatusEnum: int
{
    case NEW        = 40;
    case CONTACTED  = 41;
    case QUALIFIED  = 42;
    case PROPOSAL   = 43;
    case CONVERTED  = 44;
    case LOST       = 45;

    public function label(): string { return match($this) {
        self::NEW       => 'New',
        self::CONTACTED => 'Contacted',
        self::QUALIFIED => 'Qualified',
        self::PROPOSAL  => 'Proposal',
        self::CONVERTED => 'Converted',
        self::LOST      => 'Lost',
    }; }
}
```
