Initial project upload

This commit is contained in:
Mohamed Mathar Irfan
2026-07-28 17:57:02 +05:30
commit ed6610d5d8
23919 changed files with 3003316 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
export default function Audit() {
const [logs, setLogs] = useState<any[]>([]);
useEffect(() => { api("/admin/audit-logs").then(setLogs).catch(() => {}); }, []);
return (
<div className="space-y-5">
<h2 className="text-xl font-bold">Audit Logs</h2>
<div className="card overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500 text-left">
<tr><th className="px-4 py-3 font-medium">Time</th><th className="px-4 py-3 font-medium">User</th><th className="px-4 py-3 font-medium">Action</th><th className="px-4 py-3 font-medium">Module</th><th className="px-4 py-3 font-medium">Details</th></tr>
</thead>
<tbody className="divide-y divide-slate-100">
{logs.map((l, i) => (
<tr key={i}>
<td className="px-4 py-3 text-slate-500">{l.at ? new Date(l.at).toLocaleString() : ""}</td>
<td className="px-4 py-3">{l.user ?? "—"}</td>
<td className="px-4 py-3">{l.action}</td>
<td className="px-4 py-3">{l.module}</td>
<td className="px-4 py-3 text-slate-500">{l.details}</td>
</tr>
))}
{logs.length === 0 && <tr><td colSpan={5} className="px-4 py-12 text-center text-slate-400">No audit logs.</td></tr>}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ShieldHalf, Building2, CreditCard, FileText, LogOut, BarChart3,
PieChart, RefreshCw, LifeBuoy, LayoutGrid,
} from "lucide-react";
import { api, clearToken } from "@/lib/api";
const nav = [
{ href: "/admin", label: "Dashboard", icon: LayoutGrid },
{ href: "/admin/organizations", label: "Organizations", icon: Building2 },
{ href: "/admin/utilization", label: "Utilization", icon: PieChart },
{ href: "/admin/retention", label: "Retention", icon: RefreshCw },
{ href: "/admin/plans", label: "Plans & Pricing", icon: CreditCard },
{ href: "/admin/revenue", label: "Revenue", icon: BarChart3 },
{ href: "/admin/support", label: "Support View", icon: LifeBuoy },
{ href: "/admin/audit", label: "Audit Logs", icon: FileText },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const [me, setMe] = useState<any>(null);
useEffect(() => {
api("/me")
.then((m) => {
if (!m.is_product_admin) { router.replace("/dashboard"); return; }
setMe(m);
})
.catch(() => { clearToken(); router.replace("/login"); });
}, [router]);
if (!me) return <div className="min-h-screen grid place-items-center text-slate-400">Loading</div>;
function logout() { clearToken(); window.location.href = "/login"; }
return (
<div className="flex min-h-screen bg-slate-50">
<aside className="w-64 shrink-0 bg-sidebar text-white flex flex-col h-screen sticky top-0">
<div className="px-6 py-6 flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-white/10 grid place-items-center">
<ShieldHalf className="h-6 w-6" />
</div>
<div>
<div className="font-extrabold text-lg leading-none">VerifyPack</div>
<div className="text-[10px] text-white/50 mt-1">Super Admin Panel</div>
</div>
</div>
<nav className="flex-1 overflow-y-auto px-3 space-y-1 mt-2">
{nav.map(({ href, label, icon: Icon }) => (
<Link key={href} href={href}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-white/70 hover:bg-white/10 hover:text-white">
<Icon className="h-[18px] w-[18px]" /> {label}
</Link>
))}
</nav>
<div className="p-4 space-y-2">
<div className="rounded-xl bg-white/5 px-4 py-3 text-[11px]">
<div className="text-white font-semibold">{me.name}</div>
<div className="text-white/50 capitalize">{me.product_admin_role?.replace("_", " ")}</div>
</div>
<button onClick={logout}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl text-sm font-medium text-white/70 hover:bg-white/10">
<LogOut className="h-[18px] w-[18px]" /> Log out
</button>
</div>
</aside>
<main className="flex-1 min-w-0 p-8">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,186 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Mail, UserCog, Clock, Plus } from "lucide-react";
import { api } from "@/lib/api";
const LIFECYCLE = ["active", "grace", "archived", "deleted"];
export default function OrgDetail() {
const { org_id } = useParams<{ org_id: string }>();
const [d, setD] = useState<any>(null);
const [hist, setHist] = useState<any>(null);
const [notes, setNotes] = useState<any[]>([]);
const [note, setNote] = useState("");
const [toast, setToast] = useState("");
function load() {
api(`/admin/organizations/${org_id}`).then(setD).catch(() => {});
api(`/admin/organizations/${org_id}/history`).then(setHist).catch(() => {});
api(`/admin/organizations/${org_id}/notes`).then(setNotes).catch(() => setNotes([]));
}
useEffect(() => { load(); }, [org_id]);
async function addNote() {
if (!note.trim()) return;
await api(`/admin/organizations/${org_id}/notes`, { method: "POST", body: JSON.stringify({ note }) });
setNote(""); load();
}
async function sendReminder() {
const r = await api(`/admin/organizations/${org_id}/send-reminder`, { method: "POST" });
setToast(`Reminder sent to ${r.to}`);
setTimeout(() => setToast(""), 2500);
}
async function extend() {
await api(`/admin/organizations/${org_id}/subscription`, { method: "PUT", body: JSON.stringify({ extend_days: 30 }) });
setToast("Subscription extended 30 days"); load();
setTimeout(() => setToast(""), 2500);
}
async function impersonate() {
const r = await api(`/admin/organizations/${org_id}/impersonate`, { method: "POST" });
setToast(r.message);
setTimeout(() => setToast(""), 2500);
}
if (!d) return <div className="text-slate-400">Loading</div>;
const curStage = LIFECYCLE.indexOf(d.subscription_status);
return (
<div className="space-y-5">
{toast && <div className="card p-3 bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{toast}</div>}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">{d.name}</h2>
<p className="text-sm text-slate-500 capitalize">{d.plan} · {d.subscription_status}</p>
</div>
<div className="flex gap-2">
<button onClick={extend} className="flex items-center gap-2 border border-slate-200 px-3 py-1.5 rounded-lg text-sm font-medium"><Clock className="h-4 w-4" /> Extend 30d</button>
<button onClick={sendReminder} className="flex items-center gap-2 border border-slate-200 px-3 py-1.5 rounded-lg text-sm font-medium"><Mail className="h-4 w-4" /> Send reminder</button>
<button onClick={impersonate} className="flex items-center gap-2 bg-brand-600 text-white px-3 py-1.5 rounded-lg text-sm font-semibold"><UserCog className="h-4 w-4" /> Impersonate</button>
</div>
</div>
{/* Lifecycle timeline */}
<div className="card p-5">
<h3 className="font-bold mb-4">Subscription Lifecycle</h3>
<div className="flex items-center">
{LIFECYCLE.map((stage, i) => (
<div key={stage} className="flex items-center flex-1 last:flex-none">
<div className="flex flex-col items-center">
<div className={`h-9 w-9 rounded-full grid place-items-center text-xs font-bold capitalize
${i <= curStage ? "bg-brand-600 text-white" : "bg-slate-100 text-slate-400"}`}>
{i + 1}
</div>
<span className="text-xs mt-1 capitalize text-slate-500">{stage}</span>
</div>
{i < LIFECYCLE.length - 1 && (
<div className={`flex-1 h-0.5 mx-2 ${i < curStage ? "bg-brand-600" : "bg-slate-200"}`} />
)}
</div>
))}
</div>
</div>
{/* Utilization */}
<div className="grid sm:grid-cols-3 gap-4">
<UtilBar label="Products" used={d.products_used} limit={d.product_limit} />
<UtilBar label="Users" used={d.users_used} limit={d.user_limit} />
<UtilBar label="Storage (GB)" used={d.storage_used_gb} limit={d.storage_total_gb} />
</div>
<div className="grid sm:grid-cols-3 gap-4">
<Stat label="QR Codes" value={d.qr_codes} />
<Stat label="Total Scans" value={d.total_scans} />
<Stat label="Plan Price" value={`${d.plan_price}`} />
</div>
{/* Admins */}
<div className="card p-5">
<h3 className="font-bold mb-3">Admin Users</h3>
<div className="space-y-2 text-sm">
{(d.admins ?? []).map((a: any, i: number) => (
<div key={i} className="flex justify-between border-b border-slate-100 py-2">
<span>{a.name} <span className="text-slate-400">({a.email})</span></span>
<span className="text-slate-500">{a.last_login ? new Date(a.last_login).toLocaleDateString() : "never"}</span>
</div>
))}
{(!d.admins || d.admins.length === 0) && <p className="text-slate-400">No admins.</p>}
</div>
</div>
{/* History */}
{hist && (
<div className="grid md:grid-cols-2 gap-4">
<div className="card p-5">
<h3 className="font-bold mb-3">Invoices</h3>
<div className="space-y-1 text-sm">
{hist.invoices.map((i: any, k: number) => (
<div key={k} className="flex justify-between border-b border-slate-100 py-1.5">
<span className="font-mono">{i.invoice_number}</span>
<span>{i.total} · <span className="capitalize text-slate-500">{i.status}</span></span>
</div>
))}
{hist.invoices.length === 0 && <p className="text-slate-400">No invoices.</p>}
</div>
</div>
<div className="card p-5">
<h3 className="font-bold mb-3">Plan History</h3>
<div className="space-y-1 text-sm">
{hist.plan_history.map((h: any, k: number) => (
<div key={k} className="flex justify-between border-b border-slate-100 py-1.5">
<span>{h.from ?? "—"} {h.to}</span>
<span className="text-slate-400">{new Date(h.at).toLocaleDateString()}</span>
</div>
))}
{hist.plan_history.length === 0 && <p className="text-slate-400">No changes.</p>}
</div>
</div>
</div>
)}
{/* Support notes */}
<div className="card p-5">
<h3 className="font-bold mb-3">Internal Support Notes</h3>
<div className="flex gap-2 mb-3">
<input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Add an internal note…"
className="flex-1 px-3 py-2 rounded-lg border border-slate-200 text-sm" />
<button onClick={addNote} className="flex items-center gap-1 bg-brand-600 text-white px-3 py-2 rounded-lg text-sm font-semibold">
<Plus className="h-4 w-4" /> Add
</button>
</div>
<div className="space-y-2">
{notes.map((n) => (
<div key={n.id} className="text-sm border-b border-slate-100 py-2">
<div>{n.note}</div>
<div className="text-xs text-slate-400">{n.author} · {new Date(n.at).toLocaleString()}</div>
</div>
))}
{notes.length === 0 && <p className="text-sm text-slate-400">No notes yet.</p>}
</div>
</div>
</div>
);
}
function UtilBar({ label, used, limit }: { label: string; used: number; limit: number | null }) {
const pct = limit == null ? 8 : Math.min(100, Math.round((used / limit) * 100));
return (
<div className="card p-4">
<div className="flex justify-between text-sm mb-1">
<span className="text-slate-600">{label}</span>
<span className="text-slate-500">{used} / {limit ?? "∞"}</span>
</div>
<div className="h-2 rounded-full bg-slate-100">
<div className="h-full rounded-full bg-brand-500" style={{ width: `${pct}%` }} />
</div>
</div>
);
}
function Stat({ label, value }: any) {
return (
<div className="card p-4">
<div className="text-sm text-slate-500">{label}</div>
<div className="text-2xl font-extrabold mt-1">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Download, Search, Building2, CheckCircle2, Clock, Archive } from "lucide-react";
import { api, apiBase } from "@/lib/api";
function bar(used: number, limit: number | null) {
if (limit == null) return 12;
return Math.min(100, Math.round((used / limit) * 100));
}
export default function AdminOrgs() {
const [orgs, setOrgs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [q, setQ] = useState("");
const [status, setStatus] = useState("");
const [plan, setPlan] = useState("");
useEffect(() => {
api("/admin/organizations").then(setOrgs).catch(() => {}).finally(() => setLoading(false));
}, []);
const filtered = useMemo(() => orgs.filter((o) =>
(!q || o.name.toLowerCase().includes(q.toLowerCase())) &&
(!status || o.subscription_status === status) &&
(!plan || o.plan === plan)), [orgs, q, status, plan]);
const kpis = {
total: orgs.length,
active: orgs.filter((o) => o.subscription_status === "active").length,
grace: orgs.filter((o) => o.subscription_status === "grace").length,
archived: orgs.filter((o) => o.subscription_status === "archived").length,
};
const plans = [...new Set(orgs.map((o) => o.plan).filter(Boolean))];
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">All Organizations</h2>
<p className="text-sm text-slate-500">Utilization across every client org.</p>
</div>
<div className="flex gap-2">
<a href={`${apiBase}/admin/utilization-report?format=csv`} className="flex items-center gap-2 border border-slate-200 px-3 py-1.5 rounded-lg text-sm font-medium"><Download className="h-4 w-4" /> CSV</a>
<a href={`${apiBase}/admin/utilization-report?format=pdf`} className="flex items-center gap-2 border border-slate-200 px-3 py-1.5 rounded-lg text-sm font-medium"><Download className="h-4 w-4" /> PDF</a>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Kpi label="Total Orgs" value={kpis.total} icon={<Building2 className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
<Kpi label="Active" value={kpis.active} icon={<CheckCircle2 className="h-5 w-5" />} tint="bg-emerald-50 text-emerald-600" />
<Kpi label="Grace Period" value={kpis.grace} icon={<Clock className="h-5 w-5" />} tint="bg-amber-50 text-amber-600" />
<Kpi label="Archived" value={kpis.archived} icon={<Archive className="h-5 w-5" />} tint="bg-slate-100 text-slate-600" />
</div>
<div className="card p-4 flex flex-wrap items-end gap-3">
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search org…" className="pl-9 pr-3 py-2 w-52 rounded-lg border border-slate-200 text-sm" />
</div>
<label className="block"><span className="text-xs text-slate-500">Status</span>
<select value={status} onChange={(e) => setStatus(e.target.value)} className="mt-1 block px-3 py-2 rounded-lg border border-slate-200 text-sm min-w-[130px]">
<option value="">All Status</option>{["active", "grace", "archived", "deleted"].map((s) => <option key={s} value={s} className="capitalize">{s}</option>)}
</select>
</label>
<label className="block"><span className="text-xs text-slate-500">Plan</span>
<select value={plan} onChange={(e) => setPlan(e.target.value)} className="mt-1 block px-3 py-2 rounded-lg border border-slate-200 text-sm min-w-[130px]">
<option value="">All Plans</option>{plans.map((pl) => <option key={pl} value={pl}>{pl}</option>)}
</select>
</label>
</div>
<div className="card overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500 text-left">
<tr>
<th className="px-4 py-3 font-medium">Organization</th>
<th className="px-4 py-3 font-medium">Plan</th>
<th className="px-4 py-3 font-medium">Products</th>
<th className="px-4 py-3 font-medium">Users</th>
<th className="px-4 py-3 font-medium">QRs</th>
<th className="px-4 py-3 font-medium">Scans</th>
<th className="px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((o) => (
<tr key={o.org_id} className="hover:bg-slate-50">
<td className="px-4 py-3 font-medium text-brand-700"><Link href={`/admin/organizations/${o.org_id}`} className="hover:underline">{o.name}</Link></td>
<td className="px-4 py-3">{o.plan ?? "—"}</td>
<td className="px-4 py-3 w-40">
<div className="text-xs text-slate-500 mb-1">{o.products_used} / {o.product_limit ?? "∞"}</div>
<div className="h-1.5 rounded-full bg-slate-100"><div className="h-full rounded-full bg-blue-500" style={{ width: `${bar(o.products_used, o.product_limit)}%` }} /></div>
</td>
<td className="px-4 py-3">{o.users_used} / {o.user_limit ?? "∞"}</td>
<td className="px-4 py-3">{o.qr_codes}</td>
<td className="px-4 py-3">{o.total_scans}</td>
<td className="px-4 py-3"><span className="text-xs font-semibold bg-emerald-50 text-emerald-700 px-2 py-0.5 rounded-full capitalize">{o.subscription_status}</span></td>
</tr>
))}
{!loading && filtered.length === 0 && <tr><td colSpan={7} className="px-4 py-12 text-center text-slate-400">No organizations match.</td></tr>}
</tbody>
</table>
</div>
</div>
);
}
function Kpi({ label, value, icon, tint }: any) {
return (
<div className="card p-5">
<div className="flex items-start gap-4">
<div className={`h-12 w-12 rounded-xl grid place-items-center shrink-0 ${tint}`}>{icon}</div>
<div><div className="text-sm text-slate-500">{label}</div><div className="text-2xl font-extrabold text-slate-900 mt-0.5">{value}</div></div>
</div>
</div>
);
}

123
frontend/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,123 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import {
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
PieChart, Pie, Cell,
} from "recharts";
import {
Building2, IndianRupee, TrendingUp, Users, QrCode, ScanLine, ArrowRight,
} from "lucide-react";
import { api } from "@/lib/api";
import { inr } from "@/lib/utils";
const DONUT = ["#2563eb", "#10b981", "#f59e0b", "#8b5cf6", "#94a3b8"];
export default function AdminDashboard() {
const [d, setD] = useState<any>(null);
const [rev, setRev] = useState<any>(null);
useEffect(() => {
api("/admin/dashboard").then(setD).catch(() => {});
api("/admin/revenue").then(setRev).catch(() => {});
}, []);
const trend = (d?.revenue_trend ?? []).map((t: any) => ({ label: t.month.slice(2), revenue: t.revenue }));
return (
<div className="space-y-5">
<div>
<h2 className="text-xl font-bold">Platform Overview</h2>
<p className="text-sm text-slate-500">VerifyPack all organizations at a glance.</p>
</div>
{/* KPI row 1 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Kpi label="Total Organizations" value={d?.total_orgs ?? 0} sub={`${d?.active ?? 0} active`} icon={<Building2 className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
<Kpi label="MRR" value={inr(d?.mrr ?? 0)} sub={`ARR ${inr(d?.arr ?? 0)}`} icon={<IndianRupee className="h-5 w-5" />} tint="bg-emerald-50 text-emerald-600" />
<Kpi label="Paying Orgs" value={d?.paying_orgs ?? 0} sub={`ARPO ${inr(d?.arpo ?? 0)}`} icon={<Users className="h-5 w-5" />} tint="bg-violet-50 text-violet-600" />
<Kpi label="Total Revenue" value={inr(d?.total_revenue ?? 0)} sub="all time" icon={<TrendingUp className="h-5 w-5" />} tint="bg-amber-50 text-amber-500" />
</div>
{/* KPI row 2 — lifecycle + platform totals */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<Mini label="Active" value={d?.active ?? 0} color="text-emerald-600" />
<Mini label="Grace" value={d?.grace ?? 0} color="text-amber-600" />
<Mini label="Archived" value={d?.archived ?? 0} color="text-slate-500" />
<Mini label="Total QRs" value={d?.total_qr ?? 0} color="text-blue-600" icon={<QrCode className="h-4 w-4" />} />
<Mini label="Total Scans" value={d?.total_scans ?? 0} color="text-violet-600" icon={<ScanLine className="h-4 w-4" />} />
</div>
<div className="grid lg:grid-cols-3 gap-4">
<div className="card p-5 lg:col-span-2">
<h3 className="font-bold mb-2">Revenue Trend</h3>
<ResponsiveContainer width="100%" height={230}>
<AreaChart data={trend} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}>
<defs><linearGradient id="rev" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#10b981" stopOpacity={0.35} /><stop offset="100%" stopColor="#10b981" stopOpacity={0} />
</linearGradient></defs>
<CartesianGrid strokeDasharray="3 3" stroke="#eef2f7" vertical={false} />
<XAxis dataKey="label" tickLine={false} axisLine={false} tick={{ fontSize: 12, fill: "#94a3b8" }} />
<YAxis tickLine={false} axisLine={false} tick={{ fontSize: 12, fill: "#94a3b8" }} />
<Tooltip formatter={(v: any) => inr(v)} />
<Area type="monotone" dataKey="revenue" stroke="#10b981" strokeWidth={2.5} fill="url(#rev)" />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="card p-5">
<h3 className="font-bold mb-2">Revenue by Plan</h3>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={rev?.plan_breakdown ?? []} dataKey="revenue" nameKey="plan" innerRadius={50} outerRadius={80} paddingAngle={2}>
{(rev?.plan_breakdown ?? []).map((_: any, i: number) => <Cell key={i} fill={DONUT[i % DONUT.length]} />)}
</Pie>
<Tooltip formatter={(v: any) => inr(v)} />
</PieChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-2 justify-center mt-2 text-xs">
{(rev?.plan_breakdown ?? []).map((p: any, i: number) => (
<span key={p.plan} className="flex items-center gap-1"><span className="h-2 w-2 rounded-full" style={{ background: DONUT[i % DONUT.length] }} />{p.plan}</span>
))}
</div>
</div>
</div>
<div className="grid sm:grid-cols-3 gap-4">
<Quick href="/admin/organizations" label="View Organizations" />
<Quick href="/admin/revenue" label="Revenue & Finance" />
<Quick href="/admin/retention" label="Retention & Lifecycle" />
</div>
</div>
);
}
function Kpi({ label, value, sub, icon, tint }: any) {
return (
<div className="card p-5">
<div className="flex items-start gap-4">
<div className={`h-12 w-12 rounded-xl grid place-items-center shrink-0 ${tint}`}>{icon}</div>
<div className="min-w-0">
<div className="text-sm text-slate-500">{label}</div>
<div className="text-2xl font-extrabold text-slate-900 mt-0.5">{value}</div>
<div className="text-xs text-slate-400 mt-1">{sub}</div>
</div>
</div>
</div>
);
}
function Mini({ label, value, color, icon }: any) {
return (
<div className="card p-4">
<div className={`text-xl font-extrabold flex items-center gap-1 ${color}`}>{icon}{value.toLocaleString?.() ?? value}</div>
<div className="text-xs text-slate-500">{label}</div>
</div>
);
}
function Quick({ href, label }: any) {
return (
<Link href={href} className="card p-4 flex items-center justify-between hover:shadow-sm transition">
<span className="font-medium text-slate-700">{label}</span>
<ArrowRight className="h-4 w-4 text-brand-600" />
</Link>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useEffect, useState } from "react";
import { Plus, Pencil, X, CheckCircle2 } from "lucide-react";
import { api } from "@/lib/api";
import { inr } from "@/lib/utils";
type PlanForm = {
_id?: string; name: string; price: number;
product_limit: number | null; user_limit: number | null;
storage_gb: number; grace_days: number; archive_years: number;
features: string; enabled: boolean;
};
const empty: PlanForm = {
name: "", price: 0, product_limit: 100, user_limit: 3, storage_gb: 10,
grace_days: 30, archive_years: 1, features: "", enabled: true,
};
export default function Plans() {
const [plans, setPlans] = useState<any[]>([]);
const [form, setForm] = useState<PlanForm | null>(null);
const [toast, setToast] = useState("");
function load() { api("/admin/plans").then(setPlans).catch(() => {}); }
useEffect(() => { load(); }, []);
function openEdit(p: any) {
setForm({ _id: p.id, name: p.name, price: p.price,
product_limit: p.product_limit, user_limit: p.user_limit,
storage_gb: p.storage_gb, grace_days: p.grace_days ?? 30,
archive_years: p.archive_years ?? 1,
features: (p.features ?? []).join("\n"), enabled: p.enabled });
}
async function save() {
if (!form) return;
const { _id, features, ...rest } = form;
const payload = { ...rest, features: features.split("\n").map((s) => s.trim()).filter(Boolean) };
if (_id) await api(`/admin/plans/${_id}`, { method: "PUT", body: JSON.stringify(payload) });
else await api("/admin/plans", { method: "POST", body: JSON.stringify(payload) });
setForm(null); setToast("Plan saved"); setTimeout(() => setToast(""), 2000); load();
}
return (
<div className="space-y-5">
{toast && <div className="card p-3 bg-emerald-50 border-emerald-200 text-emerald-800 text-sm flex items-center gap-2"><CheckCircle2 className="h-4 w-4" /> {toast}</div>}
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Plans & Pricing</h2>
<button onClick={() => setForm({ ...empty })} className="flex items-center gap-2 bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-semibold"><Plus className="h-4 w-4" /> Add Plan</button>
</div>
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-4">
{plans.map((p) => (
<div key={p.id} className={`card p-5 relative ${!p.enabled ? "opacity-60" : ""}`}>
<button onClick={() => openEdit(p)} className="absolute top-3 right-3 h-8 w-8 grid place-items-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50"><Pencil className="h-4 w-4" /></button>
<h3 className="font-bold">{p.name}</h3>
<div className="text-2xl font-extrabold mt-1">{p.price ? inr(p.price) : "Free"}{p.price > 0 && <span className="text-xs font-normal text-slate-400">/mo</span>}</div>
<ul className="text-xs text-slate-500 mt-3 space-y-1">
<li>Products: {p.product_limit ?? "∞"}</li>
<li>Users: {p.user_limit ?? "∞"}</li>
<li>Storage: {p.storage_gb} GB</li>
</ul>
{!p.enabled && <span className="text-[10px] font-semibold text-rose-500 mt-2 block">DISABLED</span>}
</div>
))}
</div>
<div className="card p-5">
<h3 className="font-bold mb-1">System Configuration</h3>
<p className="text-sm text-slate-500">Grace period and archive/preservation windows are configured per plan above. Changes to plan prices do not affect active subscriptions until renewal.</p>
</div>
{form && (
<div className="fixed inset-0 bg-black/40 z-50 grid place-items-center p-4 overflow-y-auto" onClick={() => setForm(null)}>
<div className="bg-white rounded-2xl w-full max-w-lg my-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
<h3 className="font-bold">{form._id ? "Edit Plan" : "Add Plan"}</h3>
<button onClick={() => setForm(null)}><X className="h-5 w-5 text-slate-400" /></button>
</div>
<div className="p-5 grid grid-cols-2 gap-3">
<Inp label="Name" v={form.name} on={(x) => setForm({ ...form, name: x })} full />
<Inp label="Price (₹/mo)" type="number" v={form.price} on={(x) => setForm({ ...form, price: Number(x) })} />
<Inp label="Storage (GB)" type="number" v={form.storage_gb} on={(x) => setForm({ ...form, storage_gb: Number(x) })} />
<LimitInp label="Product limit" v={form.product_limit} on={(x) => setForm({ ...form, product_limit: x })} />
<LimitInp label="User limit" v={form.user_limit} on={(x) => setForm({ ...form, user_limit: x })} />
<Inp label="Grace days" type="number" v={form.grace_days} on={(x) => setForm({ ...form, grace_days: Number(x) })} />
<Inp label="Archive years" type="number" v={form.archive_years} on={(x) => setForm({ ...form, archive_years: Number(x) })} />
<label className="block col-span-2">
<span className="text-sm font-medium">Features (one per line)</span>
<textarea value={form.features} onChange={(e) => setForm({ ...form, features: e.target.value })} rows={4} className="mt-1 w-full px-3 py-2 rounded-lg border border-slate-200 text-sm" />
</label>
<label className="flex items-center gap-2 col-span-2 text-sm">
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} /> Enabled (new signups can select this plan)
</label>
</div>
<div className="flex justify-end gap-3 px-5 py-4 border-t border-slate-100">
<button onClick={() => setForm(null)} className="px-5 py-2 rounded-lg border border-slate-200 text-sm font-medium">Cancel</button>
<button onClick={save} className="px-6 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold">Save</button>
</div>
</div>
</div>
)}
</div>
);
}
function Inp({ label, v, on, type = "text", full }: { label: string; v: any; on: (x: string) => void; type?: string; full?: boolean }) {
return (
<label className={`block ${full ? "col-span-2" : ""}`}>
<span className="text-sm font-medium">{label}</span>
<input type={type} value={v ?? ""} onChange={(e) => on(e.target.value)} className="mt-1 w-full px-3 py-2 rounded-lg border border-slate-200 text-sm" />
</label>
);
}
function LimitInp({ label, v, on }: { label: string; v: number | null; on: (x: number | null) => void }) {
return (
<label className="block">
<span className="text-sm font-medium">{label}</span>
<div className="mt-1 flex gap-2 items-center">
<input type="number" value={v ?? ""} disabled={v === null} onChange={(e) => on(Number(e.target.value))}
className="flex-1 px-3 py-2 rounded-lg border border-slate-200 text-sm disabled:bg-slate-50" />
<label className="flex items-center gap-1 text-xs text-slate-500">
<input type="checkbox" checked={v === null} onChange={(e) => on(e.target.checked ? null : 100)} />
</label>
</div>
</label>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
export default function Retention() {
const [d, setD] = useState<any>(null);
useEffect(() => { api("/admin/subscription-lifecycle").then(setD).catch(() => {}); }, []);
return (
<div className="space-y-5">
<h2 className="text-xl font-bold">Subscription & Retention</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<Kpi label="Active" value={d?.active ?? 0} tint="bg-emerald-50 text-emerald-700" />
<Kpi label="Grace Period" value={d?.grace ?? 0} tint="bg-amber-50 text-amber-700" />
<Kpi label="Archived" value={d?.archived ?? 0} tint="bg-slate-100 text-slate-600" />
<Kpi label="Deleted" value={d?.deleted ?? 0} tint="bg-rose-50 text-rose-700" />
</div>
<div className="card p-5">
<h3 className="font-bold mb-3">Upcoming Expiries (next 30 days)</h3>
<div className="space-y-2 text-sm">
{(d?.upcoming_expiries ?? []).map((u: any, i: number) => (
<div key={i} className="flex justify-between border-b border-slate-100 py-2">
<Link href={`/admin/organizations/${u.org_id}`} className="text-brand-700 hover:underline">{u.org}</Link>
<span className="text-slate-500">{new Date(u.renewal).toLocaleDateString()} · {u.days}d left</span>
</div>
))}
{(!d?.upcoming_expiries || d.upcoming_expiries.length === 0) && (
<p className="text-slate-400">No upcoming expiries.</p>
)}
</div>
</div>
</div>
);
}
function Kpi({ label, value, tint }: any) {
return (
<div className="card p-5">
<span className={`text-xs font-semibold px-2 py-1 rounded-full ${tint}`}>{label}</span>
<div className="text-3xl font-extrabold mt-2">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import { useEffect, useState } from "react";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
import { IndianRupee, TrendingUp, FileText, Download } from "lucide-react";
import { api, apiBase } from "@/lib/api";
import { inr } from "@/lib/utils";
const DONUT = ["#2563eb", "#10b981", "#f59e0b", "#8b5cf6", "#94a3b8"];
const STATUS: Record<string, string> = {
paid: "bg-emerald-50 text-emerald-700", pending: "bg-amber-50 text-amber-700",
failed: "bg-rose-50 text-rose-700", refunded: "bg-slate-100 text-slate-600",
};
export default function Revenue() {
const [d, setD] = useState<any>(null);
useEffect(() => { api("/admin/revenue").then(setD).catch(() => {}); }, []);
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Revenue & Finance</h2>
<a href={`${apiBase}/admin/revenue/export`} className="flex items-center gap-2 border border-slate-200 px-3 py-1.5 rounded-lg text-sm font-medium"><Download className="h-4 w-4" /> CSV</a>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Kpi label="MRR" value={inr(d?.mrr ?? 0)} icon={<IndianRupee className="h-5 w-5" />} tint="bg-emerald-50 text-emerald-600" />
<Kpi label="ARR" value={inr(d?.arr ?? 0)} icon={<TrendingUp className="h-5 w-5" />} tint="bg-blue-50 text-blue-600" />
<Kpi label="Total Revenue" value={inr(d?.total_revenue ?? 0)} icon={<IndianRupee className="h-5 w-5" />} tint="bg-violet-50 text-violet-600" />
<Kpi label="Invoices" value={`${d?.paid_count ?? 0} / ${d?.invoices_count ?? 0}`} icon={<FileText className="h-5 w-5" />} tint="bg-amber-50 text-amber-500" />
</div>
<div className="grid lg:grid-cols-3 gap-4">
<div className="card p-5">
<h3 className="font-bold mb-2">Revenue by Plan</h3>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={d?.plan_breakdown ?? []} dataKey="revenue" nameKey="plan" innerRadius={50} outerRadius={80} paddingAngle={2}>
{(d?.plan_breakdown ?? []).map((_: any, i: number) => <Cell key={i} fill={DONUT[i % DONUT.length]} />)}
</Pie>
<Tooltip formatter={(v: any) => inr(v)} />
</PieChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-2 justify-center mt-2 text-xs">
{(d?.plan_breakdown ?? []).map((p: any, i: number) => (
<span key={p.plan} className="flex items-center gap-1"><span className="h-2 w-2 rounded-full" style={{ background: DONUT[i % DONUT.length] }} />{p.plan} · {inr(p.revenue)}</span>
))}
</div>
</div>
<div className="card overflow-x-auto lg:col-span-2">
<h3 className="font-bold p-5 pb-3">All Invoices</h3>
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500 text-left">
<tr>
<th className="px-4 py-3 font-medium">Invoice</th>
<th className="px-4 py-3 font-medium">Organization</th>
<th className="px-4 py-3 font-medium">Plan</th>
<th className="px-4 py-3 font-medium">Total</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{(d?.invoices ?? []).map((i: any) => (
<tr key={i.id} className="hover:bg-slate-50">
<td className="px-4 py-3 font-mono text-slate-600">{i.invoice_number}</td>
<td className="px-4 py-3">{i.org}</td>
<td className="px-4 py-3">{i.plan}</td>
<td className="px-4 py-3 font-semibold">{inr(i.total)}</td>
<td className="px-4 py-3"><span className={`text-xs font-semibold px-2 py-0.5 rounded-full capitalize ${STATUS[i.status] ?? "bg-slate-100"}`}>{i.status}</span></td>
<td className="px-4 py-3 text-slate-500">{new Date(i.at).toLocaleDateString()}</td>
</tr>
))}
{(!d?.invoices || d.invoices.length === 0) && <tr><td colSpan={6} className="px-4 py-12 text-center text-slate-400">No invoices yet.</td></tr>}
</tbody>
</table>
</div>
</div>
</div>
);
}
function Kpi({ label, value, icon, tint }: any) {
return (
<div className="card p-5">
<div className="flex items-start gap-4">
<div className={`h-12 w-12 rounded-xl grid place-items-center shrink-0 ${tint}`}>{icon}</div>
<div><div className="text-sm text-slate-500">{label}</div><div className="text-2xl font-extrabold text-slate-900 mt-0.5">{value}</div></div>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
"use client";
import { useEffect, useState } from "react";
import { Search, Plus, Lock } from "lucide-react";
import { api } from "@/lib/api";
export default function Support() {
const [orgs, setOrgs] = useState<any[]>([]);
const [q, setQ] = useState("");
const [selected, setSelected] = useState<string>("");
const [view, setView] = useState<any>(null);
const [notes, setNotes] = useState<any[]>([]);
const [note, setNote] = useState("");
useEffect(() => { api("/admin/organizations").then(setOrgs).catch(() => {}); }, []);
async function open(orgId: string) {
setSelected(orgId);
api(`/admin/organizations/${orgId}/support-view`).then(setView).catch(() => {});
api(`/admin/organizations/${orgId}/notes`).then(setNotes).catch(() => setNotes([]));
}
async function addNote() {
if (!note.trim()) return;
await api(`/admin/organizations/${selected}/notes`, { method: "POST", body: JSON.stringify({ note }) });
setNote("");
api(`/admin/organizations/${selected}/notes`).then(setNotes);
}
const filtered = orgs.filter((o) => o.name.toLowerCase().includes(q.toLowerCase()));
return (
<div className="space-y-5">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold">Support View</h2>
<span className="text-xs font-semibold bg-slate-100 text-slate-500 px-2 py-1 rounded-full flex items-center gap-1">
<Lock className="h-3 w-3" /> Read-only
</span>
</div>
<div className="grid md:grid-cols-[260px_1fr] gap-5">
{/* Org list */}
<div className="card p-4 h-fit">
<div className="relative mb-3">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search org…"
className="pl-9 pr-3 py-2 w-full rounded-lg border border-slate-200 text-sm" />
</div>
<div className="space-y-1 max-h-[60vh] overflow-y-auto">
{filtered.map((o) => (
<button key={o.org_id} onClick={() => open(o.org_id)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm ${selected === o.org_id ? "bg-brand-50 text-brand-700" : "hover:bg-slate-50"}`}>
{o.name}
</button>
))}
</div>
</div>
{/* Detail */}
<div className="space-y-4">
{!view ? (
<div className="card p-12 text-center text-slate-400">Select an organization to debug.</div>
) : (
<>
<div className="card p-5">
<h3 className="font-bold">{view.org.name}</h3>
<p className="text-sm text-slate-500 capitalize">{view.org.status} · {view.org.contact_email}</p>
</div>
<div className="card overflow-x-auto">
<h3 className="font-bold p-4 pb-2">Products ({view.products.length})</h3>
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500 text-left">
<tr><th className="px-4 py-2">Name</th><th className="px-4 py-2">SKU</th><th className="px-4 py-2">Status</th><th className="px-4 py-2">QRs</th></tr>
</thead>
<tbody className="divide-y divide-slate-100">
{view.products.map((p: any, i: number) => (
<tr key={i}><td className="px-4 py-2">{p.name}</td><td className="px-4 py-2">{p.sku}</td><td className="px-4 py-2 capitalize">{p.status}</td><td className="px-4 py-2">{p.qr_count}</td></tr>
))}
</tbody>
</table>
</div>
<div className="card overflow-x-auto">
<h3 className="font-bold p-4 pb-2">Recent Scans</h3>
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500 text-left">
<tr><th className="px-4 py-2">Time</th><th className="px-4 py-2">Code</th><th className="px-4 py-2">City</th><th className="px-4 py-2">Device</th></tr>
</thead>
<tbody className="divide-y divide-slate-100">
{view.recent_scans.map((s: any, i: number) => (
<tr key={i}><td className="px-4 py-2 text-slate-500">{new Date(s.at).toLocaleString()}</td><td className="px-4 py-2 font-mono">{s.code}</td><td className="px-4 py-2">{s.city ?? "—"}</td><td className="px-4 py-2">{s.device ?? "—"}</td></tr>
))}
{view.recent_scans.length === 0 && <tr><td colSpan={4} className="px-4 py-6 text-center text-slate-400">No scans.</td></tr>}
</tbody>
</table>
</div>
<div className="card p-5">
<h3 className="font-bold mb-3">Internal Notes</h3>
<div className="flex gap-2 mb-3">
<input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Add a debugging note…"
className="flex-1 px-3 py-2 rounded-lg border border-slate-200 text-sm" />
<button onClick={addNote} className="flex items-center gap-1 bg-brand-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"><Plus className="h-4 w-4" /> Add</button>
</div>
<div className="space-y-2">
{notes.map((n) => (
<div key={n.id} className="text-sm border-b border-slate-100 py-2">
<div>{n.note}</div>
<div className="text-xs text-slate-400">{n.author} · {new Date(n.at).toLocaleString()}</div>
</div>
))}
{notes.length === 0 && <p className="text-sm text-slate-400">No notes.</p>}
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { useEffect, useState } from "react";
import { Download } from "lucide-react";
import { api, apiBase } from "@/lib/api";
export default function Utilization() {
const [orgs, setOrgs] = useState<any[]>([]);
useEffect(() => { api("/admin/organizations").then(setOrgs).catch(() => {}); }, []);
const totals = orgs.reduce((a, o) => ({
products: a.products + o.products_used,
qrs: a.qrs + o.qr_codes,
scans: a.scans + o.total_scans,
revenue: a.revenue + (o.plan_price || 0),
}), { products: 0, qrs: 0, scans: 0, revenue: 0 });
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Utilization Report</h2>
<div className="flex gap-2">
<a href={`${apiBase}/admin/utilization-report?format=csv`} className="flex items-center gap-2 border border-slate-200 px-3 py-1.5 rounded-lg text-sm font-medium"><Download className="h-4 w-4" /> CSV</a>
<a href={`${apiBase}/admin/utilization-report?format=pdf`} className="flex items-center gap-2 border border-slate-200 px-3 py-1.5 rounded-lg text-sm font-medium"><Download className="h-4 w-4" /> PDF</a>
</div>
</div>
<div className="card overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500 text-left">
<tr>
<th className="px-4 py-3 font-medium">Organization</th>
<th className="px-4 py-3 font-medium">Plan</th>
<th className="px-4 py-3 font-medium">Products</th>
<th className="px-4 py-3 font-medium">Users</th>
<th className="px-4 py-3 font-medium">Storage</th>
<th className="px-4 py-3 font-medium">QRs</th>
<th className="px-4 py-3 font-medium">Scans</th>
<th className="px-4 py-3 font-medium">Revenue</th>
<th className="px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{orgs.map((o) => (
<tr key={o.org_id} className="hover:bg-slate-50">
<td className="px-4 py-3 font-medium">{o.name}</td>
<td className="px-4 py-3">{o.plan ?? "—"}</td>
<td className="px-4 py-3">{o.products_used} / {o.product_limit ?? "∞"}</td>
<td className="px-4 py-3">{o.users_used} / {o.user_limit ?? "∞"}</td>
<td className="px-4 py-3">{o.storage_used_gb} GB</td>
<td className="px-4 py-3">{o.qr_codes}</td>
<td className="px-4 py-3">{o.total_scans}</td>
<td className="px-4 py-3">{o.plan_price}</td>
<td className="px-4 py-3 capitalize">{o.subscription_status}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="bg-slate-50 font-semibold">
<td className="px-4 py-3" colSpan={2}>Totals ({orgs.length} orgs)</td>
<td className="px-4 py-3">{totals.products}</td>
<td className="px-4 py-3"></td>
<td className="px-4 py-3"></td>
<td className="px-4 py-3">{totals.qrs}</td>
<td className="px-4 py-3">{totals.scans}</td>
<td className="px-4 py-3">{totals.revenue}</td>
<td className="px-4 py-3"></td>
</tr>
</tfoot>
</table>
</div>
</div>
);
}