13 — State Management (CRM Module)
- 1. Cubits & responsibilities
- 2. LeadsCubit (S1) — list + derived follow-up
- 3. LeadDetailCubit (S2)
- 4. LeadFormCubit (S3)
- 5. FollowUpCubit (S2 sheet)
- 6. ConvertCubit (S4)
- 7. AdmissionsCubit (S5) — list + stats
- 8. AdmissionDetailCubit (S6)
- 9. InterviewCubit (S8) / DecisionCubit (S9) / AdmissionFormCubit (S7)
- 10. CampaignsCubit (S10) / CampaignFormCubit (S11)
- 11. State diagram
- 12. Cross-cutting
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
| Cubit | Screen(s) | State |
|---|---|---|
LeadsCubit | S1 | page + status filter + client-side source/mine filters + rows |
LeadDetailCubit | S2 | full lead + timeline + mutation flags |
LeadFormCubit | S3 | create/edit form, 409 handling |
FollowUpCubit | S2 sheet | follow-up form lifecycle |
ConvertCubit | S4 | pre-check + in-flight + result |
AdmissionsCubit | S5 | page + status filter + stats tiles |
AdmissionDetailCubit | S6 | admission + workflow + mutation flags |
AdmissionFormCubit | S7 | create form |
InterviewCubit | S8 | schedule form lifecycle |
DecisionCubit | S9 | decision form lifecycle |
CampaignsCubit | S10 | paged list |
CampaignFormCubit | S11 | create 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 fixedcreatedAt: -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 = minscheduledAtoverfollowUpswithoutcompletedAt(lead.schema.ts:28-31); overdue flag. Pure function, unit-tested; cache keycrm.leads:{status}(SWR, 5 min TTL — volatile;RefreshIndicatorbypasses). - Realtime: none — no WS topic; refetch on foreground resume
(
(forward-looking)topiccrm.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
closedwithout reason → localclosedReasonprompt; server stampsclosedAt(crm.service.ts:94-96) — reconcile from response. FollowUpAdded→ refresh detail (pessimistic; response authoritative, carriescreatedBy).- On 401 →
sessionExpiredflow (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): stateconflict→ inline banner; action "View existing" → lookup(planned)(no email-search endpoint; today: edit fields). Double-submit guarded. - 400
VALIDATION_ERROR→ mapdetails[].field → fieldErrors.
5. FollowUpCubit (S2 sheet)
- POST
crm.controller.ts:65-69; pessimistic; success → emitFollowUpAdded→ detail refetch; validation per08 §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); sortsubmittedAt: -1(admission.repository.ts:29). - Stats:
GET /crm/admissions/stats(crm.controller.ts:97-101) — independentstatsState(failure → tiles "—", list still usable). Refreshrefetches 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.readgates tabs/routes;crm.lead.managegates all write actions;crm.campaign.managegates 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).