Files
verify/frontend/components/UsersPanel.tsx
Mohamed Mathar Irfan ed6610d5d8 Initial project upload
2026-07-28 17:57:02 +05:30

953 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect,useRef, useState } from "react";
import { UserPlus, X, Users as UsersIcon } from "lucide-react";
import { api } from "@/lib/api";
import { useLanguage } from "@/contexts/LanguageContext";
const ROLES = [
{ role: "admin", label: "Admin", desc: "Full access: products, QRs, batches, compliance, users, billing." },
{ role: "finance", label: "Finance", desc: "Billing, invoices, payment methods, subscription only." },
{ role: "user", label: "User", desc: "Create products, generate QRs, manage batches, view analytics." },
];
const ROLE_BADGE: Record<string, string> = {
admin: "bg-red-100 text-red-700 border border-red-200",
finance: "bg-violet-100 text-violet-700 border border-violet-200",
user: "bg-blue-100 text-blue-700 border border-blue-200",
manager: "bg-green-100 text-green-700 border border-green-200",
quality: "bg-orange-100 text-orange-700 border border-orange-200",
production: "bg-cyan-100 text-cyan-700 border border-cyan-200",
};
const STATUS_BADGE: Record<string, string> = {
active: "bg-emerald-50 text-emerald-700",
pending: "bg-amber-50 text-amber-700",
disabled: "bg-rose-50 text-rose-700",
};
export default function UsersPanel() {
const [tab, setTab] = useState<"users" | "roles">("users");
const [data, setData] = useState<any>(null);
const [org, setOrg] = useState<any>(null);
const [open, setOpen] = useState(false);
const [invite, setInvite] = useState({ email: "", role: "user", department: "",password: "",name: "", });
const [err, setErr] = useState("");
const { t } = useLanguage();
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [editingUser, setEditingUser] = useState<any>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const [search, setSearch] = useState("");
const [filters, setFilters] = useState({
role: "",
status: "",
department: "",
joinedFrom: "",
joinedTo: "",
});
const [detailTab, setDetailTab] = useState<
"profile" | "permissions" | "activity"
>("profile");
const [activityLogs, setActivityLogs] = useState<any[]>([]);
const [selectedUser, setSelectedUser] = useState<any>(null);
const filteredUsers = (data?.items ?? []).filter((m: any) => {
const keyword = search.toLowerCase();
const matchesSearch =
!search ||
m.name?.toLowerCase().includes(keyword) ||
m.email?.toLowerCase().includes(keyword);
const matchesRole =
!filters.role || m.role === filters.role;
const matchesStatus =
!filters.status || m.status === filters.status;
const matchesDepartment =
!filters.department ||
m.department === filters.department;
const joined = m.joined_at
? new Date(m.joined_at)
: null;
const from =
!filters.joinedFrom ||
(joined &&
joined >= new Date(filters.joinedFrom));
const to =
!filters.joinedTo ||
(joined &&
joined <= new Date(filters.joinedTo + "T23:59:59"));
return (
matchesSearch &&
matchesRole &&
matchesStatus &&
matchesDepartment &&
from &&
to
);
});
const [showFilters, setShowFilters] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
function toggleSelectAll(checked: boolean) {
if (checked) {
setSelected((data?.items ?? []).map((m: any) => m.member_id));
} else {
setSelected([]);
}
}
const toggleSelect = (memberId: string, checked: boolean) => {
if (checked) {
setSelected((prev) =>
prev.includes(memberId) ? prev : [...prev, memberId]
);
} else {
setSelected((prev) => prev.filter((id) => id !== memberId));
}
};async function updateUser() {
try {
await api(`/users/${editingUser.member_id}`, {
method: "PUT",
body: JSON.stringify({
role: invite.role,
department: invite.department,
}),
});
setOpen(false);
setEditingUser(null);
setInvite({
email: "",
role: "user",
department: "",
name: "",password: "",
});
load();
} catch (e) {
console.error(e);
}
}
function load() {
api("/users").then(setData).catch(() => setData({ items: [], kpis: {} }));
api("/organization").then(setOrg).catch(() => {});
}
useEffect(() => { load(); }, []);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
menuRef.current &&
!menuRef.current.contains(event.target as Node)
) {
setOpenMenu(null);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
async function sendInvite() {
setErr("");
try {
await api("/users/invite", { method: "POST", body: JSON.stringify(invite) });
setOpen(false);
setInvite({ email: "", role: "user", department: "" ,password: "",name: "",});
load();
} catch (e: any) {
setErr(typeof e.detail === "object" ? e.detail.message : e.detail || "Invite failed");
}
}
async function setRole(member_id: string, role: string) {
await api(`/users/${member_id}`, { method: "PUT", body: JSON.stringify({ role }) });
load();
}
async function setStatus(member_id: string, status: string) {
await api(`/users/${member_id}`, { method: "PUT", body: JSON.stringify({ status }) });
load();
}
const k = data?.kpis ?? {};
const roleSummary = data?.role_summary ?? {};
function Permission({ text }: { text: string }) {
return (
<div className="flex items-center gap-2 text-sm">
<div className="h-2 w-2 rounded-full bg-emerald-500"></div>
{text}
</div>
);
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">{t.users_management}</h2>
<div className="flex items-center gap-3">
<button
onClick={() => setOpen(true)}
className="flex items-center gap-2 bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-semibold"
>
<UserPlus className="h-4 w-4" />
{t.invite_user}
</button>
<button
onClick={async () => {
const token = localStorage.getItem("vp_token");
const res = await fetch(
"http://localhost:8000/users/export",
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "users.csv";
a.click();
window.URL.revokeObjectURL(url);
}}
className="flex items-center gap-2 border border-brand-600 text-brand-600 hover:bg-brand-50 px-4 py-2 rounded-lg text-sm font-semibold"
>
📥 Export Users
</button>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<Kpi label="Total Users" value={`${k.total ?? 0}${k.user_limit ? ` / ${k.user_limit}` : ""}`} />
<Kpi label="Active" value={k.active ?? 0} />
<Kpi label="Pending Invites" value={k.pending ?? 0} />
<Kpi label="Disabled" value={k.disabled ?? 0} />
</div>
<div className="flex gap-1 border-b border-slate-200">
{(["users", "roles"] as const).map((t) => (
<button key={t} onClick={() => setTab(t)}
className={`px-4 py-2 text-sm font-medium capitalize border-b-2 -mb-px ${tab === t ? "border-brand-600 text-brand-700" : "border-transparent text-slate-500"}`}>
{t === "roles" ? "Roles & Permissions" : "Users"}
</button>
))}
</div>
{tab === "users" ? (
<div className="card overflow-visible">
<div className="mb-4 flex flex-wrap items-center gap-3">
<input
type="text"
placeholder="Search user or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-72 rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
<select
value={filters.role}
onChange={(e)=>
setFilters({...filters,role:e.target.value})
}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
>
<option value="">All Roles</option>
<option value="admin">Admin</option>
<option value="finance">Finance</option>
<option value="user">User</option>
</select>
<select
value={filters.status}
onChange={(e)=>
setFilters({...filters,status:e.target.value})
}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="disabled">Disabled</option>
</select>
<select
value={filters.department}
onChange={(e)=>
setFilters({...filters,department:e.target.value})
}
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
>
<option value="">All Departments</option>
<option>Administration</option>
<option>Finance</option>
<option>Production</option>
<option>Quality</option>
<option>Warehouse</option>
<option>Sales</option>
<option>HR</option>
<option>Marketing</option>
</select>
<button
onClick={()=>{
setSearch("");
setFilters({
role:"",
status:"",
department:"",
joinedFrom:"",
joinedTo:"",
});
}}
className="rounded-lg border border-slate-300 px-4 py-2 text-sm"
>
Reset
</button>
</div>
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500 text-left">
<tr>
<th className="w-12 px-4 py-3 text-center">
<div className="flex justify-center">
<input
type="checkbox"
checked={
data?.items?.length > 0 &&
selected.length === data.items.length
}
onChange={(e) => toggleSelectAll(e.target.checked)}
className="h-4 w-4 rounded border-slate-300"
/>
</div>
</th> <th className="px-4 py-3 font-medium">{t.user}</th>
<th className="px-4 py-3 font-medium">{t.role}</th>
<th className="px-4 py-3 font-medium">{t.department}</th>
<th className="px-4 py-3 font-medium">{t.status}</th>
<th className="px-4 py-3 font-medium">{t.last_login}</th>
<th className="px-4 py-3 font-medium">Joined On</th>
<th className="px-4 py-3 text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredUsers.map((m:any,index:number)=>(
<tr
key={m.member_id}
onClick={async () => {
setSelectedUser(m);
setDetailTab("profile");
try {
const logs = await api(`/users/${m.member_id}/activity`);
setActivityLogs(logs.items ?? []);
} catch {
setActivityLogs([]);
}
}}
className="hover:bg-slate-50 cursor-pointer"
>
<td className="w-12 px-4 py-3 text-center">
<div className="flex justify-center">
<input
type="checkbox"
checked={selected.includes(m.member_id)}
onChange={(e) =>
toggleSelect(m.member_id, e.target.checked)
}
className="h-4 w-4 rounded border-slate-300"
/>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-full bg-brand-500 text-white grid place-items-center text-sm font-semibold">
{(m.name?.[0] ?? "U").toUpperCase()}
</div>
<div>
<div className="font-medium text-slate-800">{m.name}</div>
<div className="text-xs text-slate-400">{m.email}</div>
</div>
</div>
</td>
<td className="px-4 py-3">
<span
className={`px-3 py-1 rounded-full text-xs font-semibold capitalize ${ROLE_BADGE[m.role]}`}
>
{m.role}
</span>
</td>
<td className="px-4 py-3 text-slate-500">{m.department ?? "—"}</td>
<td className="px-4 py-3">
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full capitalize ${STATUS_BADGE[m.status]}`}>{m.status}</span>
</td>
<td className="px-4 py-3 text-slate-500">
{m.last_login
? new Date(m.last_login).toLocaleString()
: "—"}
</td>
<td className="px-4 py-3 text-slate-500">
{m.joined_at
? new Date(m.joined_at).toLocaleString()
: "—"}
</td>
<td
className="px-4 py-3 text-right"
onClick={(e)=>e.stopPropagation()}
>
<div className="relative inline-block" ref={menuRef}>
<button
onClick={() =>
setOpenMenu(openMenu === m.member_id ? null : m.member_id)
}
className="rounded-md p-2 hover:bg-slate-100"
>
</button>
{openMenu === m.member_id && (
<div className="absolute bottom-full right-0 mb-2 w-44 rounded-lg border border-slate-200 bg-white shadow-xl z-50">
<button
onClick={() => {
setInvite({
email: m.email,
role: m.role,
department: m.department ?? "",
name: m.name ?? "",
password: m.password ?? "",
});
setEditingUser(m);
setOpen(true);
setOpenMenu(null);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-slate-50"
>
Edit User
</button>
<button
onClick={() => {
setStatus(
m.member_id,
m.status === "active" ? "disabled" : "active"
);
setOpenMenu(null);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-slate-50"
>
{m.status === "active"
? "🚫 Disable User"
: "✅ Enable User"}
</button>
</div>
)}
</div>
</td>
</tr>
))}
{data?.items?.length === 0 && (
<tr><td colSpan={6} className="px-4 py-12 text-center text-slate-400">
<UsersIcon className="h-8 w-8 mx-auto mb-2 opacity-50" /> No team members yet.
</td></tr>
)}
</tbody>
</table>
</div>
) : (
<>
<h3 className="text-lg font-semibold mb-4">
Role Summary
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{ROLES.map((r) => {
const count = (data?.items ?? []).filter(
(u: any) => u.role === r.role
).length;
return (
<div
key={r.role}
className="rounded-xl border bg-white p-5"
>
<div
className={`inline-flex px-3 py-1 rounded-full text-xs font-semibold ${ROLE_BADGE[r.role]}`}
>
{r.label}
</div>
<div className="text-3xl font-bold mt-4">
{count}
</div>
<div className="text-sm text-slate-500 mt-2">
{r.desc}
</div>
</div>
);
})}
</div>
</>
)}
{/* Org profile */}
{org && (
<div className="card p-5">
<h3 className="font-bold mb-3">{t.organization_profile}</h3>
<div className="grid sm:grid-cols-2 gap-3 text-sm">
<Info label="Name" value={org.name} />
<Info label="Org ID" value={org.slug} />
<Info label="GSTIN" value={org.gstin} />
<Info label="Timezone" value={org.timezone} />
<Info label="Plan" value={org.plan?.name} />
<Info label="Language" value={org.language} />
<Info label="Address" value={org.address} />
</div>
</div>
)}
{selectedUser && (
<div
className="fixed inset-0 bg-black/30 grid place-items-center z-50"
onClick={() => setSelectedUser(null)}
>
<div
className="bg-white rounded-2xl w-full max-w-sm p-5 shadow-xl"
onClick={(e)=>e.stopPropagation()}
>
{/* Header */}
<div className="flex justify-between items-start">
<h3 className="font-bold text-lg">
User Detail
</h3>
<button onClick={()=>setSelectedUser(null)}>
<X className="h-5 w-5 text-slate-400"/>
</button>
</div>
{/* Profile */}
{/* Profile */}
<div className="mt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="
h-12 w-12 rounded-full
bg-brand-100 text-brand-700
grid place-items-center
font-bold
"
>
{selectedUser.name?.[0]?.toUpperCase()}
</div>
<div>
<div className="font-semibold text-base">
{selectedUser.name}
</div>
<div className="text-xs text-slate-500 mt-1">
{selectedUser.role}
</div>
</div>
</div>
{/* Enable / Disable button */}
<span className={`
text-xs px-2 py-1 rounded-full
${STATUS_BADGE[selectedUser.status]}
`}>
{selectedUser.status}
</span>
</div>
{/* Email + Phone */}
<div className="mt-4 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-500">
Email
</span>
<span className="font-medium">
{selectedUser.email ?? "—"}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">
Phone
</span>
<span className="font-medium">
{selectedUser.phone ?? "—"}
</span>
</div>
</div>
</div>
<div className="flex border-b mt-5">
<button
onClick={() => setDetailTab("profile")}
className={`px-4 py-2 text-sm ${
detailTab === "profile"
? "border-b-2 border-brand-600 font-semibold text-brand-700"
: "text-slate-500"
}`}
>
Profile
</button>
<button
onClick={() => setDetailTab("permissions")}
className={`px-4 py-2 text-sm ${
detailTab === "permissions"
? "border-b-2 border-brand-600 font-semibold text-brand-700"
: "text-slate-500"
}`}
>
Role & Permissions
</button>
<button
onClick={() => setDetailTab("activity")}
className={`px-4 py-2 text-sm ${
detailTab === "activity"
? "border-b-2 border-brand-600 font-semibold text-brand-700"
: "text-slate-500"
}`}
>
Activity Log
</button>
</div>
{/* Details */}
{detailTab === "profile" && (
<>
{/* Your existing Info components */}
<Info label="Department" value={selectedUser.department} />
<Info
label="Joined On"
value={
selectedUser.joined_at
? new Date(selectedUser.joined_at).toLocaleDateString()
: "—"
}
/>
<Info
label="Last Login"
value={
selectedUser.last_login
? new Date(selectedUser.last_login).toLocaleString()
: "—"
}
/>
<Info label="Email" value={selectedUser.email} />
<Info label="Phone" value={selectedUser.phone} />
<Info label="Timezone" value={org?.timezone} />
<Info label="Language" value={org?.language} />
</>
)}
{detailTab === "permissions" && (
<div className="space-y-4 mt-4">
<Info
label="Current Role"
value={selectedUser.role}
/>
<Info
label="Department"
value={selectedUser.department}
/>
<div>
<h4 className="font-semibold mb-2">
Permissions
</h4>
<div className="space-y-2">
{selectedUser.role === "admin" && (
<>
<Permission text="Manage Products" />
<Permission text="Manage Users" />
<Permission text="Billing" />
<Permission text="Generate QR Codes" />
<Permission text="Compliance" />
<Permission text="Organization Settings" />
</>
)}
{selectedUser.role === "finance" && (
<>
<Permission text="Invoices" />
<Permission text="Subscription" />
<Permission text="Payments" />
</>
)}
{selectedUser.role === "user" && (
<>
<Permission text="Products" />
<Permission text="QR Generation" />
<Permission text="Batch Management" />
<Permission text="Analytics" />
</>
)}
</div>
</div>
</div>
)}
{detailTab === "activity" && (
<div className="mt-4">
{activityLogs.length === 0 ? (
<div className="text-center text-slate-400 py-8">
No activity found
</div>
) : (
<div className="space-y-3 max-h-72 overflow-y-auto">
{activityLogs.map((log) => (
<div
key={log.id}
className="border rounded-lg p-3"
>
<div className="font-medium text-sm">
{log.action}
</div>
<div className="text-xs text-slate-500 mt-1">
{log.details}
</div>
<div className="text-xs text-slate-400 mt-2">
{new Date(log.created_at).toLocaleString()}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Buttons */}
<div className="flex gap-2 mt-5">
<button
onClick={()=>{
setInvite({
email:selectedUser.email,
role:selectedUser.role,
department:selectedUser.department ?? "",
name:selectedUser.name?? "",
password:selectedUser.password ?? "",
});
setEditingUser(selectedUser);
setSelectedUser(null);
setOpen(true);
}}
className="
flex-1 border rounded-lg py-2 text-sm
"
>
Edit User
</button>
<button
onClick={()=>{
setStatus(
selectedUser.member_id,
selectedUser.status==="active"
?"disabled"
:"active"
);
setSelectedUser(null);
}}
className={`
flex-1 border rounded-lg py-2 text-sm
${
selectedUser.status==="disabled"
?"border-emerald-200 text-emerald-600"
:"border-red-200 text-red-600"
}
`}
>
{
selectedUser.status==="disabled"
? "✅ Enable User"
: "🚫 Disable User"
}
</button>
</div>
</div>
</div>
)}
{/* Invite modal */}
{open && (
<div className="fixed inset-0 bg-black/30 grid place-items-center z-50" onClick={() => setOpen(false)}>
<div className="bg-white rounded-2xl w-full max-w-md p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-lg">
{editingUser ? "Edit User" : t.invite_user}
</h3>
<button onClick={() => setOpen(false)}><X className="h-5 w-5 text-slate-400" /></button>
</div>
{err && <p className="text-sm text-rose-600 mb-3">{err}</p>}
<label className="block mb-3">
<span className="text-sm font-medium">Name</span>
<input
value={invite.name}
onChange={(e) =>
setInvite({ ...invite, name: e.target.value })
}
className="mt-1 w-full rounded-lg border border-slate-200 px-3 py-2"
/>
</label>
<label className="block mb-3">
<span className="text-sm font-medium">Email</span>
<input
value={invite.email}
disabled={!!editingUser}
onChange={(e) =>
setInvite({ ...invite, email: e.target.value })
}
className="mt-1 w-full px-3 py-2 rounded-lg border border-slate-200 text-sm disabled:bg-slate-100"
/>
</label>
<label className="block mb-3">
<span className="text-sm font-medium">Role</span>
<select value={invite.role} onChange={(e) => setInvite({ ...invite, role: e.target.value })}
className="mt-1 w-full px-3 py-2 rounded-lg border border-slate-200 text-sm">
{ROLES.map((r) => <option key={r.role} value={r.role}>{r.label}</option>)}
</select>
</label>
{!editingUser && (
<label className="block mb-3">
<span>Password</span>
<input
type="password"
value={invite.password}
onChange={(e)=>
setInvite({...invite,password:e.target.value})
}
className="mt-1 w-full px-3 py-2 rounded-lg border"
/>
</label>
)}
<label className="block mb-4">
<span className="text-sm font-medium">Department (optional)</span>
<select
value={invite.department}
onChange={(e) =>
setInvite({ ...invite, department: e.target.value })
}
className="mt-1 w-full px-3 py-2 rounded-lg border border-slate-200 text-sm"
>
<option value="">Select Department</option>
<option value="Administration">Administration</option>
<option value="Production">Production</option>
<option value="Quality">Quality</option>
<option value="Warehouse">Warehouse</option>
<option value="Sales">Sales</option>
<option value="Finance">Finance</option>
<option value="HR">HR</option>
<option value="Marketing">Marketing</option>
</select>
</label>
<button
onClick={editingUser ? updateUser : sendInvite}
className="w-full bg-brand-600 text-white py-2.5 rounded-lg font-semibold"
>
{editingUser ? "Save Changes" : "Send Invite"}
</button>
</div>
</div>
)}
</div>
);
}
function Kpi({ label, value }: any) {
return (
<div className="card p-5">
<div className="text-sm text-slate-500">{label}</div>
<div className="text-2xl font-extrabold mt-1">{value}</div>
</div>
);
}
function Info({ label, value }: any) {
return (
<div className="flex justify-between border-b border-slate-100 py-2">
<span className="text-slate-500">{label}</span>
<span className="font-medium text-slate-800">{value ?? "—"}</span>
</div>
);
}