Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

13 — State Management (CRM Module)

Per-screen state on top of 00-shared/06_State_Management.md (Bloc/Cubit, repository layer, SWR cache, optimistic-update rules, pagination mixin). CRM's defining problems: two status-driven lists with server pagination, a derived next-follow-up, and terminal, side-effect-heavy mutations (convert, decide) that are never optimistic.


1. Cubits & responsibilities

CubitScreen(s)State
LeadsCubitS1page + status filter + client-side source/mine filters + rows
LeadDetailCubitS2full lead + timeline + mutation flags
LeadFormCubitS3create/edit form, 409 handling
FollowUpCubitS2 sheetfollow-up form lifecycle
ConvertCubitS4pre-check + in-flight + result
AdmissionsCubitS5page + status filter + stats tiles
AdmissionDetailCubitS6admission + workflow + mutation flags
AdmissionFormCubitS7create form
InterviewCubitS8schedule form lifecycle
DecisionCubitS9decision form lifecycle
CampaignsCubitS10paged list
CampaignFormCubitS11create form

All implement LoadState {Initial, Loading, Success, Error} (00-shared/06 §3.1); lists implement PaginatedListMixin (contract mirrors API: page/limit/meta).

2. LeadsCubit (S1) — list + derived follow-up

state: {
  loadState, statusFilter: LeadStatus?, sourceFilter: LeadSource?,
  mineOnly: bool, items: [Lead], meta, page
}
events: Load, Refresh, ChangeStatus(status?), ChangeSource(source?),
        ToggleMine, LoadMore, Retry
  • Server request: GET /crm/leads?page&limit&status (crm.controller.ts:37-45); server sort fixed createdAt: -1 (lead.repository.ts:31).
  • Client-side filters (source, mineOnly) applied over loaded pages only — banner hint "filtered on device" (10 §2); server filters (planned).
  • Derived per row: followUpSummary(lead) → next due = min scheduledAt over followUps without completedAt (lead.schema.ts:28-31); overdue flag. Pure function, unit-tested; cache key crm.leads:{status} (SWR, 5 min TTL — volatile; RefreshIndicator bypasses).
  • Realtime: none — no WS topic; refetch on foreground resume ((forward-looking) topic crm.lead.updated).

3. LeadDetailCubit (S2)

state: {loadState, lead, saving: {fields|assignment}, converting: bool,
        lastError: ApiException?}
events: Load, Refresh, UpdateFields(patch), Assign(staffId),
        CloseWith(reason), FollowUpAdded(fu)
  • Load: GET /crm/leads/:id (crm.controller.ts:53-57) — no client cache.
  • UpdateFields → PATCH (crm.controller.ts:59-63) — optimistic with rollback (00-shared/06 §3.5); rollback restores previous lead; snackbar on failure.
  • Status closed without reason → local closedReason prompt; server stamps closedAt (crm.service.ts:94-96) — reconcile from response.
  • FollowUpAdded → refresh detail (pessimistic; response authoritative, carries createdBy).
  • On 401 → sessionExpired flow (00-shared/06 §3.6).

4. LeadFormCubit (S3)

state: {loadState, mode: create|edit, fields, fieldErrors, conflict:
        {email, existingLeadId?}, saving}
  • Create → POST /crm/leads; Edit → PATCH /crm/leads/:id.
  • 409 duplicate (crm.service.ts:42-47): state conflict → inline banner; action "View existing" → lookup (planned) (no email-search endpoint; today: edit fields). Double-submit guarded.
  • 400 VALIDATION_ERROR → map details[].field → fieldErrors.

5. FollowUpCubit (S2 sheet)

  • POST crm.controller.ts:65-69; pessimistic; success → emit FollowUpAdded → detail refetch; validation per 08 §4.

6. ConvertCubit (S4)

state: {preCheck: {pass, missing: [String]}, inFlight, result?, error}
  • Pre-check (client, mirrors crm.service.ts:118-149): status ∉ {converted, closed}; placement = gradeId+academicYearId+classId set.
  • Execute: POST /crm/leads/:id/convert (crm.controller.ts:71-75) — never optimistic (User+Student side effects). 400 → map exact message to i18n (12 §4); success → refresh detail + snackbar.

7. AdmissionsCubit (S5) — list + stats

state: {loadState, statusFilter, items, meta, stats: {counts, total,
        conversionRate}?, statsState}
  • List: GET /crm/admissions?page&limit&status (crm.controller.ts:103-111); sort submittedAt: -1 (admission.repository.ts:29).
  • Stats: GET /crm/admissions/stats (crm.controller.ts:97-101) — independent statsState (failure → tiles "—", list still usable).
  • Refresh refetches both in parallel.

8. AdmissionDetailCubit (S6)

state: {loadState, admission, saving, deciding, scheduling, uploading,
        editable: bool (decidable/open)}
  • editable = status decidable (admission.schema.ts:20-25) for decisions; PATCH allowed while not closed (admission.service.ts:257-269).
  • Mutations: documents POST (crm.controller.ts:125-132), interview POST (:134-141), decision POST (:143-147), convert POST (:149-153) — all pessimistic; each refreshes the admission (workflow history authoritative).
  • Status transitions surfaced as snackbar: submitted → documents_pending (admission.service.ts:112-114), → interview_scheduled (:130-132).

9. InterviewCubit (S8) / DecisionCubit (S9) / AdmissionFormCubit (S7)

  • Single-purpose form lifecycle cubits; validation per 08 §6-8; pessimistic submit; success → pop sheet/dialog + detail/list refresh.
  • Decision: explicit choice required; comment optional (admission-decision.dto.ts:10-18).

10. CampaignsCubit (S10) / CampaignFormCubit (S11)

  • List: GET /crm/campaigns (crm.controller.ts:77-81); no detail endpoint — rows are read-only. Create: POST /crm/campaigns (crm.controller.ts:83-87).
  • Metrics (campaign.schema.ts:45-52) rendered from payload; absent → "—".

11. State diagram

flowchart TD
    A[CRM Shell] -->|crm.read| B[LeadsCubit]
    A -->|crm.read| C[AdmissionsCubit]
    A -->|crm.read| D[CampaignsCubit]

    B --> E[LeadDetailCubit]
    E --> F[LeadFormCubit]
    E --> G[FollowUpCubit]
    E --> H[ConvertCubit]
    H -->|success| E

    C --> I[AdmissionDetailCubit]
    I --> J[AdmissionFormCubit]
    I --> K[InterviewCubit]
    I --> L[DecisionCubit]
    K -->|success| I
    L -->|success| I
    I -.->|convert| M[ConvertCubit reuse]
    M -->|success| I

    D --> N[CampaignFormCubit]

    classDef flow fill:#e8f0fe,stroke:#3949ab;
    class B,C,D,E,I,H flow;

12. Cross-cutting

  • Auth/session: all cubits react to sessionExpired → redirect login, restore route on re-login (00-shared/06 §3.6).
  • Permissions: crm.read gates tabs/routes; crm.lead.manage gates all write actions; crm.campaign.manage gates S11 — evaluated at route build + guard (00-shared/05 §9); server authoritative (enforcement (planned), permissions.constants.ts:34-36).
  • Offline: reads = SWR cache + banner; writes are never queued in CRM (no offline write queue — explicit decision; forms require connectivity).
  • Testing hooks: pure-Dart cubits with mocked repositories; widget tests for three-state machine, optimistic rollback (status/assignment), 409 banner, pre-check gating (00-shared/06 §6).