06 — Screen Specifications (Organizations Module)
- 0. Global rules for this module
- S1 — Org Overview
- S2 — Org Edit
- S3 — Branding
- S4 — Settings tabs
- S5 — Feature flags
- S6 — Invite admin (members)
- S7 — Tenants list (platform)
- S8 — Tenant detail (+ create/edit)
Per-screen build spec. Tokens (T = 00-shared/02), components (C = 00-shared/03), motion (M = 00-shared/08 §4-5), a11y (A = 00-shared/09), mapping (D = 00-shared/04). Applies to all 8 screens of the module.
(proposed)= client-side validation/behavior.
0. Global rules for this module
- No FAB except S6 (Invite admin). Toolbar actions carry all primary actions.
- Forms >3 fields are full-screen pages (00-shared/05 §5); dialogs only for confirms/guards.
- All mutation buttons: 48 dp height, loading spinner replaces label, anti-double-submit (M §6).
- Light/dark parity, tokens only, no literal colors (
D §7). - Safe areas: bottom nav/insets respected on phone; keyboard avoidance via
Scaffold.resizeToAvoidBottomInset+AppScrollableFormwrapper. - Typography: one
headlineMediumscreen title per screen (A §11); sectionstitleMedium. - Analytics
(proposed)per 05.
S1 — Org Overview
Header: AppBar (T: 64, surface, titleLarge) — title "Organization", actions: Edit (IconButton + tooltip "Edit organization", shown iff organization.update), Branding (IconButton + tooltip). Hero: AppAvatar logo 64 (fallback initials from name, C §C), name headlineMedium, chips row: status AppBadge (primaryContainer when active, tertiary when onboarding, error when suspended — always icon+label, A §9), plan AppBadge (filled for premium/enterprise, tonal for free/basic).
Onboarding banner: AppBanner severity info, dismissible=false while status === onboarding (organization.schema.ts:122-127): "Finish setting up — configure academic year, attendance rules and branding." → action "Open settings".
Sections (cards): AppCard flat e-0, padding 16, gap 12, section gap 24 (T §3). Each section: AppSectionHeader (titleMedium) + AppInfoRow rows (label bodyMedium onSurfaceVariant, value bodyMedium onSurface). Rows: Identity (name/slug/domain — slug/domain in mono font, T §2), Contact (email/phone/website — email as TextButton link), Address (5 rows), Localization (timezone/currency), Academic year (formatted via Intl locale — e.g., "2026-04-01 → 2027-03-31", month "April"), Subscription (plan + status badges), shortcuts: Settings (AppListTile trailing chevron → S4), Feature flags (AppListTile trailing chevron + count badge → S5), Members (AppListTile → S6, rbac.member.read).
States:
- Loading: header skeleton (circle 64 + lines) + 4 card skeletons (
AppSkeleton list, shimmerm-slow, reduced-motion static, A §8). - Error:
AppErrorState(code, message, onRetry); 404 → copy "Organization not found" + reload action; 5xx → generic + requestId hint (C §A). - Offline: cached org +
AppOfflineBanner; stale badge "Updated {time}". - Empty (no data anomaly): treated as 404.
Transitions: screen enter slide-forward m-base; hero logo fade-rise m-slow; skeleton→content cross-fade m-slow (M §4).
Responsive: phone 1-col; tablet ≥600 dp 2-col info grid; desktop ≥1200 dp 3-col grid + breadcrumb "Organization".
A11y: chips read "Status: active", "Plan: premium" (never color-only); logo Semantics("Logo of {name}"); all rows focusable buttons with labels.
Micro-interactions: row tap ripple m-fast; badge scale-pop on status change (M §5).
Analytics: organizations.overview.view, organizations.overview.edit.tap, organizations.overview.branding.tap.
S2 — Org Edit
Header: AppBar "Edit organization", leading back (pop with unsaved guard), no other actions. Body: scrollable form groups (labels bodyMedium; fields gap 16, T §3). Group 1 Identity: name (required), slug (mono, helper "Used for tenant registration; changing it affects future sign-ups" — organizations.service.ts:107-113), domain (mono, helper "Must be unique across all institutions"). Group 2 Contact: email (emailAddress keyboard, @IsEmail server — update-organization.dto.ts:83-86), phone (phone keyboard), website (url keyboard). Group 3 Address: street, city, state, country, zip (all IsString — :89-94). Group 4 Localization: timezone (readonly field → searchable bottom sheet, AppSearchBar + list, returns IANA name; server default 'UTC' — organization.schema.ts:69-70), currency (sheet: code + symbol, default USD — :72-73). Group 5 Academic year: startDate/endDate (AppDatePicker, min < max validation (proposed)), month (1–12 number (proposed)). Group 6 Subscription: plan AppDropdown (free/basic/premium/enterprise — :6-11), status AppDropdown (active/inactive/suspended/trial — :13-18) — read-only when user lacks billing perms (proposed), edit disabled + lock icon.
Primary CTA: bottom-pinned "Save changes" AppButton filled fullWidth; loading spinner; disabled until dirty. Secondary: "Cancel" (text) → unsaved guard.
Dialogs: unsaved-changes (title "Discard changes?", actions Discard / Keep editing); slug change confirm (warning copy above). Snackbar: "Changes saved" (<3 s, success icon); error rollback snackbar (≥4 s).
Validation (client, (proposed) unless server-tested): name non-empty (server: @IsString only — update-organization.dto.ts:62-65); slug lowercase alnum+hyphen regex ^[a-z0-9]+(?:-[a-z0-9]+)*$ matching slugify output (organizations.service.ts:161-169); domain lowercase FQDN; email emailAddress; zip/phone free text. Server re-validates: 409 → inline on slug/domain fields (scroll-to-field), 400 → per-field details (envelope details[], http-exception.filter.ts:103-107).
Keyboard/touch: TextInputAction.next between fields, done on last; autofill hints (name, email, address-*, postal-code, tel) per A §10; keyboard avoidance; tablet/desktop 2-col field grid (label-above layout); Enter submits from text fields (M §2).
A11y: focus moved to first invalid field on failed submit (A §10); errors announced.
Animations: section entrance stagger m-entrance 40 ms; error highlight shake-free (color+icon only).
Analytics: organizations.edit.save.success|error, organizations.edit.slug.conflict.
S3 — Branding
Header: AppBar "Branding". Body: two-column on tablet/desktop (editor | live preview).
Editor column: Primary color (AppColorPicker — swatch grid + hex AppTextField [0-9A-Fa-f]{6}, (proposed) widget), Secondary color (same), Logo upload (AppAttachmentTile "school_logo.png · uploading 40%" states: idle/uploading/success/error+retry; upload → storage provider (planned), sets logoFileId/branding.logo — organization.schema.ts:114-120), Favicon upload (optional, same tile).
Preview panel: AppCard e-2 simulating: app bar tinted with primaryColor, button samples (filled/tonal), text swatches, contrast badge computing WCAG ratio vs white/black ((proposed) — warn <4.5:1, A §2), logo render. Branding override model: org primary overrides theme seed; all other roles derive (02_Design_Tokens.md §1).
Primary CTA: "Save branding" → PATCH :id {branding} (update-organization.dto.ts:125-128; server: unvalidated Record<string,unknown> — client controls shape).
States: preview updates instantly on change (m-fast color transitions); save spinner; upload error inline; offline: colors editable locally, upload blocked, save blocked with banner.
A11y: hex fields label "Primary color hex"; picker swatches have tooltip + name; contrast warning announced; color-blind: also shows hex text (A §9).
Analytics: organizations.branding.preview, organizations.branding.save.success|error.
S4 — Settings tabs
Header: AppBar "Organization settings" + global actions: "Save all" (primary, enabled iff any tab dirty). Below: AppTabs (General | Attendance | Academic | Grading | Notification | Theme), TabBarView keep-alive (00-shared/03 AppTabs), indicator primary (D §2).
Attendance tab (embedded settings — update-organization-settings.dto.ts:8-12): gracePeriod (number, suffix "min", (proposed) 0–300), lateThreshold (number, min), halfDayThreshold (number, min), workingDays (multi-select day chips AppChips Sun–Sat, values 0–6, (proposed) ≥1 required), helper "Determines how attendance is counted as late / half-day".
Academic tab: gradingScale (text, (proposed) presets A+/A/B+…, free text allowed), passingPercentage (number 0–100).
Theme tab: primaryColor/secondaryColor (hex fields — mirrors branding; saved into settings.theme).
General / Grading / Notification tabs: read-only reference lists from the standalone settings collection (GET /settings?group=…, settings.controller.ts:24-29) — "managed by module settings" copy; upsert via PUT /settings/PUT /settings/bulk is reserved for module-level config screens (cross-module).
Save model (critical): "Save all" submits the full merged settings object {attendance, academic, theme} to PATCH :id/settings (full-replace — organizations.service.ts:134). Dirty tab indicator (dot on tab, secondaryContainer); per-tab revert (discard) action; after save all tabs clean + snackbar "Settings saved".
States: per-tab skeleton while loading; global save spinner; 400 → field errors on tab; 409/404 → snackbar.
Dialogs: none (forms ≤5 fields per tab; save is global). Sheets: none.
Keyboard/landscape: numeric fields number keyboard; landscape = wider 2-col; desktop = max-width 720 form column.
A11y: chips toggle state announced (Semantics(toggled:)); numeric fields announce units in helper; tab dirty dot read as "unsaved".
Animations: tab indicator m-base; dirty-dot scale-pop; save success check + snackbar (M §5).
Analytics: organizations.settings.tab.view.{tab}, organizations.settings.save.success|error.
S5 — Feature flags
Header: AppBar "Feature flags" + info IconButton (tooltip "Flags apply to this institution only"). Body: grouped list by module (from catalog GET /feature-flags; keys absent from catalog still shown from map under "Other"). Section header + row: AppListTile title = label ?? key, subtitle = description (mono key chip), trailing AppSwitch (disabled iff no feature-flags.update).
Merge logic: catalog ∪ map; enabled = map[key] ?? (catalog only? false). Toggle → optimistic flip (00-shared/06 §3.5) → full-map PATCH :id/feature-flags (organizations.service.ts:144-154) → reconcile with server map; failure → rollback + snackbar "Couldn't update — retry" (with Retry action). Row delete (standalone flag only): AppMenu → "Delete flag" → confirm dialog → DELETE /feature-flags/:key (feature-flags.controller.ts:54-58).
Empty state: AppEmptyState "No feature flags yet — configure one in the platform catalog" (only when both lists empty).
States: skeleton rows; loading toggle spinner on the row switch (brief); offline: toggles disabled + banner (no offline queue).
Section expand/collapse: AnimatedSize m-base, chevron rotate; sections default expanded.
A11y: switch label = "Enable {flag label}"; result announced via live region; mono keys read as-is.
Analytics: organizations.flags.toggle.{key}.on|off, organizations.flags.delete.
S6 — Invite admin (members)
Header: AppBar "Team & invites". FAB: AppFAB.extended "Invite admin" (heroTag: 'org-invite'), hidden on scroll-down (C §D).
Body: section "Members" — list rows (AppListTile leading AppAvatar initials, name, email, role chips, menu). Menu: "Change roles" (sheet multi-select chips from GET /rbac/roles — rbac.controller.ts:27-31; save → PATCH /rbac/members/:id :69-73), "Remove" (dialog → DELETE /rbac/members/:id :75-79).
Invite sheet (full-screen page, >3 fields): firstName, lastName, email (emailAddress), phone (optional), roles (chips, default org_admin), CTA "Send invitation". Submission: create user (users.controller.ts:36-40 — Users module contract) then POST /rbac/members (rbac.controller.ts:63-67). No email/token invite exists (OQ-2) — copy "They'll sign up at register with tenant slug" ((forward-looking) invite email).
States: list skeleton; invite spinner; 409 duplicate email inline; empty "No members yet".
Responsive: phone = page push; tablet+ = dialog (≤ 5 fields? use sheet half-height on tablet).
A11y: FAB label "Invite admin" (A §7); role chips announce selection.
Analytics: organizations.members.invite.submit, organizations.members.role.change, organizations.members.remove.
S7 — Tenants list (platform)
Header: AppBar "Tenants" + count subtitle "12 tenants" (from meta.totalItems). Toolbar row: AppSearchBar (debounce 300 ms, clear suffix), sort AppDropdown (AppMenu): Newest (-createdAt, default), Oldest, Name A–Z, Name Z–A (maps to sort param, pagination-query.dto.ts:21-24), status filter AppChips (All/Active/Onboarding/Suspended/Inactive — (proposed) filter, no server param today; client-side or future status= query).
List: ListView.builder rows 72 dp: logo 40, name (titleMedium), slug (bodySmall mono), status AppBadge + plan badge, created date (bodySmall), trailing AppMenu (View / Edit / Suspend·Activate / Delete). Infinite scroll: bottom spinner fade m-instant, "End of list" footer bodySmall (meta.hasNext — pagination-query.dto.ts:52). Pull-to-refresh bypasses cache (00-shared/06 §3.3).
States: skeleton list (first load); error AppErrorState + retry; empty AppEmptyState ("No tenants match your search"); offline last-good + banner; permission (non-platform) → 403 screen (route guard).
Tablet/desktop: master-detail — row tap loads S8 in right pane (≥840 dp, 00-shared/05 §3); hover row tint m-fast; keyboard: N new tenant, arrows navigate rows (M §2).
Dialogs: suspend/activate confirm (AppDialog with consequence copy), delete = typed confirm.
Analytics: organizations.tenants.list.view, organizations.tenants.search, organizations.tenants.filter.status.
S8 — Tenant detail (+ create/edit)
Header: AppBar back + tenant name; actions: Edit (organization.update-equivalent, platform), Status menu (Activate/Suspend), Delete (destructive icon). Body reuses S1 card set (id-driven) + status/plan switchers (dropdowns → PATCH :id status/subscriptionStatus/subscriptionPlan — update-organization.dto.ts:115-133).
Delete flow: AppDialog → typed confirm (type org name; 00-shared/05 §5) → spinner → DELETE :id (organizations.service.ts:156-159) → snackbar "Tenant deleted — data purged after 30 days" (worker tenant-purge.worker.ts:32-42) → back to list, row removed.
Create (/admin/tenants/new): full-screen form per CreateOrganizationDto (create-organization.dto.ts:57-112): name*, slug (optional, live-preview of slugify result — organizations.service.ts:38), domain, contact block, address block, timezone, currency, plan, branding (skip link), metadata (JSON field (proposed)). CTA "Create tenant" → success → tenant detail + handoff banner ("Share slug: springfield-academy — the admin registers with this slug").
States: detail skeleton; save spinners; 409 slug/domain inline; create success haptic lightImpact (M §3); error states standard.
Analytics: organizations.tenants.create.success|error, organizations.tenants.status.change, organizations.tenants.delete.confirmed.