187 lines
8.2 KiB
TypeScript
187 lines
8.2 KiB
TypeScript
"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>
|
|
);
|
|
}
|