Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar shell, per-page help guides, and a layout/content pass across every remaining page) plus backend fixes to the dashboard KPI stats: - Admin/Supervisor dashboard KPIs (revenue, donations, registrations, tickets sold) now use a rolling trailing-month window (today back one calendar month, e.g. 9 May - 8 June if today is 8 June) instead of calendar month-to-date, which under-counted for most of the month. The comparison window shifts the same way, so like is still compared with like. - Reports deep-links from those stat tiles now match the same window (range=trailing_month, replacing range=this_month). - Design tokens (brand-* Tailwind scale + shadcn CSS variables), a site-wide contextual help button, fixed dashboard sidebar/navbar, Admin/Supervisor/Staff/User dashboard rebuilds backed by a new GET /api/stats/overview endpoint, a dedicated Contact page, Site Settings restyle with WhatsApp config folded in, and an Account activity feed backed by a new SecurityEvent model. - Every remaining page (home, events, registration flow, auth, legal, payment results, and every Admin/Supervisor/Staff/User tool page) restyled onto the same design tokens, several with real layout upgrades (home hero, events list/detail, donate page, auth pages). - 20+ new dedicated help guides so the whole site has page-specific help content instead of falling back to a generic guide. - Assorted fixes surfaced along the way: donation-leg double-counting in payment stats, donations not counting toward revenue, refund netting in per-method report breakdowns, and donation over-allocation after a refund. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
484 lines
22 KiB
TypeScript
484 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useRouter } from "next/navigation";
|
|
import { apiFetch } from "@/lib/api";
|
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
|
import { Users as UsersIcon } from "lucide-react";
|
|
import { RoleBadge, type Role as RoleBadgeRole } from "@/components/shared/RoleBadge";
|
|
|
|
interface UserItem {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
role: string;
|
|
phoneNumber?: string | null;
|
|
notificationPreference?: string;
|
|
isActive: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
const roleOptions = ["user", "staff", "supervisor", "admin"] as const;
|
|
type Role = typeof roleOptions[number];
|
|
|
|
const notificationPreferenceOptions = ["email", "whatsapp", "both"] as const;
|
|
|
|
// Simple fuzzy: tolerate one missing/swapped char by checking if query chars appear in order
|
|
function fuzzyMatch(query: string, target: string): boolean {
|
|
const q = query.toLowerCase();
|
|
const t = target.toLowerCase();
|
|
if (t.includes(q)) return true;
|
|
// token-based: all tokens must appear somewhere
|
|
const tokens = q.split(/\s+/).filter(Boolean);
|
|
return tokens.every(tok => t.includes(tok));
|
|
}
|
|
|
|
export default function AdminUsersPage() {
|
|
const { user, loading, token } = useAuth();
|
|
const router = useRouter();
|
|
|
|
const isAdmin = useMemo(() => user?.role === "admin", [user]);
|
|
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
if (!user) router.replace("/login");
|
|
}, [user, loading, router]);
|
|
|
|
// Data state
|
|
const [users, setUsers] = useState<UserItem[]>([]);
|
|
const [fetching, setFetching] = useState(false);
|
|
const [error, setError] = useDismissingState<string | null>(null);
|
|
const [page, setPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [total, setTotal] = useState(0);
|
|
const [pageSize, setPageSize] = useState(50);
|
|
|
|
// Search and filter state (server-side)
|
|
const [query, setQuery] = useState("");
|
|
const [debouncedQuery, setDebouncedQuery] = useState("");
|
|
const [roleFilter, setRoleFilter] = useState<Role | "">("");
|
|
const [activeFilter, setActiveFilter] = useState<"" | "true" | "false">("");
|
|
|
|
// Debounce search
|
|
useEffect(() => {
|
|
const t = setTimeout(() => setDebouncedQuery(query), 350);
|
|
return () => clearTimeout(t);
|
|
}, [query]);
|
|
|
|
// Create form state
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [cName, setCName] = useState("");
|
|
const [cEmail, setCEmail] = useState("");
|
|
const [cPassword, setCPassword] = useState("");
|
|
const [cPhone, setCPhone] = useState("");
|
|
const [cRole, setCRole] = useState<Role>("user");
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
// Edit modal state
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [editData, setEditData] = useState<Partial<UserItem> & { password?: string }>({});
|
|
const [saving, setSaving] = useState(false);
|
|
const editingUser = useMemo(() => users.find(u => u.id === editingId) || null, [users, editingId]);
|
|
|
|
const buildQuery = useCallback((p: number, ps = pageSize) => {
|
|
const qs = new URLSearchParams({ page: String(p), limit: String(ps) });
|
|
if (debouncedQuery.trim()) qs.set("search", debouncedQuery.trim());
|
|
if (roleFilter) qs.set("role", roleFilter);
|
|
if (activeFilter !== "") qs.set("isActive", activeFilter);
|
|
return `/api/users?${qs.toString()}`;
|
|
}, [debouncedQuery, roleFilter, activeFilter, pageSize]);
|
|
|
|
const loadUsers = useCallback(async (p = 1, ps = pageSize) => {
|
|
if (!token) return;
|
|
setError(null);
|
|
setFetching(true);
|
|
try {
|
|
const url = buildQuery(p, ps);
|
|
const res = await apiFetch<any>(url, { authToken: token });
|
|
setUsers(Array.isArray(res?.data) ? res.data : []);
|
|
setTotal(res?.total ?? 0);
|
|
setTotalPages(res?.pages ?? 1);
|
|
setPage(p);
|
|
} catch (e: any) {
|
|
setError(e?.message || "Failed to load users");
|
|
} finally {
|
|
setFetching(false);
|
|
}
|
|
}, [token, buildQuery]);
|
|
|
|
// Initial load + reload on filter change
|
|
useEffect(() => { if (token) loadUsers(1); }, [token, debouncedQuery, roleFilter, activeFilter]);
|
|
|
|
const goToPage = (p: number) => loadUsers(p);
|
|
|
|
const handlePageSizeChange = (newSize: number) => {
|
|
setPageSize(newSize);
|
|
loadUsers(1, newSize);
|
|
};
|
|
|
|
const resetCreateForm = () => {
|
|
setCName(""); setCEmail(""); setCPassword(""); setCPhone(""); setCRole("user");
|
|
};
|
|
|
|
const handleCreate = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!cEmail || !cPassword) { setError("Email and password are required"); return; }
|
|
try {
|
|
setCreating(true);
|
|
const created = await apiFetch<any>("/api/users", {
|
|
method: "POST",
|
|
body: { name: cName || cEmail.split("@")[0], email: cEmail, password: cPassword, phoneNumber: cPhone || undefined },
|
|
});
|
|
if (cRole && cRole !== "user" && created?.id) {
|
|
await apiFetch(`/api/users/${encodeURIComponent(created.id)}`, {
|
|
method: "PUT", authToken: token!, body: { role: cRole },
|
|
});
|
|
}
|
|
resetCreateForm();
|
|
setCreateOpen(false);
|
|
await loadUsers(page);
|
|
} catch (e: any) {
|
|
setError(e?.message || "Failed to create user");
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const startEdit = (u: UserItem) => { setEditingId(u.id); setEditData({ ...u, password: "" }); };
|
|
const cancelEdit = () => { setEditingId(null); setEditData({}); };
|
|
|
|
const saveEdit = async () => {
|
|
if (!editingId) return;
|
|
try {
|
|
setSaving(true);
|
|
const payload: any = {
|
|
name: editData.name,
|
|
email: editData.email,
|
|
role: editData.role,
|
|
phoneNumber: editData.phoneNumber || null,
|
|
notificationPreference: editData.notificationPreference,
|
|
isActive: editData.isActive,
|
|
};
|
|
if (editData.password && editData.password.trim().length > 0) {
|
|
payload.password = editData.password.trim();
|
|
}
|
|
await apiFetch(`/api/users/${encodeURIComponent(editingId)}`, {
|
|
method: "PUT", authToken: token!, body: payload,
|
|
});
|
|
setEditingId(null);
|
|
setEditData({});
|
|
await loadUsers(page);
|
|
} catch (e: any) {
|
|
setError(e?.message || "Failed to save user");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const revokeUserSessions = async (id: string, name: string) => {
|
|
if (!confirm(`Revoke all active sessions for ${name}? They will be signed out on all devices.`)) return;
|
|
try {
|
|
await apiFetch(`/api/users/${encodeURIComponent(id)}/revoke-sessions`, { method: "POST", authToken: token! });
|
|
} catch (e: any) {
|
|
setError(e?.message || "Failed to revoke sessions");
|
|
}
|
|
};
|
|
|
|
const deactivate = async (id: string) => {
|
|
if (!confirm("Deactivate this user? Their account will be disabled.")) return;
|
|
try {
|
|
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token! });
|
|
await loadUsers(page);
|
|
} catch (e: any) {
|
|
setError(e?.message || "Failed to deactivate user");
|
|
}
|
|
};
|
|
|
|
const deleteUserData = async (id: string, name: string) => {
|
|
if (!confirm(`Delete personal data for "${name}"?\n\nThis will set their name to "Deleted User", clear their email and phone number, and deactivate the account. This action cannot be undone.`)) return;
|
|
try {
|
|
await apiFetch(`/api/users/${encodeURIComponent(id)}/anonymize`, { method: "POST", authToken: token! });
|
|
await loadUsers(page);
|
|
} catch (e: any) {
|
|
setError(e?.message || "Failed to delete user data");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-6xl mx-auto w-full p-6">
|
|
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
|
<UsersIcon className="w-5 h-5 text-brand-600" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-gray-900">User Management</h1>
|
|
<p className="text-sm text-gray-500">{total} user{total !== 1 ? "s" : ""} total</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
|
|
<button className="px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700" onClick={() => setCreateOpen(v => !v)}>
|
|
{createOpen ? "Close" : "Create user"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{!isAdmin && (
|
|
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
|
You need admin access to manage users.
|
|
</div>
|
|
)}
|
|
|
|
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
|
|
|
{createOpen && (
|
|
<form onSubmit={handleCreate} className="border rounded-xl p-4 bg-white shadow-sm mb-6 grid sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-sm text-gray-700 mb-1">Name</label>
|
|
<input className="w-full border rounded px-3 py-2" value={cName} onChange={e => setCName(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-gray-700 mb-1">Email</label>
|
|
<input type="email" className="w-full border rounded px-3 py-2" value={cEmail} onChange={e => setCEmail(e.target.value)} required />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-gray-700 mb-1">Password</label>
|
|
<input type="password" className="w-full border rounded px-3 py-2" value={cPassword} onChange={e => setCPassword(e.target.value)} required />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-gray-700 mb-1">Phone</label>
|
|
<input className="w-full border rounded px-3 py-2" value={cPhone} onChange={e => setCPhone(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-gray-700 mb-1">Role</label>
|
|
<select className="w-full border rounded px-3 py-2" value={cRole} onChange={e => setCRole(e.target.value as Role)}>
|
|
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="flex items-end">
|
|
<button disabled={creating} className="px-3 py-2 rounded bg-brand-600 text-white disabled:opacity-50" type="submit">
|
|
{creating ? "Creating…" : "Create"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
|
{/* Filters row */}
|
|
<div className="flex flex-wrap items-end gap-3 mb-4">
|
|
<div className="flex-1 min-w-48">
|
|
<label className="block text-xs text-gray-600 mb-1">Search (name, email, phone)</label>
|
|
<input
|
|
className="w-full border rounded px-3 py-1.5 text-sm"
|
|
placeholder="Type to search…"
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Role</label>
|
|
<select className="border rounded px-2 py-1.5 text-sm" value={roleFilter} onChange={e => setRoleFilter(e.target.value as Role | "")}>
|
|
<option value="">All roles</option>
|
|
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Status</label>
|
|
<select className="border rounded px-2 py-1.5 text-sm" value={activeFilter} onChange={e => setActiveFilter(e.target.value as "" | "true" | "false")}>
|
|
<option value="">Active & inactive</option>
|
|
<option value="true">Active only</option>
|
|
<option value="false">Inactive only</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Per page</label>
|
|
<select className="border rounded px-2 py-1.5 text-sm" value={pageSize} onChange={e => handlePageSizeChange(Number(e.target.value))}>
|
|
<option value={10}>10</option>
|
|
<option value={25}>25</option>
|
|
<option value={50}>50</option>
|
|
<option value={100}>100</option>
|
|
</select>
|
|
</div>
|
|
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={() => loadUsers(page)} disabled={fetching}>
|
|
{fetching ? "Refreshing…" : "Refresh"}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="text-xs text-gray-500 mb-2">
|
|
{total > 0 && `Page ${page} of ${totalPages}`}
|
|
</div>
|
|
|
|
<div className="overflow-auto">
|
|
<table className="min-w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-gray-600 border-b">
|
|
<th className="p-2">Name</th>
|
|
<th className="p-2">Email</th>
|
|
<th className="p-2">Role</th>
|
|
<th className="p-2">Phone</th>
|
|
<th className="p-2">Notify</th>
|
|
<th className="p-2">Active</th>
|
|
<th className="p-2">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map(u => (
|
|
<tr key={u.id} className="border-t hover:bg-gray-50">
|
|
<td className="p-2">
|
|
<span className={`font-medium ${!u.isActive ? "text-gray-400" : ""}`}>{u.name}</span>
|
|
</td>
|
|
<td className="p-2">
|
|
<span className={u.email?.endsWith("@deleted.local") ? "text-gray-400 italic" : ""}>{u.email}</span>
|
|
</td>
|
|
<td className="p-2">
|
|
<RoleBadge role={u.role as RoleBadgeRole} />
|
|
</td>
|
|
<td className="p-2">
|
|
<span>{u.phoneNumber || ""}</span>
|
|
</td>
|
|
<td className="p-2">
|
|
<span className="capitalize">{u.notificationPreference || "email"}</span>
|
|
</td>
|
|
<td className="p-2">
|
|
<span className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full ${u.isActive ? "bg-green-50 text-green-700" : "bg-gray-100 text-gray-500"}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${u.isActive ? "bg-green-500" : "bg-gray-400"}`} />
|
|
{u.isActive ? "Active" : "Inactive"}
|
|
</span>
|
|
</td>
|
|
<td className="p-2">
|
|
<div className="flex gap-1 flex-wrap">
|
|
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
|
|
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Force this user to sign in again on every device where they're currently logged in">Sign out everywhere</button>
|
|
<button className="px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600" onClick={() => deactivate(u.id)} title="Deactivate account">Deactivate</button>
|
|
<button className="px-2 py-1 text-xs rounded bg-red-700 text-white hover:bg-red-800" onClick={() => deleteUserData(u.id, u.name)} title="Erase personal data">Delete data</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{users.length === 0 && !fetching && (
|
|
<tr>
|
|
<td className="p-3 text-gray-500" colSpan={7}>No users found.</td>
|
|
</tr>
|
|
)}
|
|
{fetching && (
|
|
<tr>
|
|
<td className="p-3 text-gray-400" colSpan={7}>Loading…</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between mt-4 text-sm">
|
|
<span className="text-gray-500">Page {page} of {totalPages}</span>
|
|
<div className="flex gap-1">
|
|
<button
|
|
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
|
|
disabled={page <= 1 || fetching}
|
|
onClick={() => goToPage(page - 1)}
|
|
>
|
|
← Prev
|
|
</button>
|
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
.filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 1)
|
|
.reduce<(number | "…")[]>((acc, p, i, arr) => {
|
|
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
|
|
acc.push(p);
|
|
return acc;
|
|
}, [])
|
|
.map((p, i) =>
|
|
p === "…" ? (
|
|
<span key={`ellipsis-${i}`} className="px-2 py-1 text-gray-400">…</span>
|
|
) : (
|
|
<button
|
|
key={p}
|
|
className={`px-2 py-1 rounded ${page === p ? "bg-brand-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
|
|
disabled={fetching}
|
|
onClick={() => goToPage(p as number)}
|
|
>
|
|
{p}
|
|
</button>
|
|
)
|
|
)}
|
|
<button
|
|
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
|
|
disabled={page >= totalPages || fetching}
|
|
onClick={() => goToPage(page + 1)}
|
|
>
|
|
Next →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{editingUser && (
|
|
<div className="fixed inset-0 z-20">
|
|
<div className="absolute inset-0 bg-black/30" onClick={() => !saving && cancelEdit()} />
|
|
<div className="absolute inset-0 flex items-center justify-center p-4">
|
|
<div className="w-full max-w-lg bg-white rounded-lg shadow-lg border p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className="text-base font-semibold">Edit user</h2>
|
|
<button type="button" className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Close</button>
|
|
</div>
|
|
<div className="grid gap-3">
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Name</label>
|
|
<input className="w-full border rounded px-3 py-2 text-sm" value={editData.name || ""} onChange={e => setEditData(d => ({ ...d, name: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Email</label>
|
|
<input className="w-full border rounded px-3 py-2 text-sm" value={editData.email || ""} onChange={e => setEditData(d => ({ ...d, email: e.target.value }))} />
|
|
</div>
|
|
<div className="grid sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Role</label>
|
|
<select className="w-full border rounded px-3 py-2 text-sm" value={(editData.role as Role) || "user"} onChange={e => setEditData(d => ({ ...d, role: e.target.value }))}>
|
|
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Phone</label>
|
|
<input className="w-full border rounded px-3 py-2 text-sm" value={editData.phoneNumber || ""} onChange={e => setEditData(d => ({ ...d, phoneNumber: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
<div className="grid sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Notification preference</label>
|
|
<select
|
|
className="w-full border rounded px-3 py-2 text-sm"
|
|
value={editData.notificationPreference || "email"}
|
|
onChange={e => setEditData(d => ({ ...d, notificationPreference: e.target.value }))}
|
|
>
|
|
{notificationPreferenceOptions.map(p => (
|
|
<option key={p} value={p} disabled={p !== "email" && !editData.phoneNumber}>{p}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex items-end pb-2">
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input type="checkbox" checked={!!editData.isActive} onChange={e => setEditData(d => ({ ...d, isActive: e.target.checked }))} />
|
|
Active
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">New password (leave blank to keep current)</label>
|
|
<input type="password" placeholder="Set new password" className="w-full border rounded px-3 py-2 text-sm" value={editData.password || ""} onChange={e => setEditData(d => ({ ...d, password: e.target.value }))} />
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 mt-4">
|
|
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Cancel</button>
|
|
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |