06 — Screen Specifications (CRM Module)
- S1 — Leads List (
/crm/leads) - S2 — Lead Detail (
/crm/leads/:id) - S3 — Lead Editor (Create
/crm/leads/new· Edit sheet/crm/leads/:id/edit) - S4 — Convert Lead Dialog (from S2)
- S5 — Admissions List (
/crm/admissions) - S6 — Admission Detail (
/crm/admissions/:id) - S7 — Admission Editor — Create (
/crm/admissions/new) - S8 — Interview Scheduler (sheet on S6)
- S9 — Decision Dialog (on S6)
- S10 — Campaigns List (
/crm/campaigns) - S11 — Campaign Editor — Create (
/crm/campaigns/new)
Detailed specifications for every screen in 05_Screen_Inventory.md. Layout is mobile-first; tablet/desktop adaptations noted per screen. All states, copy, and API sources derive from
src/modules/crm/(file:line cited). Shared atoms from 00-shared/02-04 (Design Tokens, Component Library, Design System Mapping).
S1 — Leads List (/crm/leads)
Purpose & entry
Primary working screen for counselors. Entry: CRM tab, back from S2. Exit: S2 (row), S3 (FAB), filtered list.
Layout (mobile)
[AppBar: "Leads" | search icon (planned) | overflow]
[Status filter bar: All | New | Contacted | Qualified | Converted | Closed]
← segmented chips, horizontal scroll
[Source chips row (client-side filter): All | Website | Referral | Walk-in | Phone | Campaign | Other]
[My leads toggle chip (client-side, assignedTo == me)]
[ListView.builder of LeadListTile]
[FAB: New lead]
Data contract
GET /crm/leads?page=1&limit=20&status=new(crm.controller.ts:37-45); response{data: Lead[], meta: {page, limit, totalItems, totalPages, hasNext, hasPrevious}}.- Status values:
new|contacted|qualified|converted|closed(lead.schema.ts:7-13). - Server sort fixed
createdAt: -1(lead.repository.ts:31).
Row composition
LeadListTile: 40 dp AppAvatar (initials of firstName/lastName), title = full name,
subtitle = email (or phone if no email) + "next follow-up 12 Aug" derived from earliest
uncompleted followUps[].scheduledAt (lead.schema.ts:28-31), trailing =
LeadStatusChip + LeadSourceIcon. Converted rows show a check icon instead of the
action hint. Overdue follow-ups render subtitle in color.error with an alert icon.
States
| State | UI |
|---|---|
| initial/loading | 8 × AppSkeleton list rows |
| empty (filtered) | AppEmptyState "No leads in this status" + clear-filter action |
| empty (all) | AppEmptyState "No leads yet — create your first lead" + CTA to S3 |
| error offline | AppOfflineBanner + cached list (SWR, 00-shared/06 §3.3) |
| error 5xx | AppErrorState(code, message, retry) |
| permission | tab hidden without crm.read (route guard) |
Interactions
- Status chip tap → server request with
statusparam; chips reflect loading per-tab (keep list, show linear progress). - Source / "My leads" chips → client-side filter of current page (no server support —
04 §6gap;(planned)server filters). - Infinite scroll:
loadMore()whenhasNext(00-shared/06 §3.2). - Pull-to-refresh bypasses cache.
- Realtime
(forward-looking): no WS topic for lead changes — no live updates; refresh on foreground resume.
a11y
Row = single semantics label: "Aditi Sharma, contacted, follow-up due 12 Aug".
Status chips announce state + count. Contrast for overdue text ≥ 4.5:1 (00-shared/09).
Motion
List fade-slide 150 ms; chip selection m-fast; FAB press scale.
Analytics (proposed)
crm.leads.list, crm.leads.filter(status|source|mine), crm.leads.open(id),
crm.leads.load_more.
S2 — Lead Detail (/crm/leads/:id)
Purpose
The lead's full record + follow-up timeline + convert entry point.
Layout (scroll: CustomScrollView with slivers)
- Identity header (sticky):
AppAvatar56 dp, name, email, phone,LeadSourceIcon- label,
LeadStatusChip.
- label,
- Pipeline card (
AppCard):- status label + updatedAt
- if converted:
convertedAt, "Student #…" link (convertedToStudentId,lead.schema.ts:85-89) - if closed:
closedAt+closedReason(lead.schema.ts:91-95)
- Placement card: grade / section / academic year / class labels (resolved from
refs; unresolved → "—"). Empty state: "Add grade, academic year and class to enable
conversion" (mirrors server rule
crm.service.ts:145-149). - Assignment card:
assignedTostaff name or "Unassigned" + assign action (PATCHassignedTo,update-lead.dto.ts:72-75). - Follow-up timeline (module
AppTimeline):- each entry: note,
scheduledAt(formatted), checkmark whencompletedAtset, "by {createdBy name}" (lead.schema.ts:24-39) - overdue entries in
color.error; today's incolor.primary - empty state: "No follow-ups yet"
- each entry: note,
- Notes card:
notesor empty. - Bottom action bar (sticky,
crm.lead.manage):Add follow-up,Edit,Convert to student(primary, disabled when converted/closed or placement incomplete — tooltip explains rule).
Data contract
GET /crm/leads/:id (crm.controller.ts:53-57) → 404 RESOURCE_NOT_FOUND for
missing/cross-tenant (scoped filter, base.repository.ts:20-30).
States
loading skeleton; 404 → AppErrorState "Lead not found" + back; offline → cached
detail (no client cache per 00-shared/06 §3.3 — detail views fetch; banner + retry).
Interactions
- Convert tap → S4 dialog (rules pre-checked client-side).
- Follow-up add → bottom sheet (S2-inline form,
08 §2b), POSTcrm.controller.ts:65-69, appends to timeline on success (pessimistic — server response is authoritative). - Edit → S3 sheet; on save PATCH (
crm.controller.ts:59-63) → refresh detail. - Assignment change → PATCH, optimistic with rollback (
00-shared/06 §3.5).
a11y
Timeline announced per entry with date; convert button disabled state explained via
Tooltip + semantics hint.
Motion
Section reveal m-base; timeline entries stagger 40 ms.
Analytics (proposed)
crm.lead.open(id), crm.lead.followup.added, crm.lead.assigned,
crm.lead.update.completed, crm.lead.convert.opened.
S3 — Lead Editor (Create /crm/leads/new · Edit sheet /crm/leads/:id/edit)
Purpose & entry
Minimal capture (create) / full edit (edit). Create entered from S1 FAB; edit from S2 action bar.
Layout
Bottom sheet (phone) / centered dialog ≤ 560 dp (tablet, 00-shared/05). Scrollable
form: 3 required fields first (firstName, lastName, email), then optional groups
(contact, source, status, placement, assignment, notes). Submit = primary filled
button full width; secondary "Cancel".
Data contract
- Create:
POST /crm/leads(crm.controller.ts:47-51) — payload percreate-lead.dto.ts:11-73. - Edit:
PATCH /crm/leads/:id(crm.controller.ts:59-63) — partial fields only; server merges defined fields (crm.service.ts:88-100);versionincrements (base.repository.ts:63). - Duplicate email → 409
DUPLICATE_RESOURCE(crm.service.ts:42-47).
Field table (exact, per DTO)
| Field | Required | Type/validation | Default | Source |
|---|---|---|---|---|
| firstName | ✅ | string | — | create-lead.dto.ts:12-14 |
| middleName | — | string | — | :16-19 |
| lastName | ✅ | string | — | :21-23 |
| ✅ | @IsEmail, lowercased server-side | — | :25-27; lead.schema.ts:52-53 | |
| phone | — | string | — | :29-32 |
| source | — | enum website/referral/walk_in/phone/campaign/other | website | :34-37; lead.schema.ts:58-59 |
| status | — | enum new/contacted/qualified/converted/closed | new | :39-42; lead.schema.ts:61-62 |
| gradeId | — | MongoId | — | :44-47 |
| sectionId | — | MongoId | — | :49-52 |
| academicYearId | — | MongoId | — | :54-57 |
| classId | — | MongoId | — | :59-62 |
| notes | — | string (multiline) | — | :64-67 |
| assignedTo | — | MongoId (staff) | — | :69-72 |
Behaviors
- Create:
statushidden by default (server defaultsnew); exposed under "Advanced" — admission staff may pre-set contacted/qualified. - Status rules (mirror server): setting
convertedvia form is blocked — must use convert flow (server guards,crm.service.ts:120-125); settingclosedwithoutclosedReason→ client shows reason field (server auto-stampsclosedAt,crm.service.ts:94-96). - 409 handling: inline banner "A lead with this email already exists" + actions
"View existing" (navigates to existing lead, requires server lookup
(planned)— no email-search endpoint; today: user edits email) / "Edit details". - 400: per-field errors under fields (
00-shared/06 §5). - Double-submit guard: submit disabled while in-flight (
00-shared/08 §6).
Keyboard & a11y
Email .emailAddress, phone .phone, next-field tab order; labels linked; first
invalid field focused on error; live region for 409 banner.
Motion
Sheet slide-up m-base; error shake; success → close + snackbar "Lead created".
Analytics (proposed)
crm.lead.create.submit|success|duplicate, crm.lead.update.submit|success.
S4 — Convert Lead Dialog (from S2)
Purpose
One-action promotion of a qualified lead to User + Student. Irreversible.
Layout
AppDialog: title "Convert to student?", body summary (name, email, grade/year/class),
warning list of pre-conditions, actions Cancel / Convert (filled, destructive-tone
color.primary). Progress: in-dialog spinner + "Creating user and student record…".
Data contract
POST /crm/leads/:id/convert (crm.controller.ts:71-75).
- 400 "Lead is already converted." (
crm.service.ts:120-122) - 400 "Cannot convert a closed lead." (
crm.service.ts:123-125) - 400 "Lead must have grade, academic year, and class assigned for conversion."
(
crm.service.ts:145-149) - Success: lead becomes
converted,convertedAt,convertedToStudentId(crm.service.ts:161-164); emitsLeadConverted(crm.service.ts:166-173).
Behaviors
- Client pre-check disables Convert with tooltip when a rule fails — server 400s are still handled (rule drift) with the exact server message mapped to i18n.
- No optimistic update (side effects — user/student creation;
00-shared/06 §3.5). - On success: dialog → success check → S2 refreshes showing converted badge + student link; snackbar "Student created".
a11y
Dialog announced; pre-condition list read in order; focus lands on Cancel.
Analytics (proposed)
crm.lead.convert.submit, crm.lead.convert.success, crm.lead.convert.failed(rule).
S5 — Admissions List (/crm/admissions)
Purpose
Application funnel working screen with header stats.
Layout (mobile)
[AppBar: "Admissions"]
[FunnelStatRow: 3-4 stat cards (Submitted | Pending docs | Approved | Conversion rate)]
[Status filter chips: All + 9 statuses, horizontal scroll]
[ListView.builder of AdmissionListTile]
[FAB: New application]
Stat cards source: GET /crm/admissions/stats (crm.controller.ts:97-101) → counts
per status + total + conversionRate (admission.service.ts:227-237). Tiles show
skeleton while loading; failure → tile shows "—" (non-blocking).
Row composition
Name (title), email + submittedAt (subtitle), trailing StatusBadge; document-pending
applications get a small "2 docs missing" hint only where documents list known
(client-side count from documents, admission.schema.ts:124-125 — actual required
set is (planned)).
Data contract
GET /crm/admissions?page&limit&status (crm.controller.ts:103-111); sort fixed
submittedAt: -1 (admission.repository.ts:29).
States & interactions
Same pattern as S1: skeleton/empty/error/offline; status chips server-driven;
pull-to-refresh refreshes list and stats. Realtime (forward-looking): no WS
topic; foreground-resume refetch.
a11y / motion
As S1; stat cards are single semantics labels.
Analytics (proposed)
crm.admissions.list, crm.admissions.filter(status), crm.admissions.stats.view.
S6 — Admission Detail (/crm/admissions/:id)
Purpose
The application record with documents, interview, workflow audit trail, and decidable/convert actions.
Layout (scroll)
- Identity header: name, email, phone,
StatusBadge. - Pipeline card: status, submittedAt, decidedAt (
admission.schema.ts:136-143). - Placement card: grade/academicYear/class (resolved).
- Documents card: rows of
DocumentCard(type chip — tc/marksheet/certificate/ photo/other,admission.schema.ts:27-33— filename, uploadedBy, uploadedAt) +Add documentaction (crm.lead.manage). - Interview card: scheduledAt, mode (online/offline), panel names, feedback
(
admission.schema.ts:69-81);Scheduleaction when none / editable. - Workflow timeline (module
AppTimeline, authoritative audit trail): each stage "from → to" (admission.schema.ts:52-67), approver name, comment, timestamp — read-only, rendered bottom-up. - Conversion card (when converted):
conversion.studentId+convertedAt(admission.schema.ts:83-89) + student link. - Notes card.
- Bottom action bar (contextual):
- status decidable (
ADMISSION_DECIDABLE_STATUSES,admission.schema.ts:20-25):Add document,Schedule interview,Make decision(primary) - status
approved:Convert to student(primary) - closed statuses: no action bar; read-only banner "This application is closed."
- status decidable (
Data contract
GET /crm/admissions/:id (crm.controller.ts:113-117); 404 for missing/cross-tenant.
Behaviors
- Every mutation re-fetches detail (workflow + status authoritative from server).
- Edit admission (PATCH,
crm.controller.ts:119-123) available while open — edits allowed on identity/placement/notes only viaEditaction; closed → 400 (admission.service.ts:257-269). - Decision/interview/document actions route to S8/S9 sheets (below).
a11y / motion
Workflow timeline announced per stage; decision primary button announced with rule
hint when disabled. Motion: section reveal m-base.
Analytics (proposed)
crm.admission.open, crm.admission.document.added, crm.admission.interview.scheduled,
crm.admission.edit.completed, crm.admission.decided.{approve,reject,waitlist},
crm.admission.converted.
S7 — Admission Editor — Create (/crm/admissions/new)
Purpose & layout
Application intake form (office-facilitated). Full-page scroll form (not sheet — longer form), groups: Applicant (firstName*, lastName*, email*, phone) · Placement (gradeId, academicYearId, classId) · Notes. Submit "Submit application".
Data contract
POST /crm/admissions (crm.controller.ts:91-95). Server forces status
submitted + workflow draft → submitted regardless of DTO (admission.service.ts:41-51)
— client shows "Application submitted" success state, not a draft.
Fields (exact, create-admission.dto.ts:4-46)
firstName*, lastName*, email* (@IsEmail), phone?, gradeId?, academicYearId?,
classId?, notes? — all placement refs MongoId.
Behaviors
- 400 per-field errors; 409 n/a (no uniqueness on admissions email today — duplicates allowed; flag as possible gap).
- Success → S5 list with snackbar + new admission visible (sort by submittedAt desc).
Analytics (proposed)
crm.admission.create.submit|success.
S8 — Interview Scheduler (sheet on S6)
Purpose
Set interview slot + panel + mode (+ optional feedback now or later via edit).
Layout
Bottom sheet: AppDatePicker+time (scheduledAt*), panel multi-select chips
(staff list (planned) source: staff module read), mode AppSegmentedButton
online/offline (default offline — admission.schema.ts:76-78), feedback textarea
(optional), actions Cancel / Schedule.
Data contract
POST /crm/admissions/:id/schedule-interview (crm.controller.ts:134-141;
schedule-interview.dto.ts:11-31). Server stores interview + transitions to
interview_scheduled when different (admission.service.ts:130-132).
Behaviors
- Re-schedule allowed while open (overwrites interview doc,
admission.service.ts:124-129). - No optimistic write; success → S6 interview card refreshes + status badge updates.
Analytics (proposed)
crm.admission.interview.scheduled.
S9 — Decision Dialog (on S6)
Purpose
Approve / Reject / Waitlist with optional comment. Terminal for the pipeline —
closed statuses become immutable (admission.service.ts:257-269).
Layout
AppDialog: three action choices (AppSegmentedButton or radio list: Approve /
Reject / Waitlist), comment textarea, warning line per choice
(reject: "Application closes permanently"), Cancel / Confirm decision.
Data contract
POST /crm/admissions/:id/decision (crm.controller.ts:143-147;
admission-decision.dto.ts:10-18). Rules:
- Only from decidable statuses → else 400 with exact message
(
admission.service.ts:141-145). - Sets status +
decidedAt(admission.service.ts:155); workflow stage appended with approver + comment (admission.service.ts:239-255). - Emits
AdmissionApproved/AdmissionRejected(admission.service.ts:158-168); waitlist emits none.
Behaviors
- Client pre-check: dialog only offered when decidable; server 400 mapped to i18n.
- Confirm requires explicit selection; default none.
- Pessimistic; success → S6 status + workflow refresh.
a11y
Warning copy per decision read by screen reader; focus on first choice.
Analytics (proposed)
crm.admission.decided.{approve,reject,waitlist}.
S10 — Campaigns List (/crm/campaigns)
Purpose
Marketing campaign registry.
Layout
AppBar "Campaigns" · ListView.builder of CampaignCard: name (title), type icon +
label, StatusBadge (draft/active/paused/completed — campaign.schema.ts:7-12),
date range, metrics line "12 leads · 3 converted" (from metrics,
campaign.schema.ts:45-52 — values schema-only; write path (planned), show "—"
when absent) · FAB "New campaign".
Data contract
GET /crm/campaigns?page&limit (crm.controller.ts:77-81); sort createdAt: -1
(campaign.repository.ts:25).
States & interactions
Standard skeleton/empty/error/offline; no detail route (no GET by id) — tap opens a read-only info bottom sheet (same data); row is informational. Pull-to-refresh.
Analytics (proposed)
crm.campaigns.list, crm.campaign.open.
S11 — Campaign Editor — Create (/crm/campaigns/new)
Purpose
Register a campaign.
Layout
Bottom sheet: name* (required, create-campaign.dto.ts:5-8), description (multiline),
type dropdown (email/sms/social/print/other, default email — campaign.schema.ts:30),
status dropdown (draft/active/paused/completed, default draft —
campaign.schema.ts:33), startDate + endDate pickers (optional), Cancel / Create.
Data contract
POST /crm/campaigns (crm.controller.ts:83-87; create-campaign.dto.ts:5-34).
No PATCH/GET-by-id — editing a campaign is (planned).
Behaviors
- Date sanity: endDate ≥ startDate (client validation; server stores as-is today — no server check, flag).
- Success → S10 refresh + snackbar.
Analytics (proposed)
crm.campaign.create.submit|success.