119 lines
6.3 KiB
TypeScript
119 lines
6.3 KiB
TypeScript
"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>
|
|
);
|
|
}
|