Skip to main content

Superadmin UI Refactor Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Promote Invoice Studio to a full standalone page, fold Revenue and Invitations into their parent modules, fix the active-user-count stat and re-add-after-delete bug, and visually separate trial clinics from live tenants. Architecture: Eight sequential tasks touching 8 files (2 new, 6 modified). Tasks 1–2 are server/stat bug fixes with zero UI dependencies. Task 3 extracts PlatformInvitations as a prerequisite for Task 4. Tasks 5–8 are independent UI module updates that wire up in Task 7. Tech Stack: React 18, TypeScript, Tailwind CSS, shadcn/ui (Tabs, Select, Card, Badge, Separator), Hono (server), Drizzle ORM

File Map

FileActionResponsibility
server/src/routes/admin.tsModify ~line 2112Fix re-add-after-delete invitation check
ui/src/components/superadmin/UserManagement.tsxModifyFix active count · Add Invitations tab · Amber trial border
ui/src/components/superadmin/PlatformInvitations.tsxCreateExtracted invitations component
ui/src/components/superadmin/InvoiceStudio.tsxCreateStandalone Invoice Studio full page
ui/src/components/superadmin/BillingManagement.tsxModifyRemove manual tab · Add revenue tab · Split subscriptions
ui/src/components/dashboards/SuperAdminDashboard.tsxModifyWire InvoiceStudio · Remove stale views
ui/src/hooks/useNavItems.tsModifyRemove revenue + invitations nav entries

Task 1: Fix re-add-after-delete server bug

Files:
  • Modify: server/src/routes/admin.ts:2112–2117
Background: Users are hard-deleted with db.delete(users) but their staffInvitations row (status 'accepted') is never cleaned up. When you try to re-invite that email, the user-table check passes (user is gone), but the stale accepted invitation throws "User already accepted an invitation" before the resend logic can run.
  • Step 1: Open the file and locate the check Read server/src/routes/admin.ts around line 2112. The block looks like:
    if (existingInv) {
      // Update existing invitation if not accepted
      if (existingInv.status === 'accepted') {
        throw new AppError('User already accepted an invitation', 400);
      }
    }
    
  • Step 2: Apply the fix Replace those 5 lines with:
    if (existingInv) {
      if (existingInv.status === 'accepted') {
        // Only block re-invite if the user record still exists.
        // If they were hard-deleted, the accepted invitation is stale — allow re-invite.
        if (existingUser) {
          throw new AppError('User already accepted an invitation', 400);
        }
        // existingUser is null: fall through to the update block below,
        // which resets status to pending with a fresh token.
      }
    }
    
    The update block at ~line 2181 already resets status: 'pending', generates a fresh token, and sets a new expiresAt — no other changes needed.
  • Step 3: Verify manually Delete a non-superadmin user from the User Management panel, then immediately try to re-add them via “Add User” with the same email. It should send an invitation instead of throwing “User already accepted an invitation”.
  • Step 4: Commit
    git add server/src/routes/admin.ts
    git commit -m "fix(server): allow re-invite after hard-delete of user"
    

Task 2: Fix active user count stat

Files:
  • Modify: ui/src/components/superadmin/UserManagement.tsx:671
Background: The “Active” stat card counts users.filter(u => u.status === 'active') which ignores the isActive boolean. Users with isActive: false (soft-deactivated) still appear in the count. getEffectiveUserStatus is already imported and handles this correctly.
  • Step 1: Find the Active stat card In UserManagement.tsx the stats grid has four cards. The “Active” card (~line 669) contains:
    <p className="text-2xl font-bold">{users.filter(u => u.status === 'active').length}</p>
    
  • Step 2: Apply the fix Replace that line with:
    <p className="text-2xl font-bold">
      {users.filter(u => getEffectiveUserStatus(u.status, u.isActive) === 'active').length}
    </p>
    
    getEffectiveUserStatus is already imported at the top of the file — no import changes needed.
  • Step 3: Commit
    git add ui/src/components/superadmin/UserManagement.tsx
    git commit -m "fix(ui): use getEffectiveUserStatus for active user count in superadmin"
    

Task 3: Extract PlatformInvitations to its own file

Files:
  • Create: ui/src/components/superadmin/PlatformInvitations.tsx
  • Modify: ui/src/components/dashboards/SuperAdminDashboard.tsx
Background: PlatformInvitations is currently a local function inside SuperAdminDashboard.tsx. It needs to be a named export so UserManagement can import it as a tab. SuperAdminDashboard’s invitations view will be removed (it moves into the UserManagement Invitations tab), so the function leaves SuperAdminDashboard entirely.
  • Step 1: Create PlatformInvitations.tsx Create ui/src/components/superadmin/PlatformInvitations.tsx with the full component. Copy the existing function body from SuperAdminDashboard.tsx (the function PlatformInvitations() block, lines ~84–330) and add the required imports:
    import React, { useState, useEffect } from 'react';
    import { getAuthToken } from '@/lib/serverComm';
    import { getApiBaseUrl } from '@/lib/api-url';
    import {
      RefreshCcw,
      CheckCircle,
      XCircle,
      AlertTriangle,
      Clock,
      Loader2,
      Mail,
      MoreVertical,
      Send,
    } from 'lucide-react';
    import { ic } from '@/hooks/useNavItems';
    import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
    import { Button } from '@/components/ui/button';
    import { Badge } from '@/components/ui/badge';
    import {
      Table,
      TableBody,
      TableCell,
      TableHead,
      TableHeader,
      TableRow,
    } from '@/components/ui/table';
    import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
    import {
      DropdownMenu,
      DropdownMenuContent,
      DropdownMenuItem,
      DropdownMenuTrigger,
    } from '@/components/ui/dropdown-menu';
    import { toast } from '@/lib/toast';
    import { formatDistanceToNow } from 'date-fns';
    
    export default function PlatformInvitations() {
      const [invitations, setInvitations] = useState<any[]>([]);
      const [loading, setLoading] = useState(true);
      const [resending, setResending] = useState<string | null>(null);
      const [selectedTab, setSelectedTab] = useState('pending');
      const [currentPage, setCurrentPage] = useState(1);
      const [pagination, setPagination] = useState({ total: 0, totalPages: 1, limit: 50 });
      const [serverStats, setServerStats] = useState({ pending: 0, accepted: 0, expired: 0 });
    
      useEffect(() => {
        fetchInvitations(1, selectedTab);
      }, [selectedTab]);
    
      const getAuthHeaders = () => {
        const token = getAuthToken();
        return {
          'Authorization': `Bearer ${token}`,
          'Content-Type': 'application/json',
        };
      };
    
      const fetchInvitations = async (page = currentPage, status = selectedTab) => {
        try {
          setLoading(true);
          const apiUrl = getApiBaseUrl() || '';
          const response = await fetch(
            `${apiUrl}/api/v1/protected/admin/invitations?page=${page}&status=${status}&limit=50`,
            { headers: getAuthHeaders() }
          );
          if (!response.ok) throw new Error('Failed to fetch invitations');
          const data = await response.json();
          if (data.invitations) {
            setInvitations(data.invitations);
            setPagination(data.pagination);
            setServerStats(data.stats);
            setCurrentPage(data.pagination.page);
          } else {
            setInvitations(Array.isArray(data) ? data : []);
          }
        } catch (error) {
          console.error('Failed to fetch invitations:', error);
          toast.error('Failed to load invitations');
        } finally {
          setLoading(false);
        }
      };
    
      const resendInvitation = async (invitation: any) => {
        try {
          setResending(invitation.id);
          const apiUrl = getApiBaseUrl() || '';
          const response = await fetch(
            `${apiUrl}/api/v1/protected/admin/invitations/${invitation.id}/resend`,
            { method: 'POST', headers: getAuthHeaders() }
          );
          if (!response.ok) {
            const error = await response.json();
            throw new Error(error.error || 'Failed to resend invitation');
          }
          toast.success(`Invitation resent to ${invitation.email}`);
          fetchInvitations();
        } catch (error) {
          toast.error(error instanceof Error ? error.message : 'Failed to resend invitation');
        } finally {
          setResending(null);
        }
      };
    
      const getStatusBadge = (invitation: any) => {
        if (invitation.status === 'accepted') {
          return (
            <Badge className="bg-emerald-500/5 text-emerald-600 border-emerald-500/10">
              {ic(CheckCircle, 'h-3 w-3 mr-1')}Accepted
            </Badge>
          );
        }
        if (invitation.status === 'cancelled') {
          return (
            <Badge variant="secondary" className="bg-zinc-500/5 text-zinc-500">
              {ic(XCircle, 'h-3 w-3 mr-1')}Cancelled
            </Badge>
          );
        }
        if (invitation.isExpired) {
          return (
            <Badge variant="destructive" className="bg-red-500/5 text-red-600 border-red-200">
              {ic(AlertTriangle, 'h-3 w-3 mr-1')}Expired
            </Badge>
          );
        }
        return (
          <Badge className="bg-amber-500/5 text-amber-600 border-amber-500/10">
            {ic(Clock, 'h-3 w-3 mr-1')}Pending
          </Badge>
        );
      };
    
      const handlePageChange = (newPage: number) => {
        if (newPage >= 1 && newPage <= pagination.totalPages) {
          fetchInvitations(newPage, selectedTab);
        }
      };
    
      return (
        <div className="space-y-6">
          <div className="flex items-center justify-between">
            <div>
              <h2 className="text-2xl font-bold tracking-tight text-foreground">Platform Invitations</h2>
              <p className="text-muted-foreground">View and manage all staff invitations across all clinics</p>
            </div>
            <Button variant="outline" onClick={() => fetchInvitations(1)} disabled={loading} className="gap-2">
              {ic(RefreshCcw, `h-4 w-4 ${loading ? 'animate-spin' : ''}`)}
              Refresh
            </Button>
          </div>
    
          <div className="grid gap-4 md:grid-cols-3">
            <Card className="cursor-pointer hover:border-amber-500/50" onClick={() => setSelectedTab('pending')}>
              <CardContent className="pt-6">
                <div className="flex items-center justify-between">
                  <div>
                    <p className="text-sm font-medium text-muted-foreground">Pending</p>
                    <p className="text-2xl font-bold text-foreground">{serverStats.pending}</p>
                  </div>
                  {ic(Clock, 'h-8 w-8 text-amber-500')}
                </div>
              </CardContent>
            </Card>
            <Card className="cursor-pointer hover:border-green-500/50" onClick={() => setSelectedTab('accepted')}>
              <CardContent className="pt-6">
                <div className="flex items-center justify-between">
                  <div>
                    <p className="text-sm font-medium text-muted-foreground">Accepted</p>
                    <p className="text-2xl font-bold text-foreground">{serverStats.accepted}</p>
                  </div>
                  {ic(CheckCircle, 'h-8 w-8 text-green-500')}
                </div>
              </CardContent>
            </Card>
            <Card className="cursor-pointer hover:border-red-500/50" onClick={() => setSelectedTab('expired')}>
              <CardContent className="pt-6">
                <div className="flex items-center justify-between">
                  <div>
                    <p className="text-sm font-medium text-muted-foreground">Expired</p>
                    <p className="text-2xl font-bold text-foreground">{serverStats.expired}</p>
                  </div>
                  {ic(AlertTriangle, 'h-8 w-8 text-red-500')}
                </div>
              </CardContent>
            </Card>
          </div>
    
          <Card>
            <CardHeader>
              <CardTitle className="flex items-center gap-2">
                {ic(Mail, 'h-5 w-5')}All Invitations
              </CardTitle>
              <CardDescription>Invitations from all clinics on the platform</CardDescription>
            </CardHeader>
            <CardContent>
              <Tabs value={selectedTab} onValueChange={setSelectedTab}>
                <TabsList className="mb-4">
                  <TabsTrigger value="pending">Pending ({serverStats.pending})</TabsTrigger>
                  <TabsTrigger value="accepted">Accepted</TabsTrigger>
                  <TabsTrigger value="expired">Expired</TabsTrigger>
                  <TabsTrigger value="all">All</TabsTrigger>
                </TabsList>
                {['pending', 'accepted', 'expired', 'all'].map((tab) => (
                  <TabsContent key={tab} value={tab}>
                    {loading ? (
                      <div className="flex justify-center py-12">
                        {ic(Loader2, 'h-8 w-8 animate-spin text-muted-foreground')}
                      </div>
                    ) : invitations.length === 0 ? (
                      <div className="text-center py-12">
                        {ic(Mail, 'h-12 w-12 text-muted-foreground mx-auto mb-4')}
                        <p className="text-muted-foreground">No invitations</p>
                      </div>
                    ) : (
                      <>
                        <Table>
                          <TableHeader>
                            <TableRow>
                              <TableHead>Recipient</TableHead>
                              <TableHead>Email</TableHead>
                              <TableHead>Clinic</TableHead>
                              <TableHead>Role</TableHead>
                              <TableHead>Status</TableHead>
                              <TableHead>Sent</TableHead>
                              <TableHead className="text-right">Actions</TableHead>
                            </TableRow>
                          </TableHeader>
                          <TableBody>
                            {invitations.map((inv) => (
                              <TableRow key={inv.id}>
                                <TableCell className="font-medium text-foreground">
                                  {inv.firstName} {inv.lastName}
                                </TableCell>
                                <TableCell className="text-muted-foreground">{inv.email}</TableCell>
                                <TableCell>
                                  <Badge variant="outline">{inv.clinicName || 'Unknown'}</Badge>
                                </TableCell>
                                <TableCell><Badge>{inv.role}</Badge></TableCell>
                                <TableCell>{getStatusBadge(inv)}</TableCell>
                                <TableCell className="text-muted-foreground text-sm">
                                  {formatDistanceToNow(new Date(inv.createdAt), { addSuffix: true })}
                                </TableCell>
                                <TableCell className="text-right">
                                  {(inv.status === 'pending' || inv.isExpired) && (
                                    <DropdownMenu>
                                      <DropdownMenuTrigger asChild>
                                        <Button variant="ghost" size="icon">
                                          {ic(MoreVertical, 'h-4 w-4')}
                                        </Button>
                                      </DropdownMenuTrigger>
                                      <DropdownMenuContent align="end">
                                        <DropdownMenuItem
                                          onClick={() => resendInvitation(inv)}
                                          disabled={resending === inv.id}
                                        >
                                          {resending === inv.id
                                            ? ic(Loader2, 'h-4 w-4 mr-2 animate-spin')
                                            : ic(Send, 'h-4 w-4 mr-2')}
                                          Resend Invitation
                                        </DropdownMenuItem>
                                      </DropdownMenuContent>
                                    </DropdownMenu>
                                  )}
                                </TableCell>
                              </TableRow>
                            ))}
                          </TableBody>
                        </Table>
                        {pagination.totalPages > 1 && (
                          <div className="flex items-center justify-between px-2 py-4 border-t mt-4">
                            <p className="text-sm text-muted-foreground">
                              Showing {(currentPage - 1) * pagination.limit + 1} to{' '}
                              {Math.min(currentPage * pagination.limit, pagination.total)} of{' '}
                              {pagination.total} invitations
                            </p>
                            <div className="flex items-center gap-2">
                              <Button
                                variant="outline"
                                size="sm"
                                onClick={() => handlePageChange(currentPage - 1)}
                                disabled={currentPage === 1 || loading}
                              >
                                Previous
                              </Button>
                              <span className="text-sm font-medium px-2">
                                Page {currentPage} of {pagination.totalPages}
                              </span>
                              <Button
                                variant="outline"
                                size="sm"
                                onClick={() => handlePageChange(currentPage + 1)}
                                disabled={currentPage === pagination.totalPages || loading}
                              >
                                Next
                              </Button>
                            </div>
                          </div>
                        )}
                      </>
                    )}
                  </TabsContent>
                ))}
              </Tabs>
            </CardContent>
          </Card>
        </div>
      );
    }
    
  • Step 2: Remove the local function and its view from SuperAdminDashboard In ui/src/components/dashboards/SuperAdminDashboard.tsx: a. Delete the entire function PlatformInvitations() block (lines ~84–330, everything from // Platform Invitations Component comment through the closing }). b. Delete this import (it was used only for the removed view):
    // remove this line:
    import RevenueDashboard from '../superadmin/RevenueDashboard';
    
    c. Delete these two view renders from the JSX:
    // remove both of these lines:
    {activeView === 'invitations' && <PlatformInvitations />}
    {activeView === 'revenue' && <RevenueDashboard />}
    
    d. Add the import for the new file at the top with the other superadmin imports:
    import PlatformInvitations from '../superadmin/PlatformInvitations';
    
    (Even though the invitations view is removed, PlatformInvitations is still referenced here as a safety net — actually since activeView === 'invitations' is gone, the import is unused in SuperAdminDashboard. Do not add this import. The component is only used in UserManagement from Task 4.)
  • Step 3: Verify build is clean
    cd ui && npx tsc --noEmit 2>&1 | head -30
    
    Expected: no errors related to PlatformInvitations or RevenueDashboard.
  • Step 4: Commit
    git add ui/src/components/superadmin/PlatformInvitations.tsx \
            ui/src/components/dashboards/SuperAdminDashboard.tsx
    git commit -m "refactor(ui): extract PlatformInvitations to own file, remove from dashboard view"
    

Task 4: Add Invitations tab + trial borders to UserManagement

Files:
  • Modify: ui/src/components/superadmin/UserManagement.tsx
Depends on: Task 3 (PlatformInvitations.tsx must exist)
  • Step 1: Add the Tabs import At the top of UserManagement.tsx, add to the existing shadcn imports block:
    import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
    
    Also add the PlatformInvitations import after the existing superadmin imports:
    import PlatformInvitations from './PlatformInvitations';
    
  • Step 2: Wrap the Users content in a Tabs shell The return statement currently opens with <div className="space-y-6">. Change the return to:
    return (
      <div className="space-y-6">
        <Tabs defaultValue="users">
          <TabsList>
            <TabsTrigger value="users">Users</TabsTrigger>
            <TabsTrigger value="invitations">Invitations</TabsTrigger>
          </TabsList>
    
          <TabsContent value="users" className="space-y-6 mt-4">
            {/* Header */}
            <div className="flex items-center justify-between">
              ... {/* all existing header JSX */}
            </div>
    
            {/* Stats */}
            <div className="grid gap-4 md:grid-cols-4">
              ... {/* all existing stats cards */}
            </div>
    
            {/* Search */}
            <div className="relative w-full max-w-md">
              ... {/* existing search input */}
            </div>
    
            {/* Users grouped by clinic */}
            {loading ? ( ... ) : clinics.length === 0 ? ( ... ) : (
              <div className="space-y-4">
                {clinics.filter(Boolean).map(clinic => (
                  ...
                ))}
              </div>
            )}
          </TabsContent>
    
          <TabsContent value="invitations" className="mt-4">
            <PlatformInvitations />
          </TabsContent>
        </Tabs>
    
        {/* Dialogs stay OUTSIDE the tabs — they rely on state, not rendering position */}
        {/* Add User Dialog */}
        ...
        {/* Delete Confirmation Dialog */}
        ...
        {/* User Detail Dialog */}
        ...
        {/* Reset Password Dialog */}
        ...
        {/* Status Change Dialog */}
        ...
        {/* Edit User Dialog */}
        ...
      </div>
    );
    
    The dialogs (all the <Dialog> and <AlertDialog> blocks at lines ~870–1389) stay outside the <Tabs> but still inside the outer <div>. Move only the visible UI (header, stats, search, user list) into the “users” TabsContent.
  • Step 3: Add amber border to trial clinic cards In the clinics.filter(Boolean).map(clinic => ...) loop, the <Card> for each clinic currently has no conditional class. Change it to:
    <Card
      key={clinic.id}
      className={clinic.subscriptionStatus === 'trial' ? 'border-amber-500/30' : undefined}
    >
    
  • Step 4: Verify Start the dev server (cd ui && npm run dev), navigate to User Management. Confirm:
    • Two tabs appear: “Users” and “Invitations”
    • Invitations tab shows the platform invitations list
    • Any trial clinic has a subtle amber border on its card header
    • All dialogs (reset password, delete, edit) still open correctly from the Users tab
  • Step 5: Commit
    git add ui/src/components/superadmin/UserManagement.tsx
    git commit -m "feat(ui): add Invitations tab to UserManagement, amber border on trial clinic cards"
    

Task 5: Create standalone InvoiceStudio page

Files:
  • Create: ui/src/components/superadmin/InvoiceStudio.tsx
Background: This replaces <BillingManagement defaultTab="manual" /> for the invoice-studio nav entry. It owns clinic fetching, invoice history, and the trial/active clinic grouping in the selector.
  • Step 1: Create the file Create ui/src/components/superadmin/InvoiceStudio.tsx:
    import React, { useState, useEffect } from 'react';
    import {
      Building2,
      Loader2,
      Clock,
      CheckCircle2,
      AlertTriangle,
      Download,
      Send,
    } from 'lucide-react';
    import {
      Select,
      SelectContent,
      SelectGroup,
      SelectItem,
      SelectLabel,
      SelectTrigger,
      SelectValue,
    } from '@/components/ui/select';
    import { Badge } from '@/components/ui/badge';
    import { Button } from '@/components/ui/button';
    import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
    import { InvoiceGenerator } from './InvoiceGenerator';
    import { getAuthHeaders } from '@/lib/serverComm';
    import { getApiBaseUrl } from '@/lib/api-url';
    import { toast } from '@/lib/toast';
    
    interface Clinic {
      id: string;
      name: string;
      city?: string;
      email?: string;
      subscriptionStatus?: string;
    }
    
    export default function InvoiceStudio() {
      const [clinics, setClinics] = useState<Clinic[]>([]);
      const [allInvoices, setAllInvoices] = useState<any[]>([]);
      const [selectedClinic, setSelectedClinic] = useState<Clinic | null>(null);
      const [loading, setLoading] = useState(true);
      const [pdfLoading, setPdfLoading] = useState<Record<string, boolean>>({});
      const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
    
      useEffect(() => {
        loadData();
      }, []);
    
      const loadData = async () => {
        try {
          setLoading(true);
          const headers = getAuthHeaders();
          const [clinicsRes, invoicesRes] = await Promise.all([
            fetch(`${getApiBaseUrl()}/api/v1/protected/billing/clinics`, { headers }),
            fetch(`${getApiBaseUrl()}/api/v1/protected/billing/invoices`, { headers }),
          ]);
          if (clinicsRes.ok) setClinics(await clinicsRes.json());
          if (invoicesRes.ok) {
            const data = await invoicesRes.json();
            setAllInvoices(Array.isArray(data) ? data : []);
          }
        } catch {
          toast.error('Failed to load invoice data');
        } finally {
          setLoading(false);
        }
      };
    
      const handleDownloadPdf = async (invoice: any) => {
        if (pdfLoading[invoice.id]) return;
        setPdfLoading(p => ({ ...p, [invoice.id]: true }));
        try {
          const res = await fetch(
            `${getApiBaseUrl()}/api/v1/protected/billing/invoices/${invoice.id}/pdf`,
            { headers: getAuthHeaders() }
          );
          if (!res.ok) throw new Error(`Server returned ${res.status}`);
          const blob = await res.blob();
          const url = URL.createObjectURL(blob);
          const a = document.createElement('a');
          a.href = url;
          a.download = `OdontoX-Invoice-${invoice.invoiceNumber || invoice.id}.pdf`;
          document.body.appendChild(a);
          a.click();
          document.body.removeChild(a);
          setTimeout(() => URL.revokeObjectURL(url), 10_000);
        } catch (e: any) {
          toast.error(e.message || 'Failed to download PDF');
        } finally {
          setPdfLoading(p => ({ ...p, [invoice.id]: false }));
        }
      };
    
      const handleInvoiceAction = async (invoice: any, action: 'mark-paid' | 'portal') => {
        const key = `${invoice.id}:${action}`;
        if (actionLoading[key]) return;
        setActionLoading(p => ({ ...p, [key]: true }));
        try {
          const res = await fetch(
            `${getApiBaseUrl()}/api/v1/protected/billing/invoices/${invoice.id}/${action}`,
            {
              method: 'POST',
              headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
            }
          );
          const payload = await res.json().catch(() => ({}));
          if (!res.ok) throw new Error(payload?.message || payload?.error || `Server returned ${res.status}`);
          toast.success(action === 'mark-paid' ? 'Invoice marked as paid' : 'Published to clinic portal');
          await loadData();
        } catch (e: any) {
          toast.error(e.message || 'Action failed');
        } finally {
          setActionLoading(p => ({ ...p, [key]: false }));
        }
      };
    
      const activeClinics = clinics.filter(c => c.subscriptionStatus === 'active');
      const trialClinics = clinics.filter(c => c.subscriptionStatus !== 'active');
      const isTrialSelected = !!selectedClinic && selectedClinic.subscriptionStatus !== 'active';
    
      return (
        <div className="space-y-6">
          {/* Page header */}
          <div className="flex items-center gap-4 flex-wrap">
            <div>
              <h1 className="text-2xl font-bold tracking-tight">Invoice Studio</h1>
              <p className="text-sm text-muted-foreground">Create and manage platform invoices</p>
            </div>
            <div className="ml-auto flex items-center gap-3">
              <Select
                value={selectedClinic?.id ?? ''}
                onValueChange={(val) => {
                  setSelectedClinic(clinics.find(c => c.id === val) ?? null);
                }}
              >
                <SelectTrigger className="w-[300px]">
                  <SelectValue placeholder="Select a clinic to invoice..." />
                </SelectTrigger>
                <SelectContent>
                  {activeClinics.length > 0 && (
                    <SelectGroup>
                      <SelectLabel>Active Clinics</SelectLabel>
                      {activeClinics.map(c => (
                        <SelectItem key={c.id} value={c.id}>
                          {c.name}{c.city ? ` (${c.city})` : ''}
                        </SelectItem>
                      ))}
                    </SelectGroup>
                  )}
                  {trialClinics.length > 0 && (
                    <SelectGroup>
                      <SelectLabel>Trial / Other</SelectLabel>
                      {trialClinics.map(c => (
                        <SelectItem key={c.id} value={c.id}>
                          {c.name}{c.city ? ` (${c.city})` : ''} [TRIAL]
                        </SelectItem>
                      ))}
                    </SelectGroup>
                  )}
                </SelectContent>
              </Select>
              <Badge variant="outline" className="text-primary border-primary/30 font-medium">PKR</Badge>
            </div>
          </div>
    
          {/* Trial warning */}
          {isTrialSelected && (
            <div className="rounded-lg border border-amber-500/30 bg-amber-50/50 dark:bg-amber-900/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-200">
              <strong>Trial clinic selected</strong> — invoicing is allowed but confirm with the clinic before issuing.
            </div>
          )}
    
          {/* Two-column layout: form left, history sidebar right */}
          <div className="grid gap-6 lg:grid-cols-[1fr_380px] items-start">
            {/* Left: invoice generator */}
            <div>
              {selectedClinic ? (
                <InvoiceGenerator
                  clinicId={selectedClinic.id}
                  clinicName={selectedClinic.name}
                  onSuccess={loadData}
                />
              ) : (
                <div className="h-80 flex flex-col items-center justify-center border-2 border-dashed rounded-xl opacity-60">
                  <Building2 className="h-10 w-10 mb-2 text-muted-foreground" />
                  <p className="text-muted-foreground">Select a clinic above to start generating an invoice</p>
                </div>
              )}
            </div>
    
            {/* Right: sticky invoice history */}
            <div className="lg:sticky lg:top-6">
              <Card>
                <CardHeader className="pb-3">
                  <CardTitle className="text-base">Invoice History</CardTitle>
                  <CardDescription>All platform invoices</CardDescription>
                </CardHeader>
                <CardContent className="p-0">
                  {loading ? (
                    <div className="flex justify-center py-8">
                      <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
                    </div>
                  ) : allInvoices.length === 0 ? (
                    <p className="text-center text-sm text-muted-foreground py-8">No invoices yet</p>
                  ) : (
                    <div className="divide-y max-h-[600px] overflow-y-auto">
                      {allInvoices.map((item: any) => (
                        <div
                          key={item.invoice.id}
                          className="flex items-center gap-3 px-4 py-3 hover:bg-muted/30 transition-colors"
                        >
                          <div
                            className={`h-7 w-7 rounded-full flex items-center justify-center flex-shrink-0 ${
                              item.invoice.status === 'paid'
                                ? 'bg-green-100 dark:bg-green-900/30'
                                : item.invoice.status === 'open'
                                ? 'bg-yellow-100 dark:bg-yellow-900/30'
                                : 'bg-muted'
                            }`}
                          >
                            {item.invoice.status === 'paid' ? (
                              <CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
                            ) : item.invoice.status === 'open' ? (
                              <Clock className="h-3.5 w-3.5 text-yellow-600" />
                            ) : (
                              <AlertTriangle className="h-3.5 w-3.5 text-muted-foreground" />
                            )}
                          </div>
                          <div className="flex-1 min-w-0">
                            <p className="text-xs font-medium truncate">{item.invoice.invoiceNumber}</p>
                            <p className="text-[11px] text-muted-foreground truncate">
                              {item.clinic?.name || 'Unknown'}
                            </p>
                          </div>
                          <div className="text-right flex-shrink-0">
                            <p className="text-xs font-semibold">
                              {item.invoice.currency} {Number(item.invoice.amount).toLocaleString()}
                            </p>
                            <div className="flex gap-1 justify-end mt-1">
                              <Button
                                variant="ghost"
                                size="icon"
                                className="h-5 w-5"
                                title="Download PDF"
                                onClick={() => handleDownloadPdf(item.invoice)}
                                disabled={pdfLoading[item.invoice.id]}
                              >
                                {pdfLoading[item.invoice.id]
                                  ? <Loader2 className="h-3 w-3 animate-spin" />
                                  : <Download className="h-3 w-3" />}
                              </Button>
                              {item.invoice.status !== 'paid' && (
                                <Button
                                  variant="ghost"
                                  size="icon"
                                  className="h-5 w-5"
                                  title="Mark as paid"
                                  onClick={() => handleInvoiceAction(item.invoice, 'mark-paid')}
                                  disabled={actionLoading[`${item.invoice.id}:mark-paid`]}
                                >
                                  {actionLoading[`${item.invoice.id}:mark-paid`]
                                    ? <Loader2 className="h-3 w-3 animate-spin" />
                                    : <CheckCircle2 className="h-3 w-3" />}
                                </Button>
                              )}
                              <Button
                                variant="ghost"
                                size="icon"
                                className="h-5 w-5"
                                title="Publish to portal"
                                onClick={() => handleInvoiceAction(item.invoice, 'portal')}
                                disabled={actionLoading[`${item.invoice.id}:portal`]}
                              >
                                {actionLoading[`${item.invoice.id}:portal`]
                                  ? <Loader2 className="h-3 w-3 animate-spin" />
                                  : <Send className="h-3 w-3" />}
                              </Button>
                            </div>
                          </div>
                        </div>
                      ))}
                    </div>
                  )}
                </CardContent>
              </Card>
            </div>
          </div>
        </div>
      );
    }
    
  • Step 2: Verify TypeScript
    cd ui && npx tsc --noEmit 2>&1 | grep InvoiceStudio
    
    Expected: no output (no errors).
  • Step 3: Commit
    git add ui/src/components/superadmin/InvoiceStudio.tsx
    git commit -m "feat(ui): create standalone InvoiceStudio full-page module"
    

Task 6: Update BillingManagement — remove manual tab, add revenue tab, split subscriptions

Files:
  • Modify: ui/src/components/superadmin/BillingManagement.tsx
  • Step 1: Add RevenueDashboard import At the top of BillingManagement.tsx, add after the existing imports:
    import RevenueDashboard from './RevenueDashboard';
    
  • Step 2: Remove the Manual Invoice tab trigger In the <TabsList> block (around line 631), remove this entire trigger:
    <TabsTrigger value="manual" className="bg-primary/5 text-primary border-primary/20">Manual Invoice</TabsTrigger>
    
  • Step 3: Remove the Manual Invoice tab content Delete the entire <TabsContent value="manual"> block (~lines 917–981). It starts with:
    {/* Manual Invoice Tab */}
    <TabsContent value="manual">
    
    and ends with its closing </TabsContent>.
  • Step 4: Add Revenue tab trigger and content After the closing </TabsContent> of the Invoices tab, add:
    {/* Revenue Tab */}
    <TabsContent value="revenue">
      <RevenueDashboard />
    </TabsContent>
    
    And add the trigger to <TabsList> after the “Invoices” trigger:
    <TabsTrigger value="revenue">Revenue</TabsTrigger>
    
  • Step 5: Split subscriptions table by trial vs active In the subscriptions <TabsContent value="subscriptions"> block, replace the existing single-table render with a split render. Find the clinics.length === 0 ternary and replace the else branch:
    {clinics.length === 0 ? (
      <div className="text-center py-12 text-muted-foreground">
        <CreditCard className="h-12 w-12 mx-auto mb-4 opacity-50" />
        <p>No active subscriptions found</p>
      </div>
    ) : (
      (() => {
        const active = clinics.filter(c => c.subscriptionStatus === 'active');
        const trial = clinics.filter(c => c.subscriptionStatus !== 'active');
        const renderRows = (rows: typeof clinics) =>
          rows.map((clinic) => (
            <tr key={clinic.id} className="hover:bg-muted/30">
              <td className="px-4 py-3 font-medium">{clinic.name}</td>
              <td className="px-4 py-3">
                <Badge
                  variant={clinic.subscriptionStatus === 'active' ? 'default' : 'secondary'}
                  className="capitalize"
                >
                  {clinic.subscriptionStatus || 'trial'}
                </Badge>
              </td>
              <td className="px-4 py-3">
                {plans.find(p => p.id === clinic.subscriptionPlanId)?.planName || 'N/A'}
              </td>
              <td className="px-4 py-3">{clinic.userCount || 0} users</td>
              <td className="px-4 py-3 text-right text-muted-foreground">
                {new Date(clinic.createdAt).toLocaleDateString()}
              </td>
            </tr>
          ));
        return (
          <div className="space-y-4">
            {active.length > 0 && (
              <div>
                <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2 px-1">
                  Active Tenants
                </p>
                <div className="rounded-md border">
                  <table className="w-full text-sm">
                    <thead className="bg-muted/50 border-b">
                      <tr>
                        <th className="px-4 py-3 text-left font-medium">Clinic</th>
                        <th className="px-4 py-3 text-left font-medium">Status</th>
                        <th className="px-4 py-3 text-left font-medium">Plan</th>
                        <th className="px-4 py-3 text-left font-medium">Users</th>
                        <th className="px-4 py-3 text-right font-medium">Joined</th>
                      </tr>
                    </thead>
                    <tbody className="divide-y">{renderRows(active)}</tbody>
                  </table>
                </div>
              </div>
            )}
            {trial.length > 0 && (
              <div>
                <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2 px-1 mt-4">
                  Trial / Inactive
                </p>
                <div className="rounded-md border border-amber-500/20">
                  <table className="w-full text-sm opacity-75">
                    <thead className="bg-muted/50 border-b">
                      <tr>
                        <th className="px-4 py-3 text-left font-medium">Clinic</th>
                        <th className="px-4 py-3 text-left font-medium">Status</th>
                        <th className="px-4 py-3 text-left font-medium">Plan</th>
                        <th className="px-4 py-3 text-left font-medium">Users</th>
                        <th className="px-4 py-3 text-right font-medium">Joined</th>
                      </tr>
                    </thead>
                    <tbody className="divide-y">{renderRows(trial)}</tbody>
                  </table>
                </div>
              </div>
            )}
          </div>
        );
      })()
    )}
    
  • Step 6: Verify Navigate to Billing Management → Subscriptions tab: active clinics appear at top, trial clinics below with amber border and dimmed style. Revenue tab shows the revenue dashboard. Manual Invoice tab is gone.
  • Step 7: Commit
    git add ui/src/components/superadmin/BillingManagement.tsx
    git commit -m "feat(ui): remove manual invoice tab, add revenue tab, split subscriptions by trial/active"
    

Task 7: Wire InvoiceStudio into SuperAdminDashboard

Files:
  • Modify: ui/src/components/dashboards/SuperAdminDashboard.tsx
Depends on: Task 5 (InvoiceStudio.tsx must exist), Task 3 (revenue/invitations views already removed)
  • Step 1: Add InvoiceStudio import Add after the existing superadmin component imports:
    import InvoiceStudio from '../superadmin/InvoiceStudio';
    
  • Step 2: Replace the invoice-studio view render Find this line in the JSX:
    {activeView === 'invoice-studio' && <BillingManagement defaultTab="manual" />}
    
    Replace it with:
    {activeView === 'invoice-studio' && <InvoiceStudio />}
    
  • Step 3: Verify TypeScript
    cd ui && npx tsc --noEmit 2>&1 | head -20
    
    Expected: no errors.
  • Step 4: Commit
    git add ui/src/components/dashboards/SuperAdminDashboard.tsx
    git commit -m "feat(ui): wire InvoiceStudio as standalone view in SuperAdminDashboard"
    

Task 8: Remove revenue + invitations from nav

Files:
  • Modify: ui/src/hooks/useNavItems.ts
  • Step 1: Remove the revenue nav entry In the superadmin case, under the 'Overview' group items array, find and delete:
    { id: 'revenue', label: 'Revenue Cycle', icon: ic(BarChart3) },
    
  • Step 2: Remove the invitations nav entry Under the 'Management' group items array, find and delete:
    { id: 'invitations', label: 'Invitations', icon: ic(Send) },
    
  • Step 3: Full smoke test Start the dev server and log in as superadmin. Verify:
    1. Sidebar no longer shows “Revenue Cycle” or “Invitations” as top-level entries
    2. Invoice Studio opens the new full-page layout (clinic selector + form + history sidebar)
    3. Billing Management → Revenue tab shows the revenue dashboard
    4. User Management → Invitations tab shows platform-wide invitations
    5. Selecting a trial clinic in Invoice Studio shows the amber warning banner
    6. Billing Management → Subscriptions tab shows two sections (Active / Trial)
    7. Trial clinic cards in User Management have an amber border accent
  • Step 4: Commit
    git add ui/src/hooks/useNavItems.ts
    git commit -m "feat(ui): remove revenue and invitations top-level nav entries (folded into parent modules)"
    

Self-Review

Spec coverage:
  • ✅ Invoice Studio standalone full page (Tasks 5, 7)
  • ✅ Nav −2 entries: revenue + invitations (Tasks 3, 8)
  • ✅ Revenue folded into Billing Management (Task 6)
  • ✅ Invitations folded into User Management (Tasks 3, 4)
  • ✅ Bug A: active user count fix (Task 2)
  • ✅ Bug B: re-add after delete fix (Task 1)
  • ✅ Trial clinic selector grouping in Invoice Studio (Task 5)
  • ✅ Trial warning banner in Invoice Studio (Task 5)
  • ✅ BillingManagement subscriptions split by trial/active (Task 6)
  • ✅ Amber border on trial clinic headers in UserManagement (Task 4)
Type consistency: Clinic interface defined in Task 5 (InvoiceStudio.tsx) is self-contained and not shared with other tasks. handleDownloadPdf and handleInvoiceAction in InvoiceStudio have the same signatures as their equivalents in BillingManagement — no cross-file type conflicts. No placeholders: All code blocks are complete and runnable.