Browse documentation

Frontend Specs

RankFlow AI — Frontend Specification Part 3

- Section 9 — Content & Leads Dashboard

docs/specs/frontend/part-3.md
On this page

Version: 1.0.0 Date: 2026-06-13 Scope: Sections 9–12 — Client Dashboards (Content, Leads, Reports, Settings) + Admin Dashboards (KPI, Client Management, Infrastructure) Author: Senior Frontend Specification Writer Stack: Next.js 14 + tRPC + Prisma + Tailwind CSS + shadcn/ui Design Tokens: docs/brand/tokens/design-tokens.json Source Documents: admin-dashboard-ui.md, business_flow_map.md, backend-api.md, TEST-admin-observability.md, job-queue-workflows.md, ai-services.md


Design Token Compliance#

All UI components in this specification must adhere to the RankFlow design token system (docs/brand/tokens/design-tokens.json). Critical constraints:

Constraint Rule
Typography weight No bold weight (font-weight: 700 or font-bold) anywhere. Maximum allowed: font-weight: 600 (semibold) for headings only. Body text: 400 or 500.
Ember Orange #EC652B reserved exclusively for CTAs, primary action buttons, focus rings, booked states, and outcome indicators. Never used for decorative backgrounds or large surfaces.
Gradients No blue-purple gradients. No radial gradients. Subtle linear gradients are permitted only in chart fills (e.g., area chart underlay) using series-1 (#167E6C) to transparent.
Primary surface Paper-white #F6F6F8 for canvas. Card-white #FFFFFF for elevated surfaces.
Typography Suisse Intl family, 400/500 weights. Deep-ink #011821 for primary text.
Spacing Token-based: 4px grid (space.1 through space.36). Card padding: space.6 (24px). Element gap: space.3 (12px).
Radius border-radius.lg (8px) for cards, inputs, buttons. border-radius.full (9999px) for badges. border-radius.sm (2px) for table cells.
Shadows shadow.subtle for cards. shadow.sm for dropdowns. shadow.xl for modals. No custom shadows.
Icons Lucide family, stroke-width: 1.5px, size-md: 20px.

Section 9 — Content & Leads Dashboard#

9.1 Content Dashboard (/dashboard/content)#

Layout: Client shell (src/app/(dashboard)/layout.tsx) with sidebar navigation. Main content area uses full-width table + split-pane editor.

Role Access: CLIENT (full CRUD), EDITOR (approve/reject/edit, no delete), VIEWER (read-only).


9.1.1 Content List Screen (/dashboard/content)#

1. Screen Name & Route + Role Access

Route Component Role Access
/dashboard/content ContentListPage CLIENT (full), EDITOR (edit/approve only), VIEWER (read-only)

2. Data to Show

Field Type Format Source
id string CUID content.list
title string 1–200 chars, truncated at 48 chars in table content.list
type enum GBP_POST / SOCIAL_POST / LANDING_PAGE_ARTICLE / FAQ / BLOG_POST / REVIEW_REPLY content.list
status enum DRAFT / PENDING_REVIEW / APPROVED / PUBLISHED / REJECTED / FAILED content.list
createdAt Date DD MMM YYYY (e.g., 13 Jun 2026) content.list
scheduledFor Date DD MMM YYYY, HH:MM or if null content.list
author string AI (with provider icon) or user name content.list (aiGenerated, aiProvider)
aiProvider string anthropic / openai / null content.list
costUsd number $0.000 to $9.999, rounded to 3 decimals content.list
seoScore number 0–100, integer content.list
readabilityScore number 0–100, integer content.list
autoPublishAt Date Countdown target for PENDING_REVIEW content.list
practiceId string CUID (implicit via practiceProcedure) content.list

Status Badge Color Mapping (Design Token Compliant)

Status Badge Color Token Text
DRAFT Gray color.semantic.text-disabled (#A9ACB6) on color.semantic.surface-canvas Draft
PENDING_REVIEW Amber color.semantic.action-accent (#EC652B) on color.semantic.text-inverse Pending Review
APPROVED Green color.semantic.success (#167E6C) on color.semantic.text-inverse Approved
PUBLISHED Blue color.semantic.info (#023247) on color.semantic.text-inverse Published
REJECTED Red color.semantic.error (#C2442A) on color.semantic.text-inverse Rejected
FAILED Red color.semantic.error (#C2442A) on color.semantic.text-inverse Failed

3. Display Pattern

  • Component type: DataTable (src/components/admin/DataTable.tsx) adapted for client dashboard with reduced density.
  • Layout: Full-width table (w-full) within page-max-width (1200px) container. Top filter bar (FilterBar) with left-aligned filters and right-aligned search.
  • Table columns: 8 columns with responsive hiding: on tablet (<1024px), hide costUsd and seoScore; on mobile (<768px), render card list view (CardList component) with vertical stacking.
  • Row height: 56px. Alternating row background: surface-canvas and surface-card.
  • Bulk selection: Checkbox column (first). Shift-click for range select. indeterminate state on header checkbox.
  • Empty state: EmptyState component with FileText icon (Lucide), title "No content yet", description "AI-generated content will appear here once your first posts are created.", primary CTA "View Approval Queue" (if queue items exist) or ghost CTA "Learn More".
  • Pagination: 25 rows per page default. Page size selector: [10, 25, 50, 100]. Bottom-aligned pagination bar.

4. Backend Endpoint

// tRPC query
content.list

// Input schema (Zod)
const listContentSchema = z.object({
  status: z.enum(["DRAFT", "PENDING_REVIEW", "APPROVED", "PUBLISHED", "REJECTED", "FAILED"]).optional(),
  type: z.enum(["GBP_POST", "SOCIAL_POST", "LANDING_PAGE_ARTICLE", "FAQ", "BLOG_POST", "REVIEW_REPLY"]).optional(),
});

// Auth: practiceProcedure (CLIENT, EDITOR, VIEWER roles)
// Returns: ContentPiece[]

5. Fetch Frequency

Event Interval Strategy
Initial load Once useQuery with staleTime: 60_000
Polling 60 seconds refetchInterval: 60_000
Window focus Event-driven refetchOnWindowFocus: true
After mutation Immediate utils.content.list.invalidate()

6. Data Manipulations

Manipulation Logic Implementation
Client-side search Filter title by case-insensitive substring match useMemo with debounce(300ms) on search input
Status filter Exact match on status enum Dropdown filter; multi-select NOT supported (single status only)
Type filter Exact match on type enum Dropdown filter; multi-select NOT supported
Date range filter createdAt >= startDate && createdAt <= endDate DateRangePicker component; client-side filtering after fetch
Sort by date Descending createdAt default; toggle ascending Table header click with sortState
Sort by title Alphanumeric ascending Table header click
Bulk actions Array of selected ids passed to mutation useState<string[]>(selectedIds)
AI provider icon Map anthropicBrain, openaiSparkles (Lucide) Client-side mapping

7. Why Structured This Way

  • Polling at 60s: Content status changes frequently as AI generation completes and approval timers expire. 60s balances freshness with server load. staleTime prevents redundant fetches during active editing.
  • Client-side search/filtering: The content volume per practice is bounded (< 200 items/month). Client-side filtering is faster than round-trip API calls and avoids tRPC query key complexity.
  • Card list on mobile: Tables are unreadable on mobile. Card view shows title, status badge, and date in a vertical stack with swipe actions.
  • No bold weight: Title column uses font-weight: 500 (medium) at font-size: body-sm (14px) for visual hierarchy without bold.

8. Interaction Flows

User lands on /dashboard/content
  → content.list query fires
  → Skeleton table renders (8 rows × 6 columns)
  → Data resolves → table populates

User types in search box
  → debounce 300ms
  → client-side filter applies
  → row count updates in pagination

User clicks status filter "PENDING_REVIEW"
  → filter applied client-side
  → only PENDING_REVIEW rows visible
  → bulk action bar appears if rows > 0

User selects 3 rows via checkboxes
  → bulk action bar slides in (bottom-fixed on mobile, inline on desktop)
  → actions: "Approve All", "Delete All" (CLIENT only)

User clicks "Approve All"
  → ConfirmDialog opens: "Approve 3 items?"
  → On confirm: loop through selectedIds, call content.approve.mutate(id)
  → invalidate content.list on each success
  → Toast: "3 items approved"

User clicks row title
  → navigates to /dashboard/content/[id] (Content Editor)

9. Error States

State Trigger UI Treatment Recovery
Loading Initial fetch, refetch SkeletonTable (8 rows, 6 columns) Auto-resolves on data
Empty content.list returns [] EmptyState with icon + text + CTA N/A — valid state
Filter empty Active filters match 0 rows EmptyState with "No content matches filters" + "Clear Filters" button Click clears all filters
tRPC error Network/server failure Inline error banner: "Failed to load content." + "Retry" button (ember-orange) Click retry re-fetches
Partial error Bulk action: 2/3 succeed Toast: "2 approved, 1 failed" + link to failed item Manual retry on failed item

10. Role-Based Variations

Role What They See What They Can Do
CLIENT Full table, all action buttons, bulk actions, delete option Approve, reject, edit, delete, bulk approve, bulk delete, bulk schedule
EDITOR Full table, approve/reject/edit buttons, no delete column Approve, reject, edit. Cannot delete. Bulk approve only. Cannot bulk delete.
VIEWER Full table, no action buttons, no checkboxes, no bulk bar Read-only. No inline actions. No edit access.

9.1.2 Content Editor Screen (/dashboard/content/[id])#

1. Screen Name & Route + Role Access

Route Component Role Access
/dashboard/content/[id] ContentEditorPage CLIENT (full), EDITOR (edit/approve), VIEWER (read-only preview)

2. Data to Show

Field Type Format Source
id string CUID content.get
title string Editable in textarea content.get
content string HTML/Markdown rendered in preview content.get
status enum Current state machine position content.get
type enum Determines preview template content.get
seoTitle string 50–60 chars, editable content.get
seoDescription string 150–160 chars, editable content.get
focusKeywords string[] Array of tags, add/remove content.get
aiGenerated boolean Determines if AI badge shown content.get
aiProvider string Badge icon + name content.get
aiModel string Sub-label under provider content.get
aiTokensUsed number Integer with comma separator content.get
costUsd number $0.000 format content.get
seoScore number 0–100, color-coded content.get
readabilityScore number 0–100, color-coded content.get
humanEdited boolean Timestamp if true content.get
createdAt Date DD MMM YYYY, HH:MM content.get
updatedAt Date DD MMM YYYY, HH:MM content.get
autoPublishAt Date Countdown timer display content.get
scheduledFor Date Calendar picker default content.get
ctaType enum BOOK / CALL / LEARN_MORE / SIGN_UP / ORDER content.get + gbp.createPost schema

AI Intelligence Score Badge

Score Range Badge Color Label
90–100 series-1 (#167E6C) Excellent
70–89 series-4 (#44B48B) Good
50–69 series-2 (#7EA7E9) Average
0–49 series-3 (#9F7AEE) Needs Work

The intelligence score is computed client-side as a weighted average:

intelligenceScore = (seoScore * 0.4) + (readabilityScore * 0.4) + (humanEdited ? 10 : 0) + (focusKeywords.length > 0 ? 10 : 0);

3. Display Pattern

  • Component type: Split-pane layout (ResizablePanelGroup from shadcn/ui).
  • Layout: Left pane (60% default, min 40%, max 75%) contains editor; right pane contains preview.
  • Editor pane:
    • Textarea for title (2 rows, max 200 chars).
    • RichTextEditor (TipTap or Plate) for content body. Toolbar: bold (note: backend stores <strong>, but UI button uses font-weight: 600 semibold icon), italic, link, heading, bullet list, numbered list.
    • Input for SEO title (max 60 chars, char counter).
    • Textarea for SEO description (max 160 chars, char counter).
    • TagInput for focus keywords (max 10 tags, comma-separated entry).
    • Select for CTA type: options from gbp.createPost schema enum: BOOK, CALL, LEARN_MORE, SIGN_UP, ORDER.
  • Preview pane:
    • iframe or div with dangerouslySetInnerHTML for rendered preview. CSS scoped to prevent global leakage.
    • Template varies by type: GBP_POST → GBP card preview; SOCIAL_POST → Instagram/Facebook card preview; LANDING_PAGE_ARTICLE → article preview.
    • Mobile preview toggle: desktop / tablet / mobile buttons above iframe.
  • Status timeline: Vertical stepper below editor showing state machine: DRAFTPENDING_REVIEW (with timer) → APPROVEDPUBLISHED. Current step highlighted with series-1 (#167E6C).
  • Timer display: For PENDING_REVIEW, countdown widget showing HH:MM:SS until autoPublishAt. Timer color: series-2 (#7EA7E9) if > 4h, action-accent (#EC652B) if < 4h, error (#C2442A) if < 1h.
  • Action bar: Fixed bottom bar with primary actions: Save (ghost), Approve & Publish (accent, ember-orange), Reject (error), Schedule (info). On mobile: collapsible into floating action button (FAB) menu.

4. Backend Endpoint

// tRPC query
content.get

// Input schema
const getContentSchema = z.object({
  id: z.string(),
});

// Auth: practiceProcedure
// Returns: ContentPiece

// tRPC mutation: update
content.update

// Input schema
const updateContentSchema = z.object({
  id: z.string(),
  title: z.string().optional(),
  content: z.string().optional(),
  seoTitle: z.string().optional(),
  seoDescription: z.string().optional(),
  focusKeywords: z.array(z.string()).optional(),
});

// tRPC mutation: approve
content.approve

// Input schema
const approveContentSchema = z.object({
  id: z.string(),
});

// tRPC mutation: reject
content.reject

// Input schema
const rejectContentSchema = z.object({
  id: z.string(),
  reason: z.string().optional(),
});

5. Fetch Frequency

Event Interval Strategy
Initial load Once useQuery with content.get({ id })
Timer refresh Every 1 second setInterval for countdown display (client-side only)
Content save On mutation Invalidate content.get and content.list
Background refresh 60 seconds refetchInterval: 60_000 for status updates from AI pipeline

6. Data Manipulations

Manipulation Logic Implementation
Timer calculation autoPublishAt - Date.now() Client-side setInterval(1000) with useState
Timer expiry auto-approve When countdown reaches 0 If status === PENDING_REVIEW and timer expires, client auto-calls content.approve mutation. Server also has cron fallback.
Medical compliance timer 24h mandatory for CLINIC/HOSPITAL/DOCTOR Client reads practice.type from practice.get. If medical, timer minimum is 24h. Timer display shows "Medical compliance: 24h required".
Review star auto-rules Auto-approve: 4-5 stars; Manual: 1-2 stars; 3-star: 24h timer Client-side logic: if type === REVIEW_REPLY and reviewRating present, show appropriate badge and timer rules.
CTA dropdown Options from gbp.createPost schema enum Hardcoded array mapped to schema: [{ value: "BOOK", label: "Book Appointment" }, ...]
Intelligence score Weighted average (formula above) Client-side compute on every render
Char counter Real-time length vs max Input + span showing ${current}/${max}
HTML preview sanitization DOMPurify on content before dangerouslySetInnerHTML DOMPurify.sanitize(content, { ALLOWED_TAGS: [...] })

7. Why Structured This Way

  • Split pane: Content editors need immediate visual feedback. The 60/40 split is the industry standard for CMS interfaces. Resizable panels accommodate different screen sizes and personal preferences.
  • Medical compliance 24h timer: Indian medical advertising regulations (MCI guidelines) require human oversight for all patient-facing content. The 24h gate is non-negotiable for CLINIC/HOSPITAL/DOCTOR types. The timer is client-displayed but server-enforced (the server cron also auto-approves after 24h).
  • Auto-approve by star rating: 4-5 star reviews are low-risk; auto-approval reduces friction. 1-2 star reviews are high-risk and require manual oversight. 3-star reviews are ambiguous and get the 24h window.
  • Timer resets on edit: Editing content resets the approval timer because the content has changed and requires re-review. This is communicated via a toast: "Timer reset: content modified."
  • Rich text editor: Medical content often requires formatting (bullet lists for services, links for bookings). TipTap provides a lightweight, extensible solution.

8. Interaction Flows

User navigates to /dashboard/content/content_123
  → content.get({ id: "content_123" }) fires
  → Split pane renders with editor (left) and preview (right)
  → Status timeline shows current position
  → If PENDING_REVIEW: countdown timer starts (1s interval)

User edits title
  → onChange updates local state
  → Preview pane re-renders with new title
  → "Unsaved changes" indicator appears in action bar

User clicks "Save"
  → content.update.mutate({ id, title, content, seoTitle, seoDescription, focusKeywords })
  → Optimistic UI: no loading state on content itself
  → On success: toast "Saved"; timer resets if status was PENDING_REVIEW
  → On error: toast "Save failed"; content reverts to server state

User clicks "Approve & Publish"
  → If medical client and timer < 24h: block with modal "Medical compliance requires 24h review."
  → If non-medical or timer >= 24h: confirm dialog "Publish now?"
  → On confirm: content.approve.mutate({ id })
  → Status transitions to APPROVED → PUBLISHED (server pushes to platform)
  → Toast: "Published to Google Business Profile"
  → Redirect to /dashboard/content

User clicks "Reject"
  → Modal opens with textarea for rejection reason
  → User enters reason, clicks "Reject"
  → content.reject.mutate({ id, reason })
  → Status transitions to REJECTED
  → Toast: "Content rejected. AI will regenerate."
  → Redirect to /dashboard/content

Timer reaches 0:00:00
  → Client auto-fires content.approve.mutate({ id })
  → If mutation fails, show error banner: "Auto-approve failed. Please approve manually."
  → Server cron will retry within 5 minutes as fallback

9. Error States

State Trigger UI Treatment Recovery
Loading content.get pending Skeleton split pane: left shows 6 text skeletons, right shows 1 large skeleton Auto-resolves
Not found id doesn't exist Full-page error: "Content not found" with back link to /dashboard/content Navigate back
tRPC error content.get fails Error banner in editor pane: "Failed to load content." + Retry button Retry re-fetches
Save conflict Concurrent edit detected Toast: "Content was modified by another session. Reload?" + Reload button Click reload refreshes
Timer expiry failure Auto-approve mutation fails Banner: "Auto-approve failed." + "Approve Manually" button (ember-orange) Manual approve
Medical compliance block Approve clicked before 24h Modal: "Medical compliance requires 24h review." + "Understood" button Wait for timer
HTML sanitization error DOMPurify rejects content Fallback: render as plain text in <pre> tag Edit content to fix

10. Role-Based Variations

Role Editor Access Actions Available Timer Visibility
CLIENT Full read/write on all fields Save, Approve, Reject, Schedule, Delete Full countdown + manual override (if non-medical)
EDITOR Edit title/content/SEO fields Save, Approve, Reject. No delete. No schedule override. Full countdown. Cannot override medical timer.
VIEWER Read-only. Editor pane disabled. None. Preview only. Read-only timer display. No action buttons.

9.1.3 Content Calendar Screen (/dashboard/content/calendar)#

1. Screen Name & Route + Role Access

Route Component Role Access
/dashboard/content/calendar ContentCalendarPage CLIENT (full), EDITOR (view + reschedule), VIEWER (read-only)

2. Data to Show

Field Type Format Source
id string CUID Derived from content.list
title string Truncated to 30 chars in card content.list
type enum Determines card color content.list
status enum Determines card border content.list
scheduledFor Date Card position on calendar grid content.list
platform string Icon on card (GBP, Instagram, Facebook, LinkedIn, Twitter) Derived from type + socialAccount.platform

Platform Color Coding

Platform Card Border Token
GBP series-1 (#167E6C) Green
Instagram series-3 (#9F7AEE) Lavender
Facebook series-2 (#7EA7E9) Sky blue
LinkedIn series-5 (#023247) Midnight teal
Twitter/X series-4 (#44B48B) Mint
Blog slate (#7C7F88) Slate

3. Display Pattern

  • Component type: Custom calendar grid (CalendarGrid) + day cells.
  • Layout: Full-width. Header row: month/year selector (left), view toggle Week | Month (right), "Today" button (center-right).
  • Month view: 7-column grid (Mon–Sun). Day cells are 120px min-height. Each cell shows day number (top-right, font-size: caption, text-tertiary).
  • Week view: 7-column grid with 30-minute time slots (08:00–20:00 IST). Vertical scroll.
  • Cards: Rounded radius.lg (8px), 1px left border (4px thick) using platform color. White background. Title text font-size: caption, single line, truncated. Status dot (8px circle) top-right of card.
  • Drag-drop: Cards are draggable. On drag start, card opacity reduces to 60%. On drop over a day cell, cell background changes to info-soft (#C1E8EF). On drop, content.update mutation fires with new scheduledFor.
  • Today indicator: Current day cell has background-color: info-soft (#C1E8EF) with "Today" label in top-left.
  • Empty state: For days with no content, cell shows dashed border (border-dashed) and "+" icon on hover (CLIENT/EDITOR only). Clicking opens ContentCreateModal pre-filled with that date.
  • Overflow: If a day has > 3 items, show "+2 more" link that expands the cell or opens a modal with that day's items.

4. Backend Endpoint

// No dedicated calendar endpoint exists in backend-api.md.
// Frontend uses content.list with client-side filtering and grouping.

content.list

// Input: { status?: "PENDING_REVIEW" | "APPROVED" | "PUBLISHED" }
// Auth: practiceProcedure
// Returns: ContentPiece[]

// Frontend filters: items with scheduledFor != null
// Frontend groups: by date (YYYY-MM-DD)
// Frontend sorts: by scheduledFor ascending

Backend gap: content.getCalendar endpoint does not exist. Frontend implements calendar view by filtering content.list results. Recommended backend addition: content.getCalendar({ month, year }) returning pre-grouped data.

5. Fetch Frequency

Event Interval Strategy
Initial load Once content.list with status: undefined (all statuses)
Polling 5 minutes refetchInterval: 300_000
After reschedule Immediate Invalidate and re-fetch
Month navigation On change Filter existing client-side data; no new fetch if month already loaded

6. Data Manipulations

Manipulation Logic Implementation
Date grouping Group by YYYY-MM-DD useMemo with Map<string, ContentPiece[]>
Time slot mapping Map scheduledFor to 30-min slot Math.floor(minutes / 30) * 30
Drag-drop reorder Update scheduledFor to target date onDragEndcontent.update.mutate({ id, scheduledFor: newDate })
Month filtering Filter content.list by month new Date(item.scheduledFor).getMonth() === currentMonth
Platform color Map type to platform color Client-side object map
Overflow count If items.length > 3, show +${n} items.length - 3

7. Why Structured This Way

  • 5-minute polling: Calendar data changes less frequently than the content list. Posts are scheduled days/weeks in advance. 5 minutes is sufficient.
  • Client-side month filtering: Fetching all content items once per practice (bounded by plan limits: Starter=4 GBP/mo, Standard=8/mo, Premium=12/mo, Enterprise=20/mo) is lightweight. Month navigation is instant without API round-trips.
  • Drag-drop rescheduling: Direct manipulation is faster than opening an edit modal and changing a date field. The visual grid provides spatial context for scheduling density.
  • Platform color coding: At-a-glance identification of content mix. Medical clients need to see if they have sufficient GBP vs. social content.
  • 30-minute slots: Social posts and GBP posts are scheduled at specific times (e.g., 9:00 AM, 10:00 AM). 30-minute granularity provides enough precision without clutter.

8. Interaction Flows

User navigates to /dashboard/content/calendar
  → content.list fires (all content with scheduledFor)
  → Calendar grid renders with current month
  → Cards positioned on their scheduledFor dates

User clicks "Month" → "Week" toggle
  → View switches to 7-day week view with time slots
  → Same cards repositioned vertically by time

User drags a card from June 15 to June 20
  → Card follows cursor with 60% opacity
  → June 20 cell highlights with pale-cyan background
  → On drop: content.update.mutate({ id, scheduledFor: "2026-06-20T09:00:00Z" })
  → Optimistic UI: card moves immediately
  → On success: toast "Rescheduled to June 20"
  → On error: card snaps back to original position; toast "Reschedule failed"

User clicks empty day cell
  → If CLIENT/EDITOR: ContentCreateModal opens with scheduledFor pre-filled
  → If VIEWER: no action

User clicks "+2 more" on a crowded day
  → DayDetailModal opens with all items for that day
  → Each item shows full title, status, platform, time
  → Actions: Edit, Reschedule, Delete (CLIENT only)

User clicks "Today" button
  → Calendar scrolls to current week/month
  → Today cell is highlighted

9. Error States

State Trigger UI Treatment Recovery
Loading content.list pending Skeleton calendar: 7 columns × 5 rows of empty cells with dashed borders Auto-resolves
Empty month No scheduled content for month Empty state overlay: "No content scheduled for June 2026" + "Schedule First Post" CTA (ember-orange) Navigate to content list
Drag-drop error content.update fails Card snaps back to original position with shake animation (300ms). Toast: "Reschedule failed — please retry." Retry drag or use edit modal
tRPC error content.list fails Full-page error: "Failed to load calendar." + Retry button Retry re-fetches

10. Role-Based Variations

Role Drag-Drop Create from Cell Day Detail Actions
CLIENT Enabled Enabled Edit, reschedule, delete
EDITOR Enabled Enabled Edit, reschedule (no delete)
VIEWER Disabled Disabled View only. No actions.

9.1.4 Approval Queue Widget (/dashboard/content + /dashboard)#

1. Screen Name & Route + Role Access

Route Component Role Access
Embedded in /dashboard/content and /dashboard ApprovalQueueWidget CLIENT (full actions), EDITOR (approve/reject), VIEWER (read-only list)

2. Data to Show

Field Type Format Source
pendingCount number Badge on widget header Client-side count from content.list filtered by status: PENDING_REVIEW
items ContentPiece[] Max 5 items shown, truncated content.list with status: PENDING_REVIEW
item.id string CUID content.list
item.title string Truncated to 40 chars content.list
item.type enum Icon + label content.list
item.autoPublishAt Date Countdown: HH:MM:SS content.list
item.aiProvider string Icon content.list

3. Display Pattern

  • Component type: Card (component.card tokens) with header + list body.
  • Layout: On /dashboard, appears in right sidebar (300px wide) or as a top-row card in main grid. On /dashboard/content, appears as a collapsible panel above the table.
  • Header: "Approval Queue" + pendingCount badge (ember-orange if > 0, slate if 0). Right side: "View All" link.
  • List items: Each item is a horizontal row: status dot (amber), title (medium weight, 14px), type icon, countdown timer (font-family: mono, 12px), action buttons.
  • Actions per item: Approve (check icon, ember-orange), Reject (X icon, error-red), Edit (pencil icon, graphite). VIEWER sees no action buttons.
  • Timer display: Real-time countdown. Color transitions: graphite (> 8h), action-accent (4h–8h), error (< 4h).
  • Empty state: "Queue is clear. All content approved." with CheckCircle icon (series-1 color).
  • Max items: 5 items visible. If > 5, "+N more" link to /dashboard/content?status=PENDING_REVIEW.

4. Backend Endpoint

// No dedicated queue endpoint. Uses content.list with filter.

content.list

// Input: { status: "PENDING_REVIEW" }
// Auth: practiceProcedure
// Returns: ContentPiece[] (filtered to pending review)

// Mutations for actions:
content.approve
content.reject
content.update

Backend gap: content.getQueue does not exist. Frontend filters content.list client-side. Recommended addition: content.getQueue with pagination and pre-sorted by autoPublishAt.

5. Fetch Frequency

Event Interval Strategy
Initial load Once content.list({ status: "PENDING_REVIEW" })
Polling 60 seconds refetchInterval: 60_000
Timer display 1 second Client-side setInterval
After action Immediate Invalidate content.list

6. Data Manipulations

Manipulation Logic Implementation
Count badge items.filter(i => i.status === PENDING_REVIEW).length useMemo on content.list data
Sort by urgency autoPublishAt ascending (soonest first) useMemo with .sort()
Truncate title Max 40 chars, ellipsis title.slice(0, 40) + (title.length > 40 ? '...' : '')
Timer color Based on remaining hours Client-side conditional class
Max 5 display .slice(0, 5) useMemo

7. Why Structured This Way

  • Widget on dashboard: The approval queue is the highest-priority client action. Embedding it on the dashboard homepage ensures visibility without requiring navigation. The 5-item limit prevents clutter.
  • 60-second polling: Pending review items can transition to auto-publish at any time. The queue count must be accurate for the client to trust the system.
  • Real-time countdown: The 24h medical compliance timer creates urgency. Real-time display (1s refresh) makes the time boundary tangible.
  • Inline actions: Approve/reject without navigating to the editor reduces friction for simple content (e.g., a standard GBP post). For complex edits, the "Edit" button opens the full editor.

8. Interaction Flows

Widget mounts on /dashboard
  → content.list({ status: "PENDING_REVIEW" }) fires
  → List renders with pending items
  → Timers start counting down

User clicks "Approve" on first item
  → content.approve.mutate({ id })
  → Item slides out with 300ms exit animation
  → Remaining items animate up to fill gap
  → Count badge decrements
  → Toast: "Approved: {title}"

User clicks "Reject" on first item
  → Mini-modal opens: "Reason for rejection?" (optional textarea)
  → User clicks "Reject" in modal
  → content.reject.mutate({ id, reason })
  → Item slides out
  → Toast: "Rejected: {title}. AI will regenerate."

User clicks "Edit"
  → Navigates to /dashboard/content/[id]
  → Editor opens with full content

Timer reaches 0:00:00 on an item
  → Item automatically removed from queue (server updated status)
  → On next poll (max 60s delay), item disappears
  → Toast: "Auto-published: {title}"

9. Error States

State Trigger UI Treatment Recovery
Loading Initial fetch Skeleton: 3 rows of text + button skeletons Auto-resolves
Empty No pending items "Queue is clear" with check icon N/A
tRPC error content.list fails Inline error: "Queue unavailable." + retry icon Retry click
Action error approve/reject fails Item remains in queue. Toast: "Action failed. Please retry." Retry button on item

10. Role-Based Variations

Role Header Badge Actions Edit Link
CLIENT Full count, clickable Approve, reject, edit Enabled
EDITOR Full count, clickable Approve, reject, edit Enabled
VIEWER Full count, read-only None. Read-only list. Disabled (no click)

9.2 Leads Dashboard (/dashboard/leads)#

9.2.1 Lead List Screen (/dashboard/leads)#

1. Screen Name & Route + Role Access

Route Component Role Access
/dashboard/leads LeadListPage CLIENT (full), EDITOR (view + status update), VIEWER (read-only)

2. Data to Show

Field Type Format Source
id string CUID lead.list
name string 2–100 chars lead.list
email string Email format, masked partially on VIEWER lead.list
phone string +91XXXXXXXXXX format lead.list
source string landing_page / gbp / social / citation / direct / referral lead.list
status enum NEW / CONTACTED / QUALIFIED / CONVERTED / LOST lead.list
createdAt Date DD MMM YYYY lead.list
utmSource string e.g., google_ads, meta_ads, organic lead.list
utmMedium string e.g., cpc, social, email lead.list
assignedTo string User name or "Unassigned" lead.list
notes string Truncated to 50 chars in table lead.list
lastContactedAt Date DD MMM YYYY or Never lead.list

Status Badge Color Mapping

Status Badge Color Token Text
NEW Blue color.semantic.info (#023247) New
CONTACTED Amber color.semantic.action-accent (#EC652B) Contacted
QUALIFIED Green color.semantic.success (#167E6C) Qualified
CONVERTED Green color.semantic.success (#167E6C) Converted
LOST Red color.semantic.error (#C2442A) Lost

3. Display Pattern

  • Component type: DataTable with FilterBar.
  • Layout: Full-width table. Top action bar: left side has "Add Lead" button (ember-orange, CLIENT only), filter dropdowns, date picker; right side has search input and "Export CSV" button (ghost, CLIENT only).
  • Table columns: 10 columns (name, email, phone, source, status, date, assignedTo, notes preview, lastContactedAt, actions). Actions column: "View", "Edit" (CLIENT only), "Delete" (CLIENT only).
  • Bulk actions: Checkbox column. Select multiple → bulk action bar appears with: "Update Status" (dropdown), "Delete" (CLIENT only), "Export Selected" (CLIENT only).
  • Source icons: landing_pageGlobe, gbpMapPin, socialShare2, citationLink, directArrowRight, referralUsers (all Lucide).
  • Responsive: Tablet hides notes and lastContactedAt. Mobile shows card list with name, status, source, date.
  • Pagination: 25 rows default. Page size selector.

4. Backend Endpoint

// tRPC query
lead.list

// Input schema
const listLeadsSchema = z.object({
  status: z.enum(["NEW", "CONTACTED", "QUALIFIED", "CONVERTED", "LOST"]).optional(),
});

// Auth: practiceProcedure
// Returns: Lead[]

// tRPC mutation: update status
lead.updateStatus

// Input schema
const updateLeadStatusSchema = z.object({
  id: z.string(),
  status: z.enum(["NEW", "CONTACTED", "QUALIFIED", "CONVERTED", "LOST"]),
});

// tRPC mutation: delete
lead.delete

// Input schema
const deleteLeadSchema = z.object({
  id: z.string(),
});

Backend gap: lead.create does not exist in backend-api.md. Frontend requires this for manual lead entry. lead.export does not exist for CSV export. lead.update (full update) does not exist — only lead.updateStatus is available.

5. Fetch Frequency

Event Interval Strategy
Initial load Once useQuery with staleTime: 60_000
Polling 60 seconds refetchInterval: 60_000
After mutation Immediate utils.lead.list.invalidate()
Window focus Event-driven refetchOnWindowFocus: true

6. Data Manipulations

Manipulation Logic Implementation
Client-side search Case-insensitive match on name + email useMemo with debounce(300ms)
Status filter Exact match on status enum Dropdown filter
Source filter Exact match on source string Dropdown filter
Date range filter createdAt within range DateRangePicker + client-side filter
Sort by date Descending default Table header click
Sort by name Alphanumeric ascending Table header click
Bulk status update Loop through selectedIds, call lead.updateStatus Promise.all with toast summary
CSV export Convert selected/all rows to CSV blob Client-side convertToCSV utility + URL.createObjectURL download
Source icon mapping Map string to Lucide icon component Client-side object map

7. Why Structured This Way

  • 60-second polling: Leads are high-value, time-sensitive events. A new lead from a landing page form needs immediate visibility. 60s ensures the client sees leads within a minute of creation.
  • CSV export: Medical practices often need to export lead data for their own CRM or compliance records. Client-side export avoids server load for small datasets (< 1000 leads).
  • Source tracking: UTM parameters (utmSource, utmMedium) are critical for ROI analysis. The source column helps clients understand which marketing channel is driving leads.
  • Status pipeline: NEWCONTACTEDQUALIFIEDCONVERTED / LOST is the standard B2B lead pipeline. The status dropdown in the table enables quick pipeline updates without opening a detail page.
  • Bulk actions: Clinics receive lead batches (e.g., after a social media campaign). Bulk status update and bulk export handle these efficiently.

8. Interaction Flows

User lands on /dashboard/leads
  → lead.list fires
  → Table renders with leads
  → "Add Lead" button visible (CLIENT only)

User clicks "Add Lead"
  → LeadFormModal opens
  → User fills name, email, phone, source, notes
  → Clicks "Save"
  → lead.create.mutate(data) (requires backend implementation)
  → On success: modal closes, lead.list invalidates, toast "Lead added"
  → On error: inline validation errors

User selects 3 leads via checkboxes
  → Bulk action bar appears
  → User selects "Update Status" → "QUALIFIED"
  → ConfirmDialog: "Mark 3 leads as Qualified?"
  → On confirm: Promise.all(lead.updateStatus.mutate for each)
  → Toast: "3 leads updated to Qualified"
  → Table rows update with green badges

User clicks "Export CSV"
  → Client-side CSV generation from current filtered rows
  → Blob download triggered
  → Filename: `rankflow-leads-2026-06-13.csv`

User clicks "Delete" on a row (CLIENT only)
  → ConfirmDialog: "Delete lead for {name}? This cannot be undone."
  → On confirm: lead.delete.mutate({ id })
  → Row slides out with exit animation
  → Toast: "Lead deleted"

9. Error States

State Trigger UI Treatment Recovery
Loading lead.list pending Skeleton table (8 rows) Auto-resolves
Empty No leads EmptyState with Users icon: "No leads yet." + "Add Lead" (CLIENT) Add lead or wait
Filter empty Filters match 0 "No leads match filters" + "Clear Filters" Clear filters
tRPC error lead.list fails Error banner + retry button Retry
Create error lead.create fails Modal shows error message: "Failed to add lead." Retry in modal
Export error CSV generation fails Toast: "Export failed. Please try again." Retry export

10. Role-Based Variations

Role Add Lead Edit Delete Bulk Actions Status Update Export
CLIENT Enabled Enabled Enabled Enabled Enabled Enabled
EDITOR Disabled Disabled Disabled Bulk status update only Enabled Disabled
VIEWER Hidden Hidden Hidden Hidden Hidden Hidden

9.2.2 Lead Detail Screen (/dashboard/leads/[id])#

1. Screen Name & Route + Role Access

Route Component Role Access
/dashboard/leads/[id] LeadDetailPage CLIENT (full), EDITOR (view + status update), VIEWER (read-only)

2. Data to Show

Field Type Format Source
id string CUID lead.get
name string 2–100 chars lead.get
email string Clickable mailto link lead.get
phone string Clickable tel link (+91 prefix) lead.get
source string Icon + label + UTM params lead.get
status enum Editable dropdown lead.get
createdAt Date DD MMM YYYY, HH:MM lead.get
utmSource string e.g., google_ads lead.get
utmMedium string e.g., cpc lead.get
utmCampaign string e.g., kerala-dental-june lead.get
landingPageUrl string Clickable link to landing page lead.get
notes string Rich text, editable lead.get
assignedTo string User name or "Unassigned" lead.get
contactHistory ContactEvent[] Timeline of calls, emails, meetings lead.get (or computed from related tables)
conversionValue number ₹XX,XXX if converted lead.get
convertedAt Date DD MMM YYYY or lead.get

Contact Event Timeline

Event Type Icon Color Fields
CALL Phone series-1 Duration, notes, outcome
EMAIL Mail series-2 Subject, body preview, sent/received
MEETING Calendar series-3 Date, location, notes
NOTE FileText slate Text, createdBy, timestamp
STATUS_CHANGE ArrowRight action-accent From → To status

3. Display Pattern

  • Component type: Two-column layout (66/33 split on desktop, stacked on mobile).
  • Left column: Lead profile card + contact history timeline.
  • Right column: Actions panel + notes + conversion tracking.
  • Profile card: White card with shadow.subtle. Contains name (heading-sm, 24px), status badge, email/phone as click-to-action links, source with UTM params displayed as tag pills (utmSource=google_ads → gray pill), creation date.
  • Status dropdown: Inline Select component in profile card. Options: NEW, CONTACTED, QUALIFIED, CONVERTED, LOST. On change, immediately calls lead.updateStatus.
  • Contact history timeline: Vertical timeline with left border (1px solid mist). Each event is a dot (12px, color per type) + card with event details. Events ordered by createdAt descending. Newest at top.
  • Notes section: Textarea with auto-save (debounce 2s). Notes stored in lead.notes.
  • Conversion tracking: If status is CONVERTED, show conversion card with convertedAt date and conversionValue input (currency formatted). If not converted, show "Mark as Converted" button that sets status and opens value input.
  • Assign to team member: Select dropdown with practice members (practice.get members). CLIENT only. EDITOR/VIEWER see read-only assignment.
  • Action buttons: "Send Email" (opens mailto), "Call" (opens tel), "Add Note" (opens inline textarea), "Delete Lead" (error-red, CLIENT only).

4. Backend Endpoint

// tRPC query
lead.get

// Input schema
const getLeadSchema = z.object({
  id: z.string(),
});

// Auth: practiceProcedure
// Returns: Lead

// tRPC mutation: update status
lead.updateStatus

// Input schema (same as list screen)
// Returns: Lead

Backend gap: lead.get returns basic fields. contactHistory is not in the backend schema. Frontend either derives it from related tables or requires backend to add contactHistory to lead.get response. lead.update (full update) does not exist — only lead.updateStatus.

5. Fetch Frequency

Event Interval Strategy
Initial load Once lead.get({ id })
Background refresh 5 minutes refetchInterval: 300_000
After status change Immediate Invalidate lead.get and lead.list
Notes auto-save 2 seconds debounce lead.update (requires backend) or lead.updateStatus with notes payload

6. Data Manipulations

Manipulation Logic Implementation
Timeline sort contactHistory descending by createdAt useMemo with .sort()
UTM param display utmSource + utmMedium + utmCampaign as pills Map to <Badge> components
Phone click tel: link with +91 prefix href="tel:+91${phone}"
Email click mailto: link href="mailto:${email}"
Notes auto-save Debounce 2s, call update useEffect with setTimeout cleanup
Conversion value Currency input with prefix Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' })
Assignment Select with members from practice.get practice.get query in parallel

7. Why Structured This Way

  • Two-column layout: Left side provides the narrative (timeline), right side provides actionability (status, notes, conversion). This matches CRM conventions (HubSpot, Salesforce).
  • Click-to-call / click-to-email: Medical practice staff are often on mobile devices. Direct tel: and mailto: links reduce friction from "see lead" to "contact lead".
  • UTM param pills: Marketing attribution is critical for practices spending on Google/Meta ads. Displaying UTM params as pills makes the source visible without requiring the user to open a tracking dashboard.
  • Timeline over table: A contact history table would be dense. The vertical timeline provides visual rhythm and chronological context. The dot colors make event types scannable.
  • Auto-save notes: Notes are frequently added during calls. Auto-save prevents data loss if the user navigates away. The 2s debounce balances responsiveness with server load.
  • 5-minute refresh: Lead detail is less frequently updated than the lead list. 5 minutes is sufficient for status updates from other team members.

8. Interaction Flows

User navigates to /dashboard/leads/lead_123
  → lead.get({ id: "lead_123" }) fires
  → Two-column layout renders
  → Profile card shows lead info
  → Timeline shows contact history

User changes status dropdown from "NEW" to "CONTACTED"
  → lead.updateStatus.mutate({ id, status: "CONTACTED" })
  → Status badge transitions to amber
  → Timeline appends new STATUS_CHANGE event
  → Toast: "Status updated to Contacted"

User clicks "Add Note"
  → Inline textarea appears below existing notes
  → User types note
  → Auto-save fires after 2s of inactivity
  → Note saved to backend (requires lead.update endpoint)
  → Timeline appends NOTE event

User clicks "Assign to" dropdown (CLIENT only)
  → Select opens with practice members
  → User selects member "Dr. Sharma"
  → Assignment updates (requires backend endpoint)
  → Toast: "Lead assigned to Dr. Sharma"

User clicks "Delete Lead" (CLIENT only)
  → ConfirmDialog: "Permanently delete lead for {name}?"
  → On confirm: lead.delete.mutate({ id })
  → Redirect to /dashboard/leads
  → Toast: "Lead deleted"

9. Error States

State Trigger UI Treatment Recovery
Loading lead.get pending Skeleton: profile card + 3 timeline skeletons Auto-resolves
Not found Invalid lead ID Full-page error: "Lead not found" + back to list Navigate back
tRPC error lead.get fails Error banner: "Failed to load lead." + retry Retry
Status update error lead.updateStatus fails Status badge reverts to previous value. Toast: "Update failed." Retry dropdown
Notes save error Auto-save fails Inline error below textarea: "Save failed. Retry?" + retry button Retry button
Assignment error Assignment mutation fails Dropdown reverts. Toast: "Assignment failed." Retry

10. Role-Based Variations

Role Status Dropdown Notes Edit Assignment Delete Conversion Value
CLIENT Editable Editable Editable Enabled Editable
EDITOR Editable Read-only Read-only Hidden Read-only
VIEWER Read-only Read-only Read-only Hidden Read-only

9.2.3 Lead Form Modal (Add New Lead)#

1. Screen Name & Route + Role Access

Route Component Role Access
Modal from /dashboard/leads LeadFormModal CLIENT only

2. Data to Show

Field Type Required Validation Format
name string Yes 2–100 chars Text input
email string Yes Valid email regex Email input
phone string Yes ^\+91[0-9]{10}$ Tel input with +91 prefix
source enum Yes landing_page / gbp / social / citation / direct / referral Select dropdown
status enum No Default: NEW Select dropdown
notes string No Max 1000 chars Textarea
assignedTo string No User ID from practice members Select dropdown

3. Display Pattern

  • Component type: Dialog (shadcn/ui) with Modal wrapper.
  • Layout: 480px max-width modal. 2-column grid for name/email and phone/source on wider screens. Stacked on mobile.
  • Form fields: Input components with label above, helperText below for validation hints. Textarea for notes (4 rows, resizable).
  • Status: Pre-filled to NEW. Select dropdown with color-coded options (same as table badges).
  • Phone input: +91 prefix is non-editable (left adornment). Input accepts 10 digits only. Auto-formats as user types.
  • Source dropdown: Each option has a Lucide icon prefix (same as table source icons).
  • Assignment: Populated from practice.get members. "Unassigned" is the default option.
  • Actions: "Cancel" (ghost), "Save Lead" (accent, ember-orange). Disabled until all required fields are valid.
  • Validation: Inline validation on blur. Zod schema matches backend CreateLeadInput (proposed).

4. Backend Endpoint

// Backend endpoint DOES NOT EXIST in backend-api.md.
// Frontend requires a new mutation endpoint.

// Proposed tRPC mutation:
lead.create

// Proposed input schema:
const createLeadSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  phone: z.string().regex(/^\+91[0-9]{10}$/),
  source: z.enum(["landing_page", "gbp", "social", "citation", "direct", "referral"]),
  status: z.enum(["NEW", "CONTACTED", "QUALIFIED", "CONVERTED", "LOST"]).default("NEW"),
  notes: z.string().max(1000).optional(),
  assignedTo: z.string().optional(),
});

// Auth: practiceProcedure (CLIENT role only)
// Returns: Lead

Backend requirement: Implement lead.create mutation in src/server/api/routers/lead.ts.

5. Fetch Frequency

Event Interval Strategy
Form open Once Pre-fetch practice.get for member list
Save On submit lead.create.mutate(data)
After save Immediate utils.lead.list.invalidate()

6. Data Manipulations

Manipulation Logic Implementation
Phone formatting Strip non-digits, prepend +91 value.replace(/\D/g, '').slice(0, 10)
Validation Zod schema validation on submit createLeadSchema.safeParse(formData)
Source icon mapping Map enum to Lucide icon Client-side map
Status color mapping Map enum to badge color Client-side map
Member list Filter practice.get members members.filter(m => m.role !== 'VIEWER')

7. Why Structured This Way

  • Modal over page: Adding a lead is a quick, transactional action. A full page would be excessive. The modal keeps the user in context (they just saw the lead list and noticed a missing entry).
  • Phone validation: Indian medical practices require valid 10-digit mobile numbers with +91 country code. The regex ^\+91[0-9]{10}$ enforces this. The +91 prefix is non-editable to prevent formatting errors.
  • Pre-filled NEW status: Most manually added leads are new inquiries. Defaulting to NEW reduces clicks.
  • Source enum: Restricting sources to known values ensures consistent attribution reporting. Free-text sources would create data quality issues.
  • Assignment on creation: For practices with multiple staff, immediate assignment ensures the lead is routed to the right person without a separate step.

8. Interaction Flows

User clicks "Add Lead" on /dashboard/leads
  → LeadFormModal opens
  → practice.get fires in background for member list
  → Form fields render empty, status = NEW

User types name: "Ravi Kumar"
  → Validation on blur: passes (2–100 chars)

User types email: "ravi@example.com"
  → Validation on blur: passes regex

User types phone: "9876543210"
  → Auto-formats to "+91 98765 43210" in display
  → Stored as "+919876543210"

User selects source: "Google Business Profile"
  → Dropdown closes, icon visible

User clicks "Save Lead"
  → Client-side Zod validation runs
  → If valid: lead.create.mutate(data)
  → Loading state on button (spinner)
  → On success: modal closes, lead.list invalidates, toast "Lead added: Ravi Kumar"
  → On error: modal shows error banner, button returns to "Save Lead"

User clicks "Cancel"
  → Modal closes, form state discarded

9. Error States

State Trigger UI Treatment Recovery
Validation error Invalid field Inline error below field: "Email is required" or "Phone must be 10 digits" Correct input
Server error lead.create fails Modal error banner: "Failed to add lead. Please try again." Retry save
Member load error practice.get fails Assignment dropdown shows "Loading failed" with retry Retry dropdown load
Duplicate email Unique constraint violation Inline error on email field: "A lead with this email already exists." Change email

10. Role-Based Variations

Role Modal Access Fields Editable
CLIENT Full access All fields
EDITOR Cannot open N/A
VIEWER Cannot open N/A