# Deal Module

> CRM module. Module path: `CrmApp/Deal/`
> Key page: Kanban board (Board.tsx) is the primary UI, not a DataTable index.
> Depends on: FieldEngine, PipelineEngine, ActivityEngine, WorkflowEngine (all Phase 1)

---

## Migration

File: `CrmApp/Deal/database/migrations/2025_01_01_000002_create_deals_table.php`

```sql
CREATE TABLE deals (
    id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    uid           VARCHAR(16) UNIQUE NOT NULL,           -- e.g. DEAL-00001
    contact_id    BIGINT UNSIGNED NULL,                  -- FK contacts.id (optional)
    owner_id      BIGINT UNSIGNED NULL,                  -- FK users.id
    branch_id     BIGINT UNSIGNED NULL,
    pipeline_id   BIGINT UNSIGNED NOT NULL,              -- FK pipelines.id
    stage_id      BIGINT UNSIGNED NOT NULL,              -- FK pipeline_stages.id
    status        TINYINT UNSIGNED DEFAULT 50,            -- DealStatusEnum
    name          VARCHAR(191) NOT NULL,
    value         DECIMAL(14,2) DEFAULT 0.00,
    currency      CHAR(3) DEFAULT 'USD',
    close_date    DATE NULL,
    notes         TEXT NULL,
    -- Hybrid storage
    country       VARCHAR(128) NULL,                     -- searchable destination country
    -- Timestamps
    created_at    TIMESTAMP NULL,
    updated_at    TIMESTAMP NULL,
    deleted_at    TIMESTAMP NULL,
    -- Explicit indexes
    INDEX idx_deals_owner   (owner_id),
    INDEX idx_deals_branch  (branch_id),
    INDEX idx_deals_stage   (stage_id),
    INDEX idx_deals_status  (status),
    INDEX idx_deals_created (created_at),                   -- admin default sort
    INDEX idx_deals_scope   (owner_id, branch_id, status)  -- composite for scoped queries
);

-- FK behavior (declare in PHP migration Blueprint):
-- stage_id:    ->nullable()->nullOnDelete()
-- pipeline_id: ->constrained()->restrict()
-- owner_id:    ->nullable()->nullOnDelete()
-- contact_id:  ->nullable()->nullOnDelete()
```

---

## Model

```php
namespace CrmApp\Deal\Models;

use CoreApp\Contracts\ConvertibleTargetInterface;
use CoreApp\Traits\{HasCustomFields, HasPipeline, HasActivityTimeline, FiresWorkflowEvents};
use CrmApp\Lead\Models\Lead;
use App\Models\Contact;
use EloquentFilter\Filterable;

class Deal extends Model implements ConvertibleTargetInterface
{
    use Filterable, SoftDeletes, HasCustomFields, HasPipeline, HasActivityTimeline, FiresWorkflowEvents;

    protected $fillable = [
        'uid', 'contact_id', 'owner_id', 'branch_id', 'pipeline_id', 'stage_id',
        'status', 'name', 'value', 'currency', 'close_date', 'notes', 'country',
    ];

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

    protected static function booted(): void
    {
        static::created(function (Deal $deal) {
            if (!$deal->uid) {
                $deal->updateQuietly(['uid' => 'DEAL-' . str_pad($deal->id, 8, '0', STR_PAD_LEFT)]);
            }
        });
    }

    /**
     * Convert factory — called by LeadService::convert() after EntityRegistry
     * resolves 'lead → converts_to → deal'. Adding a new convert target = new
     * model implementing this method, no LeadService changes.
     */
    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,
        ]);
    }

    public function stages(): BelongsToMany { ... }  // via pipeline
    public function contact(): BelongsTo { return $this->belongsTo(Contact::class); }
}
```

---

## `DealFilter`

```php
class DealFilter extends ModelFilter
{
    use CommonFilter;

    public function search(string $v): self
    {
        return $this->where(fn ($q) => $q
            ->where('name', 'like', "%{$v}%")
            ->orWhere('uid', 'like', "%{$v}%")
        );
    }

    public function country(string $v): self  { return $this->where('country', $v); }
    public function ownerId(int $v): self     { return $this->where('owner_id', $v); }
    public function stageId(int $v): self     { return $this->where('stage_id', $v); }
    public function contactId(int $v): self   { return $this->where('contact_id', $v); }
    public function closeDateStart(string $d): self { return $this->where('close_date', '>=', $d); }
    public function closeDateEnd(string $d): self   { return $this->where('close_date', '<=', $d); }
}
```

---

## `DealService`

```php
class DealService
{
    public function baseQuery(array $params)
    {
        $user  = auth()->user();
        $query = Deal::filter($params);

        if ($user->hasRole('admin'))   return $query;
        if ($user->hasRole('manager')) return $query->where('branch_id', $user->branch_id);
        return $query->where('owner_id', $user->id);
    }

    public function moveToStage(Deal $deal, PipelineStage $stage): void
    {
        $deal->moveToStage($stage);  // uses HasPipeline trait — fires workflow event
    }

    // Status transition guard — WON/LOST are terminal; cannot reopen without explicit override
    public function transitionStatus(Deal $deal, DealStatusEnum $newStatus): void
    {
        $terminal = [DealStatusEnum::WON->value, DealStatusEnum::LOST->value];
        if (in_array($deal->status, $terminal)) {
            throw new \CrmApp\Deal\Exceptions\InvalidStatusTransitionException(
                "Deal is {$deal->status} — terminal status cannot be changed."
            );
        }
        $deal->update(['status' => $newStatus->value]);
    }

    public function boardData(int $pipelineId): array
    {
        $pipeline = Pipeline::with('stages')->findOrFail($pipelineId);
        return $pipeline->stages->map(fn ($stage) => [
            'id'          => $stage->id,
            'name'        => $stage->name,
            'color'       => $stage->color,
            'count'       => $stage->deals()->count(),         // real count, not loaded collection count
            'total_value' => $stage->deals()->sum('value'),
            'deals'       => DealResource::collection(
                $stage->deals()
                    ->with(['contact', 'owner'])               // eager-load prevents N+1
                    ->latest()
                    ->limit(50)                                // max 50 cards per column on initial load
                    ->get()
            ),
        ])->toArray();
    }

    /** Load-more for stages with > 50 deals (called by Board.tsx "Load more" button) */
    public function boardStageDeals(int $stageId, int $page = 1): array
    {
        return DealResource::collection(
            Deal::where('stage_id', $stageId)
                ->with(['contact', 'owner'])
                ->latest()
                ->forPage($page, 50)
                ->get()
        )->toArray(request());
    }
}
```

---

## Routes

```php
// bulk-action BEFORE resource
Route::post('deals/bulk-action', [DealController::class, 'bulkAction'])->name('deals.bulk-action');
Route::post('deals/{deal}/move-stage', [DealController::class, 'moveStage'])->name('deals.move-stage');
// Board load-more (returns JSON — called via axios, not Inertia)
Route::get('deals/board-stage', [DealController::class, 'boardStage'])->name('deals.board-stage');
Route::resource('deals', DealController::class)->names('deals');
```

---

## Controller sketch

```php
public function index(Request $request, DealService $service): Response
{
    // Board view — grouped by stage, not paginated list
    $pipelineId = $request->get('pipeline_id',
        Pipeline::where('pipeline_type', 'deal')->where('is_default', true)->value('id')
    );

    return Inertia::render('Deal/Board', [
        'boardData'  => $service->boardData($pipelineId),
        'pipelines'  => PipelineResource::collection(Pipeline::where('pipeline_type', 'deal')->get()),
        'activePipeline' => $pipelineId,
    ]);
}

public function moveStage(Request $request, Deal $deal, DealService $service): RedirectResponse
{
    $this->authorize('update', $deal);
    $stage = PipelineStage::findOrFail($request->stage_id);
    $service->moveToStage($deal, $stage);
    return redirect()->back()->with('success', 'Stage updated.');
}
```

---

## Board.tsx (Kanban)

```tsx
// File: CrmApp/Deal/resources/assets/js/pages/Deal/Board.tsx
// Columns = pipeline stages
// Cards = DealCard component
// Drag-and-drop via dnd-kit (already in project) or CSS drag events
// AJAX move: router.post(route('deals.move-stage', deal.id), { stage_id })
```

Board.layout assigns AppLayout with breadcrumbs:
`Home → CRM → useLabel('deal_plural')`

---

## Cross-Domain UI Adaptation

> Based on Figma. Same three-layer pattern as Lead. Board columns, card fields, page title, and field sections all adapt to the active profile with zero component changes.

---

### Layer 1 — Label rebranding

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

// Board header
<h1>{dealLabel.plural}</h1>                          // "Deals" / "Enrollments" / "Orders"
<Button>Add {dealLabel.singular} +</Button>          // "Add Deal" / "Add Enrollment"

// Show page header
<h1>{dealLabel.singular} Details</h1>               // "Deal Details" / "Enrollment Details"

// Convert button on Lead Show — goes to deal create
<Button>Convert to {dealLabel.singular}</Button>     // "Convert to Deal" / "Convert to Enrollment"
```

---

### Layer 2 — Kanban columns from pipeline stages

Board columns are **not hardcoded** — they are the `pipeline_stages` of the active deal pipeline:

```tsx
// Controller:
'boardData' => $service->boardData($activePipelineId)
// Each column: { id, name, color, count, total_value, deals: [...] }

// Board.tsx renders one column per stage:
{boardData.map(col => (
    <KanbanColumn
        key={col.id}
        title={col.name}          // stage name from DB — "Discovery" or "Profile" etc.
        count={col.count}
        totalValue={col.total_value}
        color={col.color}
        deals={col.deals}
    />
))}
```

| Profile | Kanban columns |
|---------|----------------|
| General | New · Contacted · Qualified · Unqualified · Converted · Closed |
| Education | Profile · Assessment · Documents · Shortlist & Apply · Application |
| Real Estate | Listed · Under Offer · Exchange · Completion |
| Pharma | Order Placed · Confirmed · Dispatched · Delivered |
| Garments | Order Received · Production · QC & Inspection · Shipment · Delivered |

The **column header** shows stage name + deal count + total value (e.g. `4 New  $54,750`). All from `boardData` — zero hardcoding.

---

### Layer 3 — Dynamic field sections on Deal Show/Create

**Fixed "Overview" section** — common migration columns, same across all profiles:
```
Deal Name     → deal.name / deal.uid
Contact       → deal.contact (linked)
Amount        → deal.value + deal.currency
Close Date    → deal.close_date
Notes         → deal.notes
Deal Owner    → deal.owner (avatar + name)
Company       → deal.contact.company (via contact relation)
Probability % → deal.stage.probability (from pipeline_stages)
```

**Dynamic sections below Overview** — `<DynamicFields entityType="deal">`:

**General CRM** (`default_crm_pack`):
```
── Deal Details ──
Budget           [number]
Decision Maker   [text]
```

**Education CRM** (`education_pack`):
```
── Application Details ──
University          [text]
Intake Month        [select: Jan–Dec]
Year                [number]
Program Type        [select: Direct University/Agent/Conditional/Unconditional]
Study Level         [select: Foundation/Undergraduate/Postgraduate/PhD/Diploma]
Course              [text]
Subject Area        [select]
```

**Real Estate CRM** (`real_estate_pack`):
```
── Property Details ──
Property Address   [text]
Asking Price       [number]
Agent Commission % [number]
Handover Date      [date]
Mortgage Required  [Yes/No]
```

**Pharma CRM** (`pharma_pack`):
```
── Order Details ──
Product Name     [text]
Quantity         [number]
Batch No         [text]
Expiry Date      [date]
Delivery Address [text]
```

**Garments CRM** (`garments_pack`):
```
── Order Details ──
Item Description  [text]
Quantity (pcs)    [number]
Unit Price (USD)  [number]
Delivery Date     [date]
Port of Loading   [text]
Payment Terms     [LC/TT/DP/DA]
```

---

### Deal card on Kanban — what adapts

```tsx
// DealCard.tsx — same component across all profiles
<div className="rounded-xl border border-gray-100 bg-white p-3 shadow-sm">
    <div className="flex justify-between">
        <h4>{deal.name}</h4>
        <MoreMenu />
    </div>
    <p className="text-xs text-gray-500">{deal.description}</p>
    <p className="font-semibold">$ {deal.value.toLocaleString()}</p>
    <div className="flex items-center gap-2 text-xs text-gray-500">
        <CompanyAvatar /> {deal.company}
        <span>{deal.probability}%</span>
    </div>
    <div className="flex items-center justify-between text-xs text-gray-400">
        <span>📅 {deal.created_at}</span>
        <AvatarGroup users={deal.assignees} />
    </div>
</div>
```

`deal.name` on education = "John Smith — De Montfort — MSc Forensic Accounting". On garments = "Zara Spring Order 2026". The card structure is identical — only the data differs.

---

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

```
┌─────────────────────────────────────────────────────────────────────────┐
│ HEADER                                                                  │
│  "{dealLabel.singular} Details"    [Stage ▾]  [Convert to Deal] [Edit] [⋮] │
└─────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────┐  ┌──────────────────────────────────────────┐
│ LEFT PANEL (35%)         │  │ RIGHT PANEL — ACTIVITY (65%)             │
│                          │  │                                          │
│ ┌────────────────────┐   │  │ [Activity][Note][Task][Email][Call]      │
│ │ Avatar  Name       │   │  │ [Comments][Attachments][More]  [Recent▾] │
│ │ Amount: $50,000    │   │  │                                          │
│ │ Stage: Contacted   │   │  │  (same timeline widget as Lead Show)     │
│ │ 🏢 Acme Corp  10%  │   │  │  entries: Note Added, Task Added,        │
│ └────────────────────┘   │  │  Contact Changes, Reminder, Email Sent   │
│                          │  │                                          │
│ [Note][Task][Chat][Email]│  │                                          │
│ [More]                   │  │                                          │
│                          │  │                                          │
│ Overview                 │  │                                          │
│  Full Name  Shei Chen    │  │                                          │
│  Job Title  Head of…     │  │                                          │
│  Phone      +880-…       │  │                                          │
│  Email      shei@…       │  │                                          │
│  Website    www.…        │  │                                          │
│  Date       Mar 17       │  │                                          │
│  Deal Owner Alex Rivera  │  │                                          │
│                          │  │                                          │
│ ── Dynamic sections ──   │  │                                          │
│  (DynamicFields)         │  │                                          │
│  varies per profile      │  │                                          │
└──────────────────────────┘  └──────────────────────────────────────────┘
```

> The `<TimelineWidget entityType="deal" entityId={deal.id} />` component (from ActivityEngine) is reused identically on both Lead Show and Deal Show — same tabs, same entry types, same date grouping.

---

## `DealServiceInterface`

```php
// CrmApp/Deal/app/Contracts/DealServiceInterface.php
namespace CrmApp\Deal\Contracts;

interface DealServiceInterface
{
    public function baseQuery(array $params): \Illuminate\Database\Eloquent\Builder;
    public function boardData(int $pipelineId): array;
    public function boardStageDeals(int $stageId, int $page): array;
    public function moveToStage(Deal $deal, PipelineStage $stage): void;
}
```

Bind in `CrmDealServiceProvider::register()`:
```php
$this->app->bind(DealServiceInterface::class, DealService::class);
```

---

## `DealStatusEnum`

```php
enum DealStatusEnum: int
{
    case OPEN       = 50;
    case WON        = 51;
    case LOST       = 52;
    case ON_HOLD    = 53;

    public function label(): string { return match($this) {
        self::OPEN    => 'Open',
        self::WON     => 'Won',
        self::LOST    => 'Lost',
        self::ON_HOLD => 'On Hold',
    }; }
}
```

---

## `DealPolicy`

Same 3-tier as `LeadPolicy`:
- admin → all
- manager → `branch_id` match
- others → `owner_id` match
