Client Portal
The Client Hub is the relationship layer. Ten sub-tabs covering onboarding, strategy, deliverables, content, meeting notes and billing.
ClientPortalTab.tsx — until Phase 4-12 progressively pull each sub-tab into its own dedicated section, the legacy host is reused with a forcedSubTab prop that hides its internal sub-nav and renders only the requested sub-section.Entry
The host component is at src/components/portal/ClientPortalTab.tsx. It receives every metric prop from ClientDashboard (so the metric-driven sub-tabs can render without their own fetch logic) and on mount it pulls editable section data from /api/portal/[clientId]. The new SubNav at the top drives which sub-section is visible via the forcedSubTab prop.
Sub-tabs
| Sub-tab | Source of truth | Component |
|---|---|---|
| Summary | Live metrics (GHL + Google Ads + Meta + GBP + GSC + ext) | SummarySection.tsx |
| Historical Revenue | Google Sheets RFMS via /api/metrics/sheets-rfms | HistoricalRevenueSection.tsx |
| Snapshot & Goals | Editable JSONB in portal_data.snapshot | SnapshotSection.tsx + SnapshotEditor |
| Pipeline Overview | Live GHL + Google Ads + Meta + GBP | PipelineSection.tsx |
| Scorecard | Editable JSONB in portal_data.scorecard | ScorecardSection.tsx + ScorecardEditor |
| 90-Day Game Plan | Editable JSONB in portal_data.gameplan | GamePlanSection.tsx + GamePlanEditor |
| Journey & Deliverables | Editable JSONB in portal_data.deliverables | DeliverablesSection.tsx + DeliverablesEditor |
| KPI Reporting | Editable JSONB in portal_data.kpi_reporting | KpiReportingSection.tsx + KpiReportingEditor |
| Content & Website | Editable JSONB in portal_data.content_tracker | ContentTrackerSection.tsx + ContentTrackerEditor |
| Documents | Editable JSONB in portal_data.documents (file URLs) | DocumentsSection.tsx + DocumentsEditor |
Editable section data shape
All editable sections live in one row in portal_data:
portal_data
├── id (uuid)
├── client_id (uuid, references clients)
├── snapshot (jsonb) — PortalSnapshot
├── scorecard (jsonb) — PortalScorecard
├── gameplan (jsonb) — PortalGameplan
├── deliverables (jsonb) — PortalDeliverables
├── kpi_reporting (jsonb) — PortalKpiReporting
├── content_tracker (jsonb) — PortalContentTracker
└── documents (jsonb) — PortalDocumentsEach JSONB column has a TypeScript type defined in src/lib/types/portal.ts. The exact shape varies by section — Snapshot has goals + current state, Scorecard has scored pillars, Game Plan has 30/60/90 sections, etc.
How editing works (admin only)
- Admin clicks the pencil icon in a sub-tab header.
setEditing('snapshot')opens the corresponding editor modal.- The editor is a dynamically imported component (
SnapshotEditoretc.) that takes the current data, lets the admin edit fields, and callsonSave(newData). - The host calls
handleSave(section, data)which PUTs to/api/portal/[clientId]. - The route validates admin role server-side, validates the JSON against the section's schema, then upserts the column.
- On success, the host re-fetches and closes the modal.
next/dynamic with ssr: false to avoid bloating the initial bundle for client users (who never see them). Don't change to static imports without considering the bundle impact.Empty state
When a client has no portal_data row yet, the host falls back to EMPTY_PORTAL (defined in src/lib/types/portal.ts) so the read-only sections still render with placeholder copy. Saving any section creates the row.
Historical Revenue (RFMS)
Historical Revenue is the only sub-tab driven by Google Sheets. It expects a sheet ID stored at clients.sheets_rfms_id, with a specific column layout (date, revenue, units sold, etc.). The route that reads it is /api/metrics/sheets-rfms and the component is HistoricalRevenueSection.tsx.
This pattern exists because most flooring/contractor clients still keep their installed-revenue records in spreadsheets they own.