Skip to main content

OdontoX Enterprise — Network Hub Implementation Plan (Plan B)

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. Prerequisite: Plan A (2026-06-11-enterprise-foundation.md) must be fully deployed before starting this plan.
Goal: Build the Network Hub — the org_admin’s home screen at /network — with 6 modules: Network Overview, Cross-Branch Analytics, Branch Management, Staff, Shared Templates, and Billing. Wire the org-level API routes that back these pages. Architecture: New ui/src/components/network/ directory houses all org-level pages. The Network Hub mounts at /network in the React router, behind an OrgAuthGuard that checks user.role === 'org_admin'. The sidebar in AppLayout.tsx gains an orgMode variant that shows org-level nav items and a branch drill-down list. API routes hang off the existing /api/v1/org prefix (registered in Plan A) with requireOrgContext already applied. Tech Stack: React, TanStack Query, Hono, Drizzle ORM, Recharts (already in project), shadcn/ui components, Vitest

File Map

ActionPathResponsibility
Createui/src/components/network/OrgAuthGuard.tsxredirect non-org_admin away from /network
Createui/src/components/network/NetworkLayout.tsxorg sidebar + header wrapper
Createui/src/components/network/NetworkOverview.tsxlive stats grid
Createui/src/components/network/NetworkAnalytics.tsxcross-branch analytics
Createui/src/components/network/NetworkBranches.tsxbranch list + create
Createui/src/components/network/NetworkStaff.tsxall-staff table
Createui/src/components/network/NetworkTemplates.tsxshared template library
Createui/src/components/network/NetworkBilling.tsxplan + add-ons summary
Modifyui/src/App.tsx (or router file)mount /network/* routes
Modifyui/src/components/layout/AppLayout.tsxorgMode sidebar variant
Createserver/src/routes/org.tsreplace skeleton with real handlers

Task 1: OrgAuthGuard + NetworkLayout

Files:
  • Create: ui/src/components/network/OrgAuthGuard.tsx
  • Create: ui/src/components/network/NetworkLayout.tsx
  • Step 1: Write OrgAuthGuard
// ui/src/components/network/OrgAuthGuard.tsx
import { useNavigate } from 'react-router-dom';
import { useEffect } from 'react';

interface Props {
  user: { role: string } | null;
  children: React.ReactNode;
}

export function OrgAuthGuard({ user, children }: Props) {
  const navigate = useNavigate();
  useEffect(() => {
    if (!user || user.role !== 'org_admin') {
      navigate('/dashboard', { replace: true });
    }
  }, [user, navigate]);

  if (!user || user.role !== 'org_admin') return null;
  return <>{children}</>;
}
  • Step 2: Write NetworkLayout
// ui/src/components/network/NetworkLayout.tsx
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { LayoutDashboard, BarChart3, Building2, Users, FileText, CreditCard, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';

const NAV_ITEMS = [
  { to: '/network', label: 'Network Overview', icon: LayoutDashboard, end: true },
  { to: '/network/analytics', label: 'Analytics', icon: BarChart3 },
  { to: '/network/branches', label: 'Branches', icon: Building2 },
  { to: '/network/staff', label: 'Staff', icon: Users },
  { to: '/network/templates', label: 'Templates', icon: FileText },
  { to: '/network/billing', label: 'Billing', icon: CreditCard },
];

interface NetworkLayoutProps {
  orgName: string;
  branches: Array<{ clinicId: string; branchDisplayName: string }>;
}

export function NetworkLayout({ orgName, branches }: NetworkLayoutProps) {
  const navigate = useNavigate();

  return (
    <div className="flex h-screen bg-background">
      {/* Sidebar */}
      <aside className="w-56 flex-shrink-0 border-r bg-card flex flex-col">
        <div className="px-4 py-4 border-b">
          <div className="font-semibold text-sm text-primary">{orgName}</div>
          <div className="text-xs text-muted-foreground mt-0.5">Head Office</div>
        </div>

        <nav className="flex-1 py-3 px-2 space-y-0.5">
          {NAV_ITEMS.map((item) => (
            <NavLink
              key={item.to}
              to={item.to}
              end={item.end}
              className={({ isActive }) =>
                cn('flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors',
                  isActive
                    ? 'bg-primary/10 text-primary font-medium'
                    : 'text-muted-foreground hover:bg-accent hover:text-foreground'
                )
              }
            >
              <item.icon className="h-4 w-4 shrink-0" />
              {item.label}
            </NavLink>
          ))}
        </nav>

        {/* Branch drill-down list */}
        <div className="px-3 py-2 border-t">
          <div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
            Branches
          </div>
          <div className="space-y-0.5 max-h-48 overflow-y-auto">
            {branches.map((b) => (
              <button
                key={b.clinicId}
                onClick={() => navigate(`/dashboard?clinicId=${b.clinicId}`)}
                className="w-full flex items-center gap-1.5 px-2 py-1 rounded text-xs text-muted-foreground hover:text-foreground hover:bg-accent transition-colors text-left"
              >
                <ChevronRight className="h-3 w-3 shrink-0" />
                {b.branchDisplayName}
              </button>
            ))}
          </div>
        </div>
      </aside>

      {/* Main content */}
      <main className="flex-1 overflow-y-auto">
        <Outlet />
      </main>
    </div>
  );
}
  • Step 3: Commit
git add ui/src/components/network/OrgAuthGuard.tsx ui/src/components/network/NetworkLayout.tsx
git commit -m "feat(enterprise): OrgAuthGuard + NetworkLayout sidebar"

Task 2: API — GET /org/overview

Files:
  • Modify: server/src/routes/org.ts
  • Step 1: Replace the ping handler with the overview endpoint
// server/src/routes/org.ts  — full file
import { Hono } from 'hono';
import { eq, and, sql, count, sum, isNull, inArray } from 'drizzle-orm';
import { requireOrgContext } from '../middleware/org-context';
import { getReadDb, getWriteDb } from '../lib/db';
import { handleError } from '../lib/errors';
import { formatDatePKT } from '../lib/pkt';
import * as schema from '../schema';

const org = new Hono();
org.use('*', requireOrgContext);

/** Returns today's date in PKT as YYYY-MM-DD */
function todayPKT(): string {
  return formatDatePKT(new Date());
}

// GET /api/v1/org/overview
// Returns today's network-wide stats across all branches in the org.
org.get('/overview', async (c) => {
  try {
    const orgCtx = c.get('orgContext');
    const db = getReadDb();
    const today = todayPKT();

    // Fetch all clinic IDs in this org
    const branchRows = await db
      .select({ clinicId: schema.organizationClinics.clinicId, branchDisplayName: schema.organizationClinics.branchDisplayName })
      .from(schema.organizationClinics)
      .where(eq(schema.organizationClinics.organizationId, orgCtx.organizationId));

    const clinicIds = branchRows.map((r) => r.clinicId);

    if (!clinicIds.length) {
      return c.json({ totalAppointments: 0, totalRevenue: 0, activeBranches: 0, noShowRate: 0, branches: [] });
    }

    // Today's appointment count
    const [apptRow] = await db
      .select({ total: count() })
      .from(schema.appointments)
      .where(
        and(
          inArray(schema.appointments.clinicId, clinicIds),
          eq(schema.appointments.appointmentDate, today),
          isNull(schema.appointments.deletedAt),
        )
      );

    // Today's completed payments revenue (payments → invoices for clinic scoping)
    const [revenueRow] = await db
      .select({ total: sum(schema.payments.amount) })
      .from(schema.payments)
      .innerJoin(schema.invoices, eq(schema.invoices.id, schema.payments.invoiceId))
      .where(
        and(
          inArray(schema.invoices.clinicId, clinicIds),
          sql`DATE(${schema.payments.createdAt} AT TIME ZONE 'Asia/Karachi') = ${today}`,
        )
      );

    // No-show count today
    const [noShowRow] = await db
      .select({ total: count() })
      .from(schema.appointments)
      .where(
        and(
          inArray(schema.appointments.clinicId, clinicIds),
          eq(schema.appointments.appointmentDate, today),
          eq(schema.appointments.status, 'no_show'),
          isNull(schema.appointments.deletedAt),
        )
      );

    const totalAppts = Number(apptRow?.total ?? 0);
    const noShows = Number(noShowRow?.total ?? 0);

    return c.json({
      totalAppointments: totalAppts,
      totalRevenue: Number(revenueRow?.total ?? 0),
      activeBranches: branchRows.length,
      noShowRate: totalAppts > 0 ? Math.round((noShows / totalAppts) * 100) : 0,
      branches: branchRows,
    });
  } catch (err) {
    return handleError(c, err);
  }
});

// GET /api/v1/org/branches
org.get('/branches', async (c) => {
  try {
    const orgCtx = c.get('orgContext');
    const db = getReadDb();
    const rows = await db
      .select({
        clinicId: schema.organizationClinics.clinicId,
        branchDisplayName: schema.organizationClinics.branchDisplayName,
        city: schema.organizationClinics.city,
        sortOrder: schema.organizationClinics.sortOrder,
        clinicName: schema.clinics.name,
        isActive: schema.clinics.isActive,
      })
      .from(schema.organizationClinics)
      .innerJoin(schema.clinics, eq(schema.clinics.id, schema.organizationClinics.clinicId))
      .where(eq(schema.organizationClinics.organizationId, orgCtx.organizationId))
      .orderBy(schema.organizationClinics.sortOrder);
    return c.json(rows);
  } catch (err) {
    return handleError(c, err);
  }
});

// GET /api/v1/org/staff
org.get('/staff', async (c) => {
  try {
    const orgCtx = c.get('orgContext');
    const db = getReadDb();

    const branchRows = await db
      .select({ clinicId: schema.organizationClinics.clinicId })
      .from(schema.organizationClinics)
      .where(eq(schema.organizationClinics.organizationId, orgCtx.organizationId));

    const clinicIds = branchRows.map((r) => r.clinicId);
    if (!clinicIds.length) return c.json([]);

    const staff = await db
      .select({
        userId: schema.userClinicAssignments.userId,
        clinicId: schema.userClinicAssignments.clinicId,
        role: schema.userClinicAssignments.role,
        firstName: schema.users.firstName,
        lastName: schema.users.lastName,
        email: schema.users.email,
        status: schema.userClinicAssignments.status,
      })
      .from(schema.userClinicAssignments)
      .innerJoin(schema.users, eq(schema.users.id, schema.userClinicAssignments.userId))
      .where(
        and(
          inArray(schema.userClinicAssignments.clinicId, clinicIds),
          eq(schema.userClinicAssignments.status, 'active'),
        )
      );

    return c.json(staff);
  } catch (err) {
    return handleError(c, err);
  }
});

// GET /api/v1/org/templates
org.get('/templates', async (c) => {
  try {
    const orgCtx = c.get('orgContext');
    const db = getReadDb();
    const rows = await db
      .select()
      .from(schema.organizationTemplates)
      .where(eq(schema.organizationTemplates.organizationId, orgCtx.organizationId));
    return c.json(rows);
  } catch (err) {
    return handleError(c, err);
  }
});

// POST /api/v1/org/templates
org.post('/templates', async (c) => {
  try {
    const orgCtx = c.get('orgContext');
    const user = c.get('user');
    const body = await c.req.json<{ type: string; name: string; contentJson: object }>();
    const db = getWriteDb();
    const [row] = await db
      .insert(schema.organizationTemplates)
      .values({
        organizationId: orgCtx.organizationId,
        type: body.type,
        name: body.name,
        contentJson: body.contentJson,
        createdBy: user.id,
      })
      .returning();
    return c.json(row, 201);
  } catch (err) {
    return handleError(c, err);
  }
});

// GET /api/v1/org/info
org.get('/info', async (c) => {
  try {
    const orgCtx = c.get('orgContext');
    const db = getReadDb();
    const [org] = await db
      .select()
      .from(schema.organizations)
      .where(eq(schema.organizations.id, orgCtx.organizationId));
    return c.json(org);
  } catch (err) {
    return handleError(c, err);
  }
});

export default org;
  • Step 2: Commit
git add server/src/routes/org.ts
git commit -m "feat(enterprise): org API routes — overview, branches, staff, templates, info"

Task 3: Network Overview page

Files:
  • Create: ui/src/components/network/NetworkOverview.tsx
  • Step 1: Write the component
// ui/src/components/network/NetworkOverview.tsx
import { useQuery } from '@tanstack/react-query';
import { Calendar, Banknote, Building2, TrendingDown } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';

interface OverviewData {
  totalAppointments: number;
  totalRevenue: number;
  activeBranches: number;
  noShowRate: number;
  branches: Array<{ clinicId: string; branchDisplayName: string }>;
}

function StatCard({ icon: Icon, label, value, sub }: {
  icon: React.ElementType; label: string; value: string; sub?: string;
}) {
  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between pb-2">
        <CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
        <Icon className="h-4 w-4 text-muted-foreground" />
      </CardHeader>
      <CardContent>
        <div className="text-2xl font-bold">{value}</div>
        {sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
      </CardContent>
    </Card>
  );
}

export function NetworkOverview() {
  const { data, isLoading } = useQuery<OverviewData>({
    queryKey: ['org', 'overview'],
    queryFn: async () => {
      const res = await fetch('/api/v1/org/overview', { credentials: 'include' });
      if (!res.ok) throw new Error('Failed to load overview');
      return res.json();
    },
    refetchInterval: 60_000,
  });

  if (isLoading) {
    return <div className="p-8 text-muted-foreground text-sm">Loading network overview…</div>;
  }

  const today = new Date().toLocaleDateString('en-PK', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });

  return (
    <div className="p-8">
      <div className="mb-6">
        <h1 className="text-2xl font-bold">Network Overview</h1>
        <p className="text-muted-foreground text-sm mt-1">{today}</p>
      </div>

      <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
        <StatCard
          icon={Calendar}
          label="Appointments Today"
          value={String(data?.totalAppointments ?? 0)}
        />
        <StatCard
          icon={Banknote}
          label="Revenue Today"
          value={`PKR ${(data?.totalRevenue ?? 0).toLocaleString()}`}
        />
        <StatCard
          icon={Building2}
          label="Active Branches"
          value={`${data?.activeBranches ?? 0}`}
        />
        <StatCard
          icon={TrendingDown}
          label="No-Show Rate"
          value={`${data?.noShowRate ?? 0}%`}
          sub="today"
        />
      </div>

      <div>
        <h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
          Branches
        </h2>
        <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
          {data?.branches.map((b) => (
            <Card key={b.clinicId} className="cursor-pointer hover:border-primary/40 transition-colors">
              <CardContent className="p-4">
                <div className="font-medium text-sm">{b.branchDisplayName}</div>
              </CardContent>
            </Card>
          ))}
        </div>
      </div>
    </div>
  );
}
  • Step 2: Commit
git add ui/src/components/network/NetworkOverview.tsx
git commit -m "feat(enterprise): NetworkOverview page — live stats + branch grid"

Task 4: Network Branches page

Files:
  • Create: ui/src/components/network/NetworkBranches.tsx
  • Step 1: Write the component
// ui/src/components/network/NetworkBranches.tsx
import { useQuery } from '@tanstack/react-query';
import { Building2, MapPin } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';

interface Branch {
  clinicId: string;
  branchDisplayName: string;
  city: string;
  clinicName: string;
  isActive: boolean;
  sortOrder: number;
}

export function NetworkBranches() {
  const { data: branches = [], isLoading } = useQuery<Branch[]>({
    queryKey: ['org', 'branches'],
    queryFn: async () => {
      const res = await fetch('/api/v1/org/branches', { credentials: 'include' });
      if (!res.ok) throw new Error('Failed to load branches');
      return res.json();
    },
  });

  if (isLoading) return <div className="p-8 text-muted-foreground text-sm">Loading branches…</div>;

  return (
    <div className="p-8">
      <div className="flex items-center justify-between mb-6">
        <div>
          <h1 className="text-2xl font-bold">Branches</h1>
          <p className="text-muted-foreground text-sm mt-1">{branches.length} location{branches.length !== 1 ? 's' : ''}</p>
        </div>
      </div>

      <div className="space-y-3">
        {branches.map((b) => (
          <Card key={b.clinicId}>
            <CardContent className="p-4 flex items-center gap-4">
              <div className="flex-shrink-0 h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center">
                <Building2 className="h-4 w-4 text-primary" />
              </div>
              <div className="flex-1 min-w-0">
                <div className="font-medium text-sm">{b.branchDisplayName}</div>
                <div className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
                  <MapPin className="h-3 w-3" />{b.city}
                </div>
              </div>
              <Badge variant={b.isActive ? 'default' : 'secondary'}>
                {b.isActive ? 'Active' : 'Inactive'}
              </Badge>
            </CardContent>
          </Card>
        ))}
      </div>
    </div>
  );
}
  • Step 2: Commit
git add ui/src/components/network/NetworkBranches.tsx
git commit -m "feat(enterprise): NetworkBranches page"

Task 5: Network Staff page

Files:
  • Create: ui/src/components/network/NetworkStaff.tsx
  • Step 1: Write the component
// ui/src/components/network/NetworkStaff.tsx
import { useQuery } from '@tanstack/react-query';
import { Badge } from '@/components/ui/badge';

interface StaffMember {
  userId: string;
  clinicId: string;
  role: string;
  firstName: string;
  lastName: string;
  email: string;
  status: string;
}

const ROLE_LABELS: Record<string, string> = {
  admin: 'Admin',
  doctor: 'Doctor',
  receptionist: 'Receptionist',
  assistant: 'Assistant',
};

export function NetworkStaff() {
  const { data: staff = [], isLoading } = useQuery<StaffMember[]>({
    queryKey: ['org', 'staff'],
    queryFn: async () => {
      const res = await fetch('/api/v1/org/staff', { credentials: 'include' });
      if (!res.ok) throw new Error('Failed to load staff');
      return res.json();
    },
  });

  if (isLoading) return <div className="p-8 text-muted-foreground text-sm">Loading staff…</div>;

  return (
    <div className="p-8">
      <div className="mb-6">
        <h1 className="text-2xl font-bold">Staff</h1>
        <p className="text-muted-foreground text-sm mt-1">{staff.length} active staff across all branches</p>
      </div>

      <div className="rounded-lg border overflow-hidden">
        <table className="w-full text-sm">
          <thead className="bg-muted/50">
            <tr>
              <th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Name</th>
              <th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Email</th>
              <th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Role</th>
            </tr>
          </thead>
          <tbody className="divide-y">
            {staff.map((s) => (
              <tr key={`${s.userId}-${s.clinicId}`} className="hover:bg-muted/30 transition-colors">
                <td className="px-4 py-3 font-medium">{s.firstName} {s.lastName}</td>
                <td className="px-4 py-3 text-muted-foreground">{s.email}</td>
                <td className="px-4 py-3">
                  <Badge variant="outline">{ROLE_LABELS[s.role] ?? s.role}</Badge>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}
  • Step 2: Commit
git add ui/src/components/network/NetworkStaff.tsx
git commit -m "feat(enterprise): NetworkStaff page"

Task 6: Network Templates page

Files:
  • Create: ui/src/components/network/NetworkTemplates.tsx
  • Step 1: Write the component
// ui/src/components/network/NetworkTemplates.tsx
import { useQuery } from '@tanstack/react-query';
import { FileText } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';

interface OrgTemplate {
  id: string;
  type: string;
  name: string;
  createdAt: string;
}

const TYPE_LABELS: Record<string, string> = {
  treatment_plan: 'Treatment Plan',
  prescription: 'Prescription',
  whatsapp_flow: 'WhatsApp Flow',
  document: 'Document',
  fee_schedule: 'Fee Schedule',
};

export function NetworkTemplates() {
  const { data: templates = [], isLoading } = useQuery<OrgTemplate[]>({
    queryKey: ['org', 'templates'],
    queryFn: async () => {
      const res = await fetch('/api/v1/org/templates', { credentials: 'include' });
      if (!res.ok) throw new Error('Failed to load templates');
      return res.json();
    },
  });

  if (isLoading) return <div className="p-8 text-muted-foreground text-sm">Loading templates…</div>;

  return (
    <div className="p-8">
      <div className="mb-6">
        <h1 className="text-2xl font-bold">Shared Templates</h1>
        <p className="text-muted-foreground text-sm mt-1">
          Templates published here are visible to all branches with an <Badge variant="outline" className="text-xs">HQ</Badge> badge.
        </p>
      </div>

      {templates.length === 0 ? (
        <div className="text-center py-16 text-muted-foreground">
          <FileText className="h-10 w-10 mx-auto mb-3 opacity-30" />
          <p className="text-sm">No shared templates yet.</p>
          <p className="text-xs mt-1">Templates published here will be available across all branches.</p>
        </div>
      ) : (
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
          {templates.map((t) => (
            <Card key={t.id}>
              <CardContent className="p-4">
                <div className="flex items-start justify-between gap-2">
                  <div className="font-medium text-sm">{t.name}</div>
                  <Badge variant="secondary" className="text-xs shrink-0">
                    {TYPE_LABELS[t.type] ?? t.type}
                  </Badge>
                </div>
                <div className="text-xs text-muted-foreground mt-1">
                  {new Date(t.createdAt).toLocaleDateString()}
                </div>
              </CardContent>
            </Card>
          ))}
        </div>
      )}
    </div>
  );
}
  • Step 2: Commit
git add ui/src/components/network/NetworkTemplates.tsx
git commit -m "feat(enterprise): NetworkTemplates page"

Task 7: Network Analytics + Billing pages (stubs)

Files:
  • Create: ui/src/components/network/NetworkAnalytics.tsx
  • Create: ui/src/components/network/NetworkBilling.tsx
Analytics will be fleshed out once the branch Overview page is validated — these stubs keep routing complete.
  • Step 1: Write NetworkAnalytics stub
// ui/src/components/network/NetworkAnalytics.tsx
import { useQuery } from '@tanstack/react-query';

export function NetworkAnalytics() {
  return (
    <div className="p-8">
      <h1 className="text-2xl font-bold mb-2">Cross-Branch Analytics</h1>
      <p className="text-muted-foreground text-sm">
        Full analytics coming in the next iteration. Revenue, no-show rates, and treatment breakdowns by branch.
      </p>
    </div>
  );
}
  • Step 2: Write NetworkBilling
// ui/src/components/network/NetworkBilling.tsx
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';

export function NetworkBilling() {
  const { data: org } = useQuery({
    queryKey: ['org', 'info'],
    queryFn: async () => {
      const res = await fetch('/api/v1/org/info', { credentials: 'include' });
      if (!res.ok) throw new Error('Failed to load org info');
      return res.json();
    },
  });

  return (
    <div className="p-8">
      <h1 className="text-2xl font-bold mb-6">Billing</h1>
      <Card className="max-w-lg">
        <CardHeader>
          <CardTitle className="text-base">Enterprise Plan</CardTitle>
        </CardHeader>
        <CardContent className="space-y-3 text-sm">
          <div className="flex justify-between">
            <span className="text-muted-foreground">Plan</span>
            <Badge>Enterprise</Badge>
          </div>
          <div className="flex justify-between">
            <span className="text-muted-foreground">Organization</span>
            <span className="font-medium">{org?.name ?? '—'}</span>
          </div>
          <div className="flex justify-between">
            <span className="text-muted-foreground">White-label domain</span>
            <span className="font-mono text-xs">{org?.whiteLabelDomain ?? 'Not configured'}</span>
          </div>
          <div className="flex justify-between">
            <span className="text-muted-foreground">Custom email sender</span>
            <span className="font-mono text-xs">{org?.customEmailSender ?? '[email protected]'}</span>
          </div>
          <div className="border-t pt-3 text-xs text-muted-foreground">
            To update your plan or add the white-label bundle, contact your OdontoX account manager.
          </div>
        </CardContent>
      </Card>
    </div>
  );
}
  • Step 3: Commit
git add ui/src/components/network/NetworkAnalytics.tsx ui/src/components/network/NetworkBilling.tsx
git commit -m "feat(enterprise): NetworkAnalytics + NetworkBilling pages"

Task 8: Mount /network routes in React router

Files:
  • Modify: App router file (find it with grep -r "createBrowserRouter\|Routes\|Route path" ui/src --include="*.tsx" -l)
  • Step 1: Find the router file
grep -r "createBrowserRouter\|<Routes\|path=\"/dashboard\"" ui/src --include="*.tsx" -l | head -5
  • Step 2: Add /network routes
In the router file, add the following inside the existing auth-protected route tree, alongside the existing /dashboard route:
import { NetworkLayout } from '@/components/network/NetworkLayout';
import { NetworkOverview } from '@/components/network/NetworkOverview';
import { NetworkAnalytics } from '@/components/network/NetworkAnalytics';
import { NetworkBranches } from '@/components/network/NetworkBranches';
import { NetworkStaff } from '@/components/network/NetworkStaff';
import { NetworkTemplates } from '@/components/network/NetworkTemplates';
import { NetworkBilling } from '@/components/network/NetworkBilling';
import { OrgAuthGuard } from '@/components/network/OrgAuthGuard';
Add a route that wraps NetworkLayout with OrgAuthGuard and nests all /network pages:
{
  path: '/network',
  element: (
    <OrgAuthGuard user={currentUser}>
      <NetworkLayoutWrapper />
    </OrgAuthGuard>
  ),
  children: [
    { index: true, element: <NetworkOverview /> },
    { path: 'analytics', element: <NetworkAnalytics /> },
    { path: 'branches', element: <NetworkBranches /> },
    { path: 'staff', element: <NetworkStaff /> },
    { path: 'templates', element: <NetworkTemplates /> },
    { path: 'billing', element: <NetworkBilling /> },
  ],
}
NetworkLayoutWrapper fetches org info + branches then renders <NetworkLayout> with those props:
function NetworkLayoutWrapper() {
  const { data: orgInfo } = useQuery({ queryKey: ['org', 'info'], queryFn: () => fetch('/api/v1/org/info', { credentials: 'include' }).then(r => r.json()) });
  const { data: branches = [] } = useQuery({ queryKey: ['org', 'branches'], queryFn: () => fetch('/api/v1/org/branches', { credentials: 'include' }).then(r => r.json()) });
  return <NetworkLayout orgName={orgInfo?.name ?? 'Head Office'} branches={branches} />;
}
  • Step 3: After login redirect org_admin to /network
Find the post-login redirect logic (search for navigate('/dashboard') and add:
if (user.role === 'org_admin') {
  navigate('/network', { replace: true });
  return;
}
  • Step 4: Commit
git add -p  # stage only the router file changes
git commit -m "feat(enterprise): mount /network routes + post-login redirect for org_admin"

Task 9: Manual QA checklist

Run after deploying to the DentoCorrect instance.
  • Log in as org_admin → lands on /network, sees Network Hub sidebar
  • Network Overview shows today’s appointment count, revenue, branch count
  • Click a branch in sidebar → navigates to /dashboard?clinicId=<id> (branch view)
  • Branches page lists all org branches with city + active status
  • Staff page lists staff across all branches
  • Templates page loads (empty state is fine until templates are seeded)
  • Billing page shows org name + plan tier
  • Log in as clinic_admin (branch staff) → lands on /dashboard as before, no Network Hub visible
  • Non-org_admin hitting /network directly → redirected to /dashboard