953 lines
26 KiB
TypeScript
953 lines
26 KiB
TypeScript
"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>
|
||
);
|
||
}
|