Skip to main content

Superadmin UI Refactor — Spec

Date: 2026-04-28
Scope: Invoice Studio standalone page · Nav consolidation · User status + re-add bugs

1. Invoice Studio — Standalone Full-Page Module

Problem

invoice-studio nav item renders <BillingManagement defaultTab="manual" />, which includes billing stats, a revenue chart, license-request tabs, and the subscription table. The actual invoice creation form is buried as a tab inside that noise.

Solution

Create ui/src/components/superadmin/InvoiceStudio.tsx as a fully isolated page. Layout (Option C — approved):
┌─────────────────────────────────────────────────────────┐
│ Invoice Studio          [Clinic selector ▾]   PKR       │
├────────────────────────────┬────────────────────────────┤
│                            │ ┌──────────────────────┐   │
│  InvoiceGenerator form     │ │  Totals / Summary    │   │
│  (60% width)               │ │  Issue buttons       │   │
│                            │ └──────────────────────┘   │
│                            │ ┌──────────────────────┐   │
│                            │ │  Invoice history     │   │
│                            │ │  (scrollable list)   │   │
│                            │ │  Mark Paid / Send    │   │
│                            │ └──────────────────────┘   │
└────────────────────────────┴────────────────────────────┘
Component responsibilities:
  • InvoiceStudio: owns clinic selector state, fetches clinic list + all invoices, passes clinicId/clinicName down
  • InvoiceGenerator: receives clinicId, clinicName, onSuccessremove its own summary Card and action buttons from its return JSX; expose them via a render-prop or lift them to InvoiceStudio’s sidebar. The simplest approach: add an optional hideSummary prop to InvoiceGenerator and render the summary card inside InvoiceStudio’s sidebar instead, passing totals up via onTotalsChange callback.
  • Invoice history list: extracted from BillingManagement’s invoices tab — same handleDownloadPdf / handleInvoiceAction logic, refactored into a small InvoiceHistoryList sub-component inside the same file.
Wiring changes:
  • SuperAdminDashboard.tsx: change activeView === 'invoice-studio' render from <BillingManagement defaultTab="manual" /> to <InvoiceStudio />
  • BillingManagement.tsx: remove the manual TabsTrigger and TabsContent entirely

2. Nav Consolidation (Option A — −2 nav entries)

Removals from useNavItems.ts

Removed entryWhere content moves
{ id: 'revenue', label: 'Revenue Cycle' } (Overview group)New “Revenue” tab inside Billing Management
{ id: 'invitations', label: 'Invitations' } (Management group)New “Invitations” tab inside User Management

Revenue Cycle → Billing Management tab

  • BillingManagement.tsx: add a revenue TabsTrigger and TabsContent that renders <RevenueDashboard />
  • RevenueDashboard is already a standalone component — import and embed, no logic changes needed
  • SuperAdminDashboard.tsx: remove {activeView === 'revenue' && <RevenueDashboard />}

Invitations → User Management tab

  • Extract PlatformInvitations function from SuperAdminDashboard.tsx into a new file: ui/src/components/superadmin/PlatformInvitations.tsx (named export)
  • UserManagement.tsx: wrap existing content in a Tabs shell with two tabs:
    • “Users” — existing user management content (unchanged)
    • “Invitations” — renders <PlatformInvitations />
  • SuperAdminDashboard.tsx: remove {activeView === 'invitations' && <PlatformInvitations />} render and the local function definition

3. Bug Fixes

Bug A — Active user count hardcoded (ignores isActive flag)

File: ui/src/components/superadmin/UserManagement.tsx
Location: Stats grid “Active” card (~line 671)
Before:
<p className="text-2xl font-bold">{users.filter(u => u.status === 'active').length}</p>
After:
<p className="text-2xl font-bold">
  {users.filter(u => getEffectiveUserStatus(u.status, u.isActive) === 'active').length}
</p>
getEffectiveUserStatus is already imported in the file. No other changes.

Bug B — “User already accepted an invitation” blocks re-adding a hard-deleted user

Root cause:
Users are hard-deleted (db.delete(users)) but their staffInvitations row (status 'accepted') is never cleaned up. On re-invite the users table check passes (user is gone), but the invitation check throws immediately when it finds the stale 'accepted' row — even though there is no live user.
File: server/src/routes/admin.ts
Location: Invitation existence check (~line 2112–2117)
Before:
if (existingInv) {
  // Update existing invitation if not accepted
  if (existingInv.status === 'accepted') {
    throw new AppError('User already accepted an invitation', 400);
  }
}
After:
if (existingInv) {
  if (existingInv.status === 'accepted') {
    // Only block if the user record still exists — if they were hard-deleted
    // their accepted invitation is stale and re-invite should be allowed.
    if (existingUser) {
      throw new AppError('User already accepted an invitation', 400);
    }
    // existingUser is null (deleted): fall through to the update block below
    // which resets this invitation to pending with a fresh token.
  }
}
The existing update block at ~line 2181 already handles the reset to pending with a new token/expiry when existingInv is truthy — no additional logic needed.

Files Touched

FileChange
ui/src/components/superadmin/InvoiceStudio.tsxNew — standalone Invoice Studio page
ui/src/components/superadmin/PlatformInvitations.tsxNew — extracted from SuperAdminDashboard
ui/src/components/superadmin/InvoiceGenerator.tsxAdd hideSummary prop + onTotalsChange callback
ui/src/components/superadmin/BillingManagement.tsxRemove manual tab · Add revenue tab · Split subscriptions by trial/active
ui/src/components/superadmin/UserManagement.tsxAdd Invitations tab · Fix active count stat · Amber border on trial clinic headers
ui/src/components/dashboards/SuperAdminDashboard.tsxWire InvoiceStudio · Remove revenue/invitations views
ui/src/hooks/useNavItems.tsRemove revenue + invitations nav entries
server/src/routes/admin.tsFix re-add-after-delete invitation check

4. Trial vs. Live Tenant Separation

Clinics in trial subscription status must be visually and functionally separated from active/paid tenants anywhere clinics are listed or selectable.

Invoice Studio clinic selector

  • Group the clinic <Select> dropdown into two <SelectGroup> sections: “Active Clinics” (subscriptionStatus active or paid) and “Trial / Other” (status trial, expired, suspended, or null)
  • Append a [TRIAL] label after the clinic name for trial entries
  • When a trial clinic is selected, render a yellow warning banner below the selector: "This clinic is on a free trial — invoicing is allowed but confirm before issuing."

BillingManagement — Subscriptions tab

  • Split the subscriptions table into two visual sections with a divider label: Active Tenants (rows with subscriptionStatus === 'active') rendered first, then Trial / Inactive below a <Separator> with a muted section label
  • Active rows keep their current appearance; trial rows get a muted/dimmed style + amber “TRIAL” badge

UserManagement — clinic headers

  • The existing subscriptionStatus badge on each clinic collapsible header already shows status text; change trial clinic headers to use an amber border accent (border-amber-500/30) so trial clinics stand out at a glance

Out of Scope

  • No changes to any other superadmin modules (Analytics, Clinics, System, etc.)
  • No DB schema changes
  • No API changes except the one-line server bug fix