Here’s a clean Admin Dashboard v2 plan + copy-paste prompts/code for Replit. It puts your KPIs first (revenue + churn), gates owner-only data, and keeps usage stats secondary.

What the Owner should see (top row KPIs)

Sales (MTD) – revenue this month

Sales (YTD) – revenue this year

Paying Customers – active Pro subs

Churn (This Month) – cancelled subs this month (% and count)

Nice-to-have (second row)

New Pro (This Month)

Free → Pro Conversion (This Month)

Active Subs (Total)

Unsatisfied (%) – % of cancellations with “unsatisfied” reason (from Stripe or exit survey)

Replit prompts (hand these to her)
1) Backend: secure owner-only metrics endpoint

Prompt:
Add a new owner-only endpoint GET /admin/metrics that aggregates KPIs from Stripe + Firestore. Use the role from custom claims. Return: mtdRevenue, ytdRevenue, activePro, churnThisMonthCount, churnThisMonthRate, newProThisMonth, freeUsers, proUsers, activeSubs, unsatisfiedPct. If role ≠ owner, return 403.

// server/routes/adminMetrics.ts
import { Router } from "express";
import Stripe from "stripe";
import { requireOwner } from "../middleware/authz"; // we already added this guard
import { db } from "../services/firestore"; // your Firestore init

const router = Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2023-10-16" });

// Helpers
const startOfMonth = () => new Date(new Date().getFullYear(), new Date().getMonth(), 1);
const startOfYear  = () => new Date(new Date().getFullYear(), 0, 1);

router.get("/admin/metrics", requireOwner, async (req, res) => {
  try {
    // --- Users (Firestore) ---
    const freeSnap = await db.collection("users").where("plan", "==", "free").count().get();
    const proSnap  = await db.collection("users").where("plan", "==", "pro").count().get();
    const freeUsers = freeSnap.data().count;
    const proUsers  = proSnap.data().count;

    // Active subs (optional: store in Firestore via webhook for speed; Stripe query is fine for now)
    const activeSubs = proUsers; // if plan mapping is accurate; otherwise query Stripe subscriptions

    // --- Stripe revenue (Invoices paid) ---
    // MTD
    const mtd = await stripe.invoices.list({
      status: "paid",
      created: { gte: Math.floor(startOfMonth().getTime() / 1000) },
      limit: 100,
    });
    const mtdRevenue = mtd.data.reduce((sum, inv) => sum + (inv.total || 0), 0) / 100;

    // YTD
    const ytd = await stripe.invoices.list({
      status: "paid",
      created: { gte: Math.floor(startOfYear().getTime() / 1000) },
      limit: 100,
    });
    const ytdRevenue = ytd.data.reduce((sum, inv) => sum + (inv.total || 0), 0) / 100;

    // --- Churn this month (cancellations) ---
    // Prefer a Firestore collection you maintain from Stripe webhooks: cancellations
    // Fallback: approximate via Stripe subscriptions updated to canceled this month
    const subsCanceled = await stripe.subscriptions.list({
      status: "canceled",
      created: { gte: Math.floor(startOfMonth().getTime() / 1000) },
      limit: 100,
    });
    const churnThisMonthCount = subsCanceled.data.length;
    const churnThisMonthRate  = proUsers
      ? Number(((churnThisMonthCount / proUsers) * 100).toFixed(1))
      : 0;

    // --- New Pro this month (upgrades) ---
    // If you track in Firestore (recommended), query by plan+createdAt. Fallback: Stripe new subs this month.
    const subsNew = await stripe.subscriptions.list({
      status: "active",
      created: { gte: Math.floor(startOfMonth().getTime() / 1000) },
      limit: 100,
    });
    const newProThisMonth = subsNew.data.length;

    // --- Unsatisfied percentage (if you store reasons from webhook/customer portal) ---
    // Expect a Firestore `cancellations` collection: { createdAt, reason: "unsatisfied" | ... }
    const cancelSnap = await db.collection("cancellations")
      .where("createdAt", ">=", startOfMonth())
      .get();
    const totalCancels = cancelSnap.size;
    const unsatisfied = cancelSnap.docs.filter(d => (d.get("reason") || "").toLowerCase().includes("unsatisfied")).length;
    const unsatisfiedPct = totalCancels ? Number(((unsatisfied / totalCancels) * 100).toFixed(1)) : 0;

    res.json({
      mtdRevenue,
      ytdRevenue,
      activePro: proUsers,
      churnThisMonthCount,
      churnThisMonthRate,
      newProThisMonth,
      freeUsers,
      proUsers,
      activeSubs,
      unsatisfiedPct,
    });
  } catch (e:any) {
    console.error(e);
    res.status(500).json({ error: "metrics_failed", message: e.message });
  }
});

export default router;


Wire it up:
In your server index: app.use(require("./routes/adminMetrics").default);

Owner-only data:
This endpoint is strictly owner-only via requireOwner. Management/Staff/Analyst should not hit it.

2) Webhooks to keep data truthful (optional but recommended)

Prompt:
In our Stripe webhook handler, store minimal documents in Firestore:

subscriptions (userId, status, createdAt, canceledAt)

cancellations (userId, createdAt, reason)
This makes churn + unsatisfied % fast and auditable.

(She’ll add/extend your existing webhook; no long snippet needed here.)

3) Frontend: replace tiles with owner KPIs (and gate by role)

Prompt:
In AdminDashboard.tsx, if role === 'owner', fetch /admin/metrics and render KPI tiles. Move “Brand Kits / Business Names” to a secondary “Product Usage” section.

// src/pages/AdminDashboard.tsx
import { useEffect, useState } from "react";
import { useRole } from "@/hooks/useRole";

type Metrics = {
  mtdRevenue: number; ytdRevenue: number;
  activePro: number; churnThisMonthCount: number; churnThisMonthRate: number;
  newProThisMonth: number; freeUsers: number; proUsers: number; activeSubs: number;
  unsatisfiedPct: number;
};

function StatCard({ label, value, sub }: {label:string; value:string|number; sub?:string}) {
  return (
    <div className="rounded-xl border p-4">
      <div className="text-sm text-slate-500">{label}</div>
      <div className="mt-1 text-2xl font-semibold">{value}</div>
      {sub && <div className="text-xs text-slate-400 mt-1">{sub}</div>}
    </div>
  );
}

export default function AdminDashboard() {
  const role = useRole();
  const [m, setM] = useState<Metrics | null>(null);
  const [err, setErr] = useState<string | null>(null);

  useEffect(() => {
    if (role === "owner") {
      fetch("/admin/metrics", { credentials: "include" })
        .then(r => r.ok ? r.json() : Promise.reject(r.statusText))
        .then(setM)
        .catch(e => setErr(String(e)));
    }
  }, [role]);

  return (
    <div className="space-y-6">
      {role === "owner" ? (
        <>
          <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
            <StatCard label="Sales (MTD)" value={`$${(m?.mtdRevenue ?? 0).toLocaleString()}`} />
            <StatCard label="Sales (YTD)" value={`$${(m?.ytdRevenue ?? 0).toLocaleString()}`} />
            <StatCard label="Paying Customers" value={m?.activePro ?? 0} />
            <StatCard label="Churn (This Month)" value={`${m?.churnThisMonthRate ?? 0}%`} sub={`${m?.churnThisMonthCount ?? 0} cancelled`} />
          </div>

          <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
            <StatCard label="New Pro (This Month)" value={m?.newProThisMonth ?? 0} />
            <StatCard label="Free → Pro Conversion" value={
              m ? `${(m.newProThisMonth / Math.max(m.freeUsers + m.proUsers, 1) * 100).toFixed(1)}%` : "0%"
            } />
            <StatCard label="Active Subs" value={m?.activeSubs ?? 0} />
            <StatCard label="Unsatisfied %" value={`${m?.unsatisfiedPct ?? 0}%`} />
          </div>
        </>
      ) : (
        // Non-owner: show a trimmed view
        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
          <StatCard label="Total Users" value={(m?.freeUsers ?? 0) + (m?.proUsers ?? 0)} />
          <StatCard label="Free Users" value={m?.freeUsers ?? 0} />
          <StatCard label="Pro Users" value={m?.proUsers ?? 0} />
          <StatCard label="Active Subs" value={m?.activeSubs ?? 0} />
        </div>
      )}

      {err && <div className="text-red-600 text-sm">Metrics error: {err}</div>}

      {/* Keep your System Status + Recent Users sections below as-is */}
    </div>
  );
}

4) Owner-only items (make this explicit to her)

Revenue numbers (MTD/YTD)

Churn % and counts

Unsatisfied % (cancellation reasons)

Billing controls (prices, coupons, refunds)

Secrets & system settings

Role assignment (promote/demote admins)

Management/Staff can see aggregate counts (Total/Free/Pro/Active Subs) and handle moderation/support, but not money, secrets, or roles. Analyst stays read-only.