Files
TX/artifacts/tx-os/src/pages/admin.tsx
T
riyadhafraa e0c586c0e2 Add review changes dialog and improve language handling
Introduce a review changes dialog for user edits and fix language persistence by correcting the localStorage key to "tx-lang".

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 1a5f18f9-0674-406a-af5d-19b28df896fc
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/wYObB6M
Replit-Helium-Checkpoint-Created: true
2026-04-29 20:04:21 +00:00

5183 lines
197 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import {
useCreateApp,
useUpdateApp,
useDeleteApp,
useListAppPermissions,
getListAppPermissionsQueryKey,
useAddAppPermission,
useRemoveAppPermission,
useListServices,
getListServicesQueryKey,
useCreateService,
useUpdateService,
useDeleteService,
useListUsers,
getListUsersQueryKey,
useCreateUser,
useUpdateUser,
useDeleteUser,
useAddUserRole,
useRemoveUserRole,
useGetAppSettings,
getGetAppSettingsQueryKey,
useUpdateAppSettings,
useGetAdminStats,
getGetAdminStatsQueryKey,
useGetAdminAppOpensByApp,
getGetAdminAppOpensByAppQueryKey,
getAdminAppOpensByApp,
useGetAdminAppOpensByUser,
getGetAdminAppOpensByUserQueryKey,
getAdminAppOpensByUser,
useAdminIssueResetLink,
useListGroups,
getListGroupsQueryKey,
useGetGroup,
getGetGroupQueryKey,
useCreateGroup,
useUpdateGroup,
useDeleteGroup,
useListRoles,
getListRolesQueryKey,
useCreateRole,
useUpdateRole,
useDeleteRole,
useListPermissions,
getListPermissionsQueryKey,
useGetRolePermissions,
getGetRolePermissionsQueryKey,
useGetRoleUsage,
getGetRoleUsageQueryKey,
useReplaceRolePermissions,
usePreviewRolePermissionsImpact,
useGetRolePermissionAudit,
getGetRolePermissionAuditQueryKey,
useListAuditLogs,
getListAuditLogsQueryKey,
getExportAuditLogsCsvUrl,
ApiError,
} from "@workspace/api-client-react";
import type {
GroupDeletionConflict,
AppDeletionConflict,
ServiceDeletionConflict,
UserDeletionConflict,
App,
Service,
UserProfile,
AppSettings,
AdminStats,
AdminAppOpensByApp,
AdminAppOpensByUser,
GetAdminStatsQueryError,
GetAdminStatsParams,
Group,
ListUsersParams,
ListAuditLogsParams,
AuditLogEntry,
RolePermissionsImpact,
RolePermissionAuditEntry,
Permission,
} from "@workspace/api-client-react";
import { ListUsersStatus } from "@workspace/api-client-react";
import { useQueryClient, useQuery } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
import { resolveServiceImageUrl } from "@/lib/image-url";
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText, Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
import { formatDate as formatDateUtil, formatWeekday as formatWeekdayUtil } from "@/lib/i18n-format";
const ADMIN_APPS_KEY = ["/api/admin/apps"] as const;
async function fetchAdminApps(): Promise<App[]> {
const res = await fetch("/api/admin/apps", { credentials: "include" });
if (!res.ok) throw new Error("Failed to fetch admin apps");
return res.json();
}
type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "roles" | "audit-log" | "settings";
function LeaderboardAvatar({
src,
initial,
alt,
}: {
src: string | null;
initial: string;
alt: string;
}) {
const [errored, setErrored] = useState(false);
useEffect(() => {
setErrored(false);
}, [src]);
const showImage = src && !errored;
return (
<div className="w-8 h-8 rounded-full shrink-0 bg-emerald-100/70 text-emerald-700 flex items-center justify-center text-xs font-semibold overflow-hidden">
{showImage ? (
<img
src={src}
alt={alt}
onError={() => setErrored(true)}
className="w-full h-full object-cover"
/>
) : (
initial
)}
</div>
);
}
type AppForm = {
nameAr: string;
nameEn: string;
slug: string;
iconName: string;
route: string;
color: string;
sortOrder: number;
};
type ServiceForm = {
nameAr: string;
nameEn: string;
descriptionAr: string;
descriptionEn: string;
price: string;
imageUrl: string;
isAvailable: boolean;
};
function DeletionWarningDialog({
title,
items,
warning,
forceHint,
emptyBody,
confirmLabel,
anywayLabel,
showWarning,
isPending,
onCancel,
onConfirm,
testId,
confirmTestId,
}: {
title: string;
items: string[];
warning: string;
forceHint: string;
emptyBody: string;
confirmLabel: string;
anywayLabel: string;
showWarning: boolean;
isPending: boolean;
onCancel: () => void;
onConfirm: () => void;
testId?: string;
confirmTestId?: string;
}) {
const { t } = useTranslation();
return (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div
className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3"
data-testid={testId}
>
<h2 className="font-semibold text-foreground">{title}</h2>
{showWarning ? (
<>
<p className="text-sm text-destructive">{warning}</p>
{items.length > 0 && (
<ul className="text-sm text-foreground space-y-1 ps-4 list-disc">
{items.map((item, idx) => (
<li key={idx}>{item}</li>
))}
</ul>
)}
<p className="text-xs text-muted-foreground">{forceHint}</p>
</>
) : (
<p className="text-sm text-muted-foreground">{emptyBody}</p>
)}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
className="flex-1"
onClick={onCancel}
disabled={isPending}
>
{t("common.cancel")}
</Button>
<Button
className="flex-1"
variant="destructive"
disabled={isPending}
onClick={onConfirm}
data-testid={confirmTestId}
>
{showWarning ? anywayLabel : confirmLabel}
</Button>
</div>
</div>
</div>
);
}
function AppPermissionsEditor({ appId }: { appId: number }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { toast } = useToast();
const { data: assigned, isLoading } = useListAppPermissions(appId, {
query: { queryKey: getListAppPermissionsQueryKey(appId) },
});
const { data: allPermissions } = useListPermissions({
query: { queryKey: getListPermissionsQueryKey() },
});
const addPermission = useAddAppPermission();
const removePermission = useRemoveAppPermission();
const [pendingId, setPendingId] = useState<number | "">("");
const assignedIds = useMemo(
() => new Set((assigned ?? []).map((p) => p.id)),
[assigned],
);
const available = useMemo(
() => (allPermissions ?? []).filter((p) => !assignedIds.has(p.id)),
[allPermissions, assignedIds],
);
const refresh = () => {
queryClient.invalidateQueries({
queryKey: getListAppPermissionsQueryKey(appId),
});
queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY });
};
const onAdd = () => {
if (pendingId === "" || typeof pendingId !== "number") return;
addPermission.mutate(
{ id: appId, data: { permissionId: pendingId } },
{
onSuccess: () => {
setPendingId("");
refresh();
},
onError: () => {
toast({ title: t("common.error"), variant: "destructive" });
},
},
);
};
const onRemove = (permissionId: number) => {
removePermission.mutate(
{ id: appId, permissionId },
{
onSuccess: refresh,
onError: () => {
toast({ title: t("common.error"), variant: "destructive" });
},
},
);
};
return (
<div
className="space-y-2 border-t border-slate-200/70 pt-3"
data-testid="app-permissions-editor"
>
<Label>{t("admin.appPermissions.title")}</Label>
<p className="text-xs text-muted-foreground">
{t("admin.appPermissions.help")}
</p>
{isLoading ? (
<p className="text-xs text-muted-foreground">{t("common.loading")}</p>
) : (assigned?.length ?? 0) === 0 ? (
<p className="text-xs text-muted-foreground">
{t("admin.appPermissions.none")}
</p>
) : (
<ul className="space-y-1" data-testid="app-permissions-list">
{assigned!.map((p) => (
<li
key={p.id}
className="flex items-center justify-between gap-2 rounded-xl bg-white/70 border border-slate-200 px-3 py-1.5"
>
<span className="text-sm text-foreground truncate">{p.name}</span>
<button
type="button"
aria-label={t("admin.appPermissions.removeAria", {
name: p.name,
})}
className="text-muted-foreground hover:text-destructive transition-colors"
disabled={removePermission.isPending}
onClick={() => onRemove(p.id)}
data-testid={`app-permission-remove-${p.id}`}
>
<Trash2 size={14} />
</button>
</li>
))}
</ul>
)}
<div className="flex items-center gap-2">
<select
className="flex-1 rounded-xl bg-white/70 border border-slate-200 px-3 py-2 text-sm"
value={pendingId === "" ? "" : String(pendingId)}
onChange={(e) =>
setPendingId(e.target.value === "" ? "" : Number(e.target.value))
}
disabled={available.length === 0 || addPermission.isPending}
data-testid="app-permission-select"
>
<option value="">{t("admin.appPermissions.selectPlaceholder")}</option>
{available.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<Button
size="sm"
onClick={onAdd}
disabled={
pendingId === "" ||
addPermission.isPending ||
available.length === 0
}
data-testid="app-permission-add"
>
{t("admin.appPermissions.add")}
</Button>
</div>
</div>
);
}
export default function AdminPage() {
const { t, i18n } = useTranslation();
const [, setLocation] = useLocation();
const { user } = useAuth();
const lang = i18n.language;
const isRtl = lang === "ar";
const queryClient = useQueryClient();
const { toast } = useToast();
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
const isAdmin = user?.roles?.includes("admin") ?? false;
// Redirect non-admins using useEffect to avoid violating rules of hooks
useEffect(() => {
if (user && !isAdmin) {
setLocation("/");
}
}, [user, isAdmin, setLocation]);
const { data: apps } = useQuery({ queryKey: ADMIN_APPS_KEY, queryFn: fetchAdminApps, enabled: isAdmin });
const { data: services } = useListServices({ query: { queryKey: getListServicesQueryKey() } });
const { data: users } = useListUsers(undefined, { query: { queryKey: getListUsersQueryKey() } });
const { data: appSettings } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } });
const statsRangeStorageKey = user?.id ? `admin.statsRange.${user.id}` : null;
const [statsRange, setStatsRange] = useState<"7d" | "30d" | "90d" | "custom">("7d");
const [statsRangeHydrated, setStatsRangeHydrated] = useState(false);
useEffect(() => {
setStatsRangeHydrated(false);
if (typeof window === "undefined" || !statsRangeStorageKey) return;
const stored = window.localStorage.getItem(statsRangeStorageKey);
if (stored === "7d" || stored === "30d" || stored === "90d") {
setStatsRange(stored);
}
setStatsRangeHydrated(true);
}, [statsRangeStorageKey]);
useEffect(() => {
if (typeof window === "undefined" || !statsRangeStorageKey || !statsRangeHydrated) return;
if (statsRange === "7d" || statsRange === "30d" || statsRange === "90d") {
window.localStorage.setItem(statsRangeStorageKey, statsRange);
}
}, [statsRange, statsRangeStorageKey, statsRangeHydrated]);
const todayIso = new Date().toISOString().slice(0, 10);
const sevenAgoIso = new Date(Date.now() - 6 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
const [customFrom, setCustomFrom] = useState<string>(sevenAgoIso);
const [customTo, setCustomTo] = useState<string>(todayIso);
const [appliedCustom, setAppliedCustom] = useState<{ from: string; to: string }>({
from: sevenAgoIso,
to: todayIso,
});
const isCustomValid =
/^\d{4}-\d{2}-\d{2}$/.test(appliedCustom.from) &&
/^\d{4}-\d{2}-\d{2}$/.test(appliedCustom.to) &&
appliedCustom.from <= appliedCustom.to;
const statsQueryParams =
statsRange === "custom"
? { range: "custom" as const, from: appliedCustom.from, to: appliedCustom.to }
: { range: statsRange };
const { data: adminStats, error: adminStatsError } = useGetAdminStats(statsQueryParams, {
query: {
queryKey: getGetAdminStatsQueryKey(statsQueryParams),
enabled: isAdmin && (statsRange !== "custom" || isCustomValid),
retry: false,
},
});
const adminStatsErrorMessage: string | null = (() => {
if (!adminStatsError) return null;
const err = adminStatsError as GetAdminStatsQueryError;
return err.data?.error ?? err.message ?? null;
})();
const createApp = useCreateApp();
const updateApp = useUpdateApp();
const deleteApp = useDeleteApp();
const createService = useCreateService();
const updateService = useUpdateService();
const deleteService = useDeleteService();
const createUser = useCreateUser();
const updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
const addUserRole = useAddUserRole();
const removeUserRole = useRemoveUserRole();
const issueResetLink = useAdminIssueResetLink();
const [resetLinkUser, setResetLinkUser] = useState<{ username: string; url: string; expiresAt: string } | null>(null);
const [editingApp, setEditingApp] = useState<{ id?: number; form: AppForm } | null>(null);
const [editingService, setEditingService] = useState<{ id?: number; form: ServiceForm } | null>(null);
const [appDeleteTarget, setAppDeleteTarget] = useState<App | null>(null);
const [appDeleteConflict, setAppDeleteConflict] = useState<AppDeletionConflict | null>(null);
const [serviceDeleteTarget, setServiceDeleteTarget] = useState<Service | null>(null);
const [serviceDeleteConflict, setServiceDeleteConflict] = useState<ServiceDeletionConflict | null>(null);
const closeAppDeleteDialog = () => {
setAppDeleteTarget(null);
setAppDeleteConflict(null);
};
const closeServiceDeleteDialog = () => {
setServiceDeleteTarget(null);
setServiceDeleteConflict(null);
};
const performAppDelete = (app: App, force: boolean) => {
deleteApp.mutate(
{ id: app.id, params: force ? { force: true } : undefined },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY });
toast({ title: t("common.success") });
closeAppDeleteDialog();
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409 && err.data) {
setAppDeleteConflict(err.data as AppDeletionConflict);
return;
}
toast({ title: t("common.error"), variant: "destructive" });
},
},
);
};
const performServiceDelete = (service: Service, force: boolean) => {
deleteService.mutate(
{ id: service.id, params: force ? { force: true } : undefined },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListServicesQueryKey() });
toast({ title: t("common.success") });
closeServiceDeleteDialog();
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409 && err.data) {
setServiceDeleteConflict(err.data as ServiceDeletionConflict);
return;
}
toast({ title: t("common.error"), variant: "destructive" });
},
},
);
};
const [newUserForm, setNewUserForm] = useState<{ username: string; email: string; password: string } | null>(null);
const [section, setSection] = useState<AdminSection>("dashboard");
const [navOpenGroups, setNavOpenGroups] = useState<Record<string, boolean>>({});
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [highlightedUserId, setHighlightedUserId] = useState<number | null>(null);
const userRowRefs = useRef<Map<number, HTMLDivElement>>(new Map());
useEffect(() => {
if (section !== "users" || highlightedUserId == null) return;
const node = userRowRefs.current.get(highlightedUserId);
if (node) {
node.scrollIntoView({ behavior: "smooth", block: "center" });
}
const timer = window.setTimeout(() => setHighlightedUserId(null), 2500);
return () => window.clearTimeout(timer);
}, [section, highlightedUserId, users]);
const handleSelectAppFromDashboard = (appId: number) => {
const app = apps?.find((a) => a.id === appId);
setSection("apps");
if (app) {
setEditingApp({
id: app.id,
form: {
nameAr: app.nameAr ?? "",
nameEn: app.nameEn ?? "",
slug: app.slug ?? "",
iconName: app.iconName ?? "Grid2X2",
route: app.route ?? "/",
color: app.color ?? "#6366f1",
sortOrder: app.sortOrder ?? 0,
},
});
}
};
const handleSelectUserFromDashboard = (userId: number) => {
setSection("users");
setHighlightedUserId(userId);
};
type NavLeaf = { key: AdminSection; label: string; Icon: typeof LayoutDashboard };
type NavGroup = { groupKey: string; label: string; Icon: typeof LayoutDashboard; children: NavLeaf[] };
type NavEntry = NavLeaf | NavGroup;
const navItems: NavEntry[] = [
{ key: "dashboard", label: t("admin.nav.dashboard"), Icon: LayoutDashboard },
{ key: "apps", label: t("admin.nav.apps"), Icon: Grid2X2 },
{ key: "services", label: t("admin.nav.services"), Icon: Coffee },
{
groupKey: "user-management",
label: t("admin.nav.userManagement"),
Icon: UsersIcon,
children: [
{ key: "users", label: t("admin.nav.users"), Icon: UsersIcon },
{ key: "groups", label: t("admin.nav.groups"), Icon: Shield },
{ key: "roles", label: t("admin.nav.roles"), Icon: KeyRound },
{ key: "audit-log", label: t("admin.nav.auditLog"), Icon: ScrollText },
],
},
{ key: "settings", label: t("admin.nav.settings"), Icon: Settings },
];
// Sync section to URL hash so deep links work
useEffect(() => {
if (typeof window === "undefined") return;
const hash = window.location.hash.replace(/^#/, "");
const sectionMatch = hash.match(/section=([a-z-]+)/);
if (sectionMatch) {
const s = sectionMatch[1] as AdminSection;
if (["dashboard", "apps", "services", "users", "groups", "roles", "audit-log", "settings"].includes(s)) {
setSection(s);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
const newHash = `#section=${section}`;
if (window.location.hash !== newHash) {
window.history.replaceState(null, "", newHash);
}
}, [section]);
const renderNavList = (onSelect?: () => void) => {
const renderLeaf = (leaf: NavLeaf, indent = false) => {
const active = section === leaf.key;
const Icon = leaf.Icon;
return (
<button
key={leaf.key}
onClick={() => {
setSection(leaf.key);
onSelect?.();
}}
className={cn(
"flex items-center gap-3 w-full py-2.5 rounded-xl text-sm transition-all text-start relative",
indent ? "ps-9 pe-3" : "px-3",
active
? "bg-primary text-primary-foreground font-semibold shadow-md shadow-primary/20"
: "text-foreground/70 hover:bg-slate-100 hover:text-foreground",
)}
data-testid={`nav-${leaf.key}`}
>
<Icon size={16} className="shrink-0" />
<span className="truncate">{leaf.label}</span>
</button>
);
};
return (
<nav className="flex flex-col gap-1">
{navItems.map((entry) => {
if ("children" in entry) {
const Icon = entry.Icon;
const anyChildActive = entry.children.some((c) => c.key === section);
const expanded = navOpenGroups[entry.groupKey] ?? anyChildActive;
return (
<div key={entry.groupKey} className="flex flex-col gap-0.5">
<button
type="button"
onClick={() =>
setNavOpenGroups((prev) => ({
...prev,
[entry.groupKey]: !(prev[entry.groupKey] ?? anyChildActive),
}))
}
aria-expanded={expanded}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 rounded-xl text-xs uppercase tracking-wider transition-colors text-start",
anyChildActive ? "text-primary" : "text-muted-foreground hover:text-foreground",
)}
data-testid={`nav-group-${entry.groupKey}`}
>
<Icon size={14} className="shrink-0" />
<span className="truncate flex-1">{entry.label}</span>
<ChevronDown
size={14}
className={cn(
"shrink-0 transition-transform",
expanded ? "rotate-0" : "-rotate-90",
)}
/>
</button>
{expanded && entry.children.map((leaf) => renderLeaf(leaf, true))}
</div>
);
}
return renderLeaf(entry, false);
})}
</nav>
);
};
const emptyAppForm: AppForm = { nameAr: "", nameEn: "", slug: "", iconName: "Grid2X2", route: "/", color: "#6366f1", sortOrder: 0 };
const emptyServiceForm: ServiceForm = { nameAr: "", nameEn: "", descriptionAr: "", descriptionEn: "", price: "0.00", imageUrl: "", isAvailable: true };
const handleSaveApp = () => {
if (!editingApp) return;
if (editingApp.id) {
updateApp.mutate(
{ id: editingApp.id, data: editingApp.form },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY });
setEditingApp(null);
toast({ title: t("common.success") });
},
},
);
} else {
createApp.mutate(
{ data: editingApp.form },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY });
setEditingApp(null);
toast({ title: t("common.success") });
},
},
);
}
};
const handleSaveService = () => {
if (!editingService) return;
if (editingService.id) {
updateService.mutate(
{ id: editingService.id, data: editingService.form },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListServicesQueryKey() });
setEditingService(null);
toast({ title: t("common.success") });
},
},
);
} else {
createService.mutate(
{ data: editingService.form },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListServicesQueryKey() });
setEditingService(null);
toast({ title: t("common.success") });
},
},
);
}
};
const handleCreateUser = () => {
if (!newUserForm) return;
createUser.mutate(
{ data: { username: newUserForm.username, email: newUserForm.email, password: newUserForm.password } },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
setNewUserForm(null);
toast({ title: t("common.success") });
},
},
);
};
if (!user || !isAdmin) return null;
return (
<div className="min-h-screen os-bg flex flex-col">
{/* Header */}
<div className="glass-panel border-b border-slate-200/70 px-4 py-4 flex items-center gap-3 sticky top-0 z-10">
<button
onClick={() => setLocation("/")}
className="p-2 rounded-xl hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
>
<BackIcon size={20} />
</button>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Settings size={20} className="text-red-400 shrink-0" />
<h1 className="text-lg font-semibold text-foreground truncate">{t("admin.title")}</h1>
</div>
<Sheet open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
<SheetTrigger asChild>
<button
className="md:hidden p-2 rounded-xl hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("admin.nav.menu")}
>
<MenuIcon size={20} />
</button>
</SheetTrigger>
<SheetContent side={isRtl ? "right" : "left"} className="w-full sm:w-72 p-4">
<div className="flex items-center gap-2 mb-4 mt-2">
<Settings size={18} className="text-red-400" />
<h2 className="font-semibold text-foreground">{t("admin.title")}</h2>
</div>
{renderNavList(() => setMobileNavOpen(false))}
</SheetContent>
</Sheet>
</div>
{/* Edit App Modal */}
{editingApp && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-4 max-h-[90vh] overflow-y-auto">
<h2 className="font-semibold text-foreground">
{editingApp.id ? t("admin.editApp") : t("admin.addApp")}
</h2>
{(
[
{ field: "nameAr", label: t("admin.appNameAr") },
{ field: "nameEn", label: t("admin.appNameEn") },
{ field: "slug", label: t("admin.appSlug") },
{ field: "iconName", label: t("admin.appIcon") },
{ field: "route", label: t("admin.appRoute") },
{ field: "color", label: t("admin.appColor") },
] as { field: keyof AppForm; label: string }[]
).map(({ field, label }) => (
<div key={field} className="space-y-1">
<Label>{label}</Label>
<Input
value={String(editingApp.form[field])}
onChange={(e) => setEditingApp({ ...editingApp, form: { ...editingApp.form, [field]: e.target.value } })}
className="bg-white/70 border-slate-200"
/>
</div>
))}
{editingApp.id !== undefined && (
<AppPermissionsEditor appId={editingApp.id} />
)}
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={() => setEditingApp(null)}>{t("common.cancel")}</Button>
<Button className="flex-1" onClick={handleSaveApp}>{t("common.save")}</Button>
</div>
</div>
</div>
)}
{/* Edit Service Modal */}
{editingService && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-4 max-h-[90vh] overflow-y-auto">
<h2 className="font-semibold text-foreground">
{editingService.id ? t("admin.editService") : t("admin.addService")}
</h2>
{(
[
{ field: "nameAr", label: t("admin.serviceNameAr") },
{ field: "nameEn", label: t("admin.serviceNameEn") },
{ field: "price", label: t("admin.servicePrice") },
] as { field: keyof ServiceForm; label: string }[]
).map(({ field, label }) => (
<div key={field} className="space-y-1">
<Label>{label}</Label>
<Input
value={String(editingService.form[field])}
onChange={(e) => setEditingService({ ...editingService, form: { ...editingService.form, [field]: e.target.value } })}
className="bg-white/70 border-slate-200"
/>
</div>
))}
<div className="space-y-2">
<Label>{t("admin.serviceImage")}</Label>
<ServiceImageUploader
value={editingService.form.imageUrl}
onChange={(url) =>
setEditingService({
...editingService,
form: { ...editingService.form, imageUrl: url },
})
}
/>
</div>
<div className="flex items-center justify-between">
<Label>{t("admin.serviceAvailable")}</Label>
<Switch
checked={editingService.form.isAvailable}
onCheckedChange={(v) => setEditingService({ ...editingService, form: { ...editingService.form, isAvailable: v } })}
/>
</div>
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={() => setEditingService(null)}>{t("common.cancel")}</Button>
<Button className="flex-1" onClick={handleSaveService}>{t("common.save")}</Button>
</div>
</div>
</div>
)}
{/* Create User Modal */}
{newUserForm && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-4">
<h2 className="font-semibold text-foreground">{t("admin.addUser")}</h2>
<div className="space-y-1">
<Label>{t("admin.username")}</Label>
<Input
value={newUserForm.username}
onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })}
className="bg-white/70 border-slate-200"
/>
</div>
<div className="space-y-1">
<Label>{t("admin.email")}</Label>
<Input
type="email"
value={newUserForm.email}
onChange={(e) => setNewUserForm({ ...newUserForm, email: e.target.value })}
className="bg-white/70 border-slate-200"
/>
</div>
<div className="space-y-1">
<Label>{t("admin.password")}</Label>
<Input
type="password"
value={newUserForm.password}
onChange={(e) => setNewUserForm({ ...newUserForm, password: e.target.value })}
className="bg-white/70 border-slate-200"
/>
</div>
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={() => setNewUserForm(null)}>{t("common.cancel")}</Button>
<Button className="flex-1" onClick={handleCreateUser}>{t("common.save")}</Button>
</div>
</div>
</div>
)}
<div className="flex-1 flex w-full">
{/* Sidebar (desktop) */}
<aside className="hidden md:flex flex-col w-64 shrink-0 border-e border-slate-200/70 bg-white/70 backdrop-blur-md">
<div className="sticky top-[73px] p-4 space-y-4">
<div className="flex items-center gap-2 px-2 pb-3 border-b border-slate-200/70">
<Shield size={18} className="text-primary" />
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{t("admin.nav.menu")}
</span>
</div>
{renderNavList()}
</div>
</aside>
{/* Main content */}
<div className="flex-1 min-w-0 p-4 pb-8">
{section === "dashboard" && (
<DashboardSection
apps={apps}
services={services}
users={users}
appSettings={appSettings}
adminStats={adminStats}
adminStatsErrorMessage={adminStatsErrorMessage}
lang={lang}
statsRange={statsRange}
onStatsRangeChange={setStatsRange}
customFrom={customFrom}
customTo={customTo}
onCustomFromChange={setCustomFrom}
onCustomToChange={setCustomTo}
onApplyCustom={() => setAppliedCustom({ from: customFrom, to: customTo })}
statsQueryParams={statsQueryParams}
onSelectApp={handleSelectAppFromDashboard}
onSelectUser={handleSelectUserFromDashboard}
/>
)}
{section === "apps" && (
<div className="space-y-3">
<div className="flex justify-end">
<Button size="sm" onClick={() => setEditingApp({ form: emptyAppForm })}>
<Plus size={14} className="me-1" />
{t("admin.addApp")}
</Button>
</div>
{apps?.map((app) => (
<div key={app.id} className={cn("rounded-2xl p-4 flex items-center justify-between gap-3 border", app.isActive ? "glass-panel border-transparent" : "bg-slate-100/80 border-slate-200")}>
<div className="flex items-center gap-3 min-w-0">
<div
className={cn("w-8 h-8 rounded-lg shrink-0", !app.isActive && "grayscale")}
style={{ backgroundColor: `${app.color}40` }}
/>
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className={cn("font-medium text-sm truncate", app.isActive ? "text-foreground" : "text-slate-400")}>
{lang === "ar" ? app.nameAr : app.nameEn}
</div>
{!app.isActive && (
<span className="text-[10px] font-medium bg-slate-300 text-slate-600 rounded px-1.5 py-0.5 shrink-0">
{t("admin.appDisabled")}
</span>
)}
</div>
<div className="text-xs text-muted-foreground">{app.route}</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch
checked={app.isActive}
onCheckedChange={(v) => {
updateApp.mutate(
{ id: app.id, data: { isActive: v } },
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }) },
);
}}
/>
<button
onClick={() => setEditingApp({
id: app.id,
form: {
nameAr: app.nameAr ?? "",
nameEn: app.nameEn ?? "",
slug: app.slug ?? "",
iconName: app.iconName ?? "Grid2X2",
route: app.route ?? "/",
color: app.color ?? "#6366f1",
sortOrder: app.sortOrder ?? 0,
},
})}
className="p-1.5 hover:bg-slate-100 rounded-lg text-muted-foreground hover:text-foreground"
>
<Pencil size={14} />
</button>
<button
onClick={() => {
// Pre-populate conflict from list-row counts so the
// warning dialog shows on the FIRST click. Falls back
// to the lazy 409 round-trip if counts are missing.
const groupCount = app.groupCount ?? 0;
const restrictionCount = app.restrictionCount ?? 0;
const openCount = app.openCount ?? 0;
const hasDeps =
groupCount > 0 || restrictionCount > 0 || openCount > 0;
setAppDeleteConflict(
hasDeps
? {
error: "App has dependent records",
groupCount,
restrictionCount,
openCount,
}
: null,
);
setAppDeleteTarget(app);
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
data-testid={`app-delete-${app.id}`}
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
)}
{section === "services" && (
<div className="space-y-3">
<div className="flex justify-end">
<Button size="sm" onClick={() => setEditingService({ form: emptyServiceForm })}>
<Plus size={14} className="me-1" />
{t("admin.addService")}
</Button>
</div>
{services?.map((service) => (
<div key={service.id} className="glass-panel rounded-2xl p-4 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="font-medium text-sm text-foreground truncate">
{lang === "ar" ? service.nameAr : service.nameEn}
</div>
<div className="text-xs text-muted-foreground">{service.price}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch
checked={service.isAvailable}
onCheckedChange={(v) => {
updateService.mutate(
{ id: service.id, data: { isAvailable: v } },
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: getListServicesQueryKey() }) },
);
}}
/>
<button
onClick={() => setEditingService({
id: service.id,
form: {
nameAr: service.nameAr ?? "",
nameEn: service.nameEn ?? "",
descriptionAr: service.descriptionAr ?? "",
descriptionEn: service.descriptionEn ?? "",
price: service.price ?? "0.00",
imageUrl: service.imageUrl ?? "",
isAvailable: service.isAvailable ?? true,
},
})}
className="p-1.5 hover:bg-slate-100 rounded-lg text-muted-foreground hover:text-foreground"
>
<Pencil size={14} />
</button>
<button
onClick={() => {
// Pre-populate conflict from list-row orderCount so
// the warning dialog shows on the FIRST click. Falls
// back to the lazy 409 round-trip if count is missing.
const orderCount = service.orderCount ?? 0;
setServiceDeleteConflict(
orderCount > 0
? {
error: "Service has dependent records",
orderCount,
}
: null,
);
setServiceDeleteTarget(service);
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
data-testid={`service-delete-${service.id}`}
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
)}
{section === "users" && (
<UsersPanel
currentUserId={user.id}
highlightedUserId={highlightedUserId}
userRowRefs={userRowRefs}
onIssueResetLink={(uid, username) =>
issueResetLink.mutate(
{ id: uid },
{
onSuccess: (resp) =>
setResetLinkUser({
username,
url: resp.resetUrl,
expiresAt:
typeof resp.expiresAt === "string"
? resp.expiresAt
: new Date(resp.expiresAt).toISOString(),
}),
onError: () =>
toast({ title: t("common.error"), variant: "destructive" }),
},
)
}
/>
)}
{section === "groups" && <GroupsPanel />}
{section === "roles" && <RolesPanel />}
{section === "audit-log" && <AuditLogPanel />}
{section === "settings" && (
<div className="space-y-3">
<SiteSettingsPanel />
</div>
)}
</div>
</div>
{appDeleteTarget && (() => {
const target = appDeleteTarget;
const items: string[] = [];
if (appDeleteConflict) {
if (appDeleteConflict.groupCount > 0)
items.push(t("admin.deleteApp.groupCount", { count: appDeleteConflict.groupCount }));
if (appDeleteConflict.restrictionCount > 0)
items.push(t("admin.deleteApp.restrictionCount", { count: appDeleteConflict.restrictionCount }));
if (appDeleteConflict.openCount > 0)
items.push(t("admin.deleteApp.openCount", { count: appDeleteConflict.openCount }));
}
return (
<DeletionWarningDialog
title={t("admin.deleteApp.title", { name: lang === "ar" ? target.nameAr : target.nameEn })}
warning={t("admin.deleteApp.warning")}
forceHint={t("admin.deleteApp.forceHint")}
emptyBody={t("admin.deleteApp.emptyBody")}
confirmLabel={t("admin.deleteApp.confirm")}
anywayLabel={t("admin.deleteApp.anyway")}
items={items}
showWarning={appDeleteConflict !== null}
isPending={deleteApp.isPending}
onCancel={closeAppDeleteDialog}
onConfirm={() => performAppDelete(target, appDeleteConflict !== null)}
testId="app-delete-dialog"
confirmTestId="app-delete-confirm"
/>
);
})()}
{serviceDeleteTarget && (() => {
const target = serviceDeleteTarget;
const items: string[] = [];
if (serviceDeleteConflict && serviceDeleteConflict.orderCount > 0) {
items.push(t("admin.deleteService.orderCount", { count: serviceDeleteConflict.orderCount }));
}
return (
<DeletionWarningDialog
title={t("admin.deleteService.title", { name: lang === "ar" ? target.nameAr : target.nameEn })}
warning={t("admin.deleteService.warning")}
forceHint={t("admin.deleteService.forceHint")}
emptyBody={t("admin.deleteService.emptyBody")}
confirmLabel={t("admin.deleteService.confirm")}
anywayLabel={t("admin.deleteService.anyway")}
items={items}
showWarning={serviceDeleteConflict !== null}
isPending={deleteService.isPending}
onCancel={closeServiceDeleteDialog}
onConfirm={() => performServiceDelete(target, serviceDeleteConflict !== null)}
testId="service-delete-dialog"
confirmTestId="service-delete-confirm"
/>
);
})()}
{resetLinkUser && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => setResetLinkUser(null)}
>
<div
className="glass-panel rounded-2xl p-6 max-w-md w-full space-y-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-bold">
{t("admin.resetLinkModal.title", { username: resetLinkUser.username })}
</h3>
<p className="text-sm text-muted-foreground">
{t("admin.resetLinkModal.description")}
</p>
<div className="rounded-md border border-border bg-background/50 p-3">
<code className="text-xs break-all block font-mono">
{resetLinkUser.url}
</code>
</div>
<p className="text-xs text-muted-foreground">
{t("admin.resetLinkModal.expires", {
time: new Date(resetLinkUser.expiresAt).toLocaleString(),
})}
</p>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
navigator.clipboard
.writeText(resetLinkUser.url)
.then(() =>
toast({ title: t("admin.resetLinkModal.copied") }),
)
.catch(() => {});
}}
className="flex-1"
>
<Copy size={14} />
<span className="ml-2">{t("admin.resetLinkModal.copy")}</span>
</Button>
<Button
onClick={() => setResetLinkUser(null)}
className="flex-1"
>
{t("common.close")}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
function SiteSettingsPanel() {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const { data } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } });
const update = useUpdateAppSettings();
const [form, setForm] = useState({
siteNameAr: "",
siteNameEn: "",
registrationOpen: true,
footerTextAr: "",
footerTextEn: "",
});
useEffect(() => {
if (data) setForm({
siteNameAr: data.siteNameAr,
siteNameEn: data.siteNameEn,
registrationOpen: data.registrationOpen,
footerTextAr: data.footerTextAr,
footerTextEn: data.footerTextEn,
});
}, [data]);
const handleSave = () => {
update.mutate(
{ data: form },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getGetAppSettingsQueryKey() });
toast({ title: t("common.success") });
},
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
);
};
return (
<div className="glass-panel rounded-2xl p-4 space-y-4">
<div className="space-y-1">
<Label>{t("admin.siteNameAr")}</Label>
<Input
value={form.siteNameAr}
onChange={(e) => setForm({ ...form, siteNameAr: e.target.value })}
className="bg-white/70 border-slate-200"
dir="rtl"
/>
</div>
<div className="space-y-1">
<Label>{t("admin.siteNameEn")}</Label>
<Input
value={form.siteNameEn}
onChange={(e) => setForm({ ...form, siteNameEn: e.target.value })}
className="bg-white/70 border-slate-200"
dir="ltr"
/>
</div>
<div className="space-y-1">
<Label>{t("admin.footerTextAr")}</Label>
<Input
value={form.footerTextAr}
onChange={(e) => setForm({ ...form, footerTextAr: e.target.value })}
className="bg-white/70 border-slate-200"
dir="rtl"
/>
</div>
<div className="space-y-1">
<Label>{t("admin.footerTextEn")}</Label>
<Input
value={form.footerTextEn}
onChange={(e) => setForm({ ...form, footerTextEn: e.target.value })}
className="bg-white/70 border-slate-200"
dir="ltr"
/>
</div>
<div className="flex items-center justify-between pt-2">
<div>
<Label className="font-medium">{t("admin.registrationOpen")}</Label>
<p className="text-xs text-muted-foreground">{t("admin.registrationOpenHint")}</p>
</div>
<Switch
checked={form.registrationOpen}
onCheckedChange={(v) => setForm({ ...form, registrationOpen: v })}
/>
</div>
<Button onClick={handleSave} disabled={update.isPending} className="w-full">
{update.isPending ? t("common.loading") : t("common.save")}
</Button>
</div>
);
}
function ServiceImageUploader({
value,
onChange,
}: {
value: string;
onChange: (url: string) => void;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const { uploadFile, isUploading, progress } = useUpload({
onSuccess: (resp: UploadResponse) => {
onChange(resp.objectPath);
},
onError: (err: Error) => {
toast({ title: t("admin.uploadFailed"), description: err.message, variant: "destructive" });
},
});
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<label className="cursor-pointer">
<input
type="file"
accept="image/*"
disabled={isUploading}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
void uploadFile(file);
}
e.currentTarget.value = "";
}}
/>
<span className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-700 hover:bg-slate-50 transition">
{isUploading ? <Loader2 size={16} className="animate-spin" /> : <Upload size={16} />}
{isUploading
? `${t("admin.uploading")} ${progress}%`
: value
? t("admin.replaceImage")
: t("admin.uploadImage")}
</span>
</label>
{value && !isUploading && (
<button
type="button"
onClick={() => onChange("")}
className="px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-500 hover:text-destructive hover:border-destructive/40"
>
<Trash2 size={14} />
</button>
)}
</div>
{value && (
<div className="rounded-xl overflow-hidden border border-slate-200 bg-slate-50">
<img
src={resolveServiceImageUrl(value) ?? ""}
alt=""
className="w-full h-32 object-cover"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
/>
</div>
)}
</div>
);
}
function DashboardSection({
apps,
services,
users,
appSettings,
adminStats,
adminStatsErrorMessage,
lang,
statsRange,
onStatsRangeChange,
customFrom,
customTo,
onCustomFromChange,
onCustomToChange,
onApplyCustom,
statsQueryParams,
onSelectApp,
onSelectUser,
}: {
apps: App[] | undefined;
services: Service[] | undefined;
users: UserProfile[] | undefined;
appSettings: AppSettings | undefined;
adminStats: AdminStats | undefined;
adminStatsErrorMessage: string | null;
lang: string;
statsRange: "7d" | "30d" | "90d" | "custom";
onStatsRangeChange: (range: "7d" | "30d" | "90d" | "custom") => void;
customFrom: string;
customTo: string;
onCustomFromChange: (v: string) => void;
onCustomToChange: (v: string) => void;
onApplyCustom: () => void;
statsQueryParams: GetAdminStatsParams;
onSelectApp: (appId: number) => void;
onSelectUser: (userId: number) => void;
}) {
const { t } = useTranslation();
const [drillIn, setDrillIn] = useState<
| { kind: "app"; appId: number }
| { kind: "user"; userId: number }
| null
>(null);
const totalApps = apps?.length ?? 0;
const totalServices = services?.length ?? 0;
const totalUsers = users?.length ?? 0;
const registrationOpen = appSettings?.registrationOpen ?? false;
const latestUser = users
? [...users].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""))[0]
: undefined;
const latestApp = apps
? [...apps].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""))[0]
: undefined;
const formatDate = (iso?: string) => {
if (!iso) return "";
try {
return formatDateUtil(iso, lang, { year: "numeric", month: "short", day: "numeric" });
} catch {
return "";
}
};
const stats: { label: string; value: string; Icon: typeof Grid2X2; tint: string }[] = [
{ label: t("admin.dashboard.totalApps"), value: String(totalApps), Icon: Grid2X2, tint: "text-indigo-500 bg-indigo-100/70" },
{ label: t("admin.dashboard.totalServices"), value: String(totalServices), Icon: Coffee, tint: "text-amber-600 bg-amber-100/70" },
{ label: t("admin.dashboard.totalUsers"), value: String(totalUsers), Icon: UsersIcon, tint: "text-emerald-600 bg-emerald-100/70" },
{
label: t("admin.dashboard.registration"),
value: registrationOpen ? t("admin.dashboard.open") : t("admin.dashboard.closed"),
Icon: Shield,
tint: registrationOpen ? "text-green-600 bg-green-100/70" : "text-rose-600 bg-rose-100/70",
},
];
const newUsers = adminStats?.newUsersInRange ?? 0;
const prevUsers = adminStats?.newUsersPrevRange ?? 0;
const userDelta = newUsers - prevUsers;
const userDeltaPct = prevUsers > 0 ? Math.round((userDelta / prevUsers) * 100) : null;
const activeServices = adminStats?.activeServices ?? 0;
const inactiveServices = adminStats?.inactiveServices ?? 0;
const signups = adminStats?.signupsByDay ?? [];
const maxSignup = Math.max(1, ...signups.map((s) => s.count));
const opens = adminStats?.appOpensByDay ?? [];
const maxOpens = Math.max(1, ...opens.map((s) => s.count));
const opensTotal = adminStats?.appOpensInRange ?? 0;
const opensPrev = adminStats?.appOpensPrevRange ?? 0;
const opensDelta = opensTotal - opensPrev;
const opensDeltaPct = opensPrev > 0 ? Math.round((opensDelta / opensPrev) * 100) : null;
const servicesCreated = adminStats?.servicesCreatedByDay ?? [];
const maxServicesCreated = Math.max(1, ...servicesCreated.map((s) => s.count));
const servicesCreatedTotal = adminStats?.servicesCreatedInRange ?? 0;
const topApps = adminStats?.topApps ?? [];
const maxTopAppCount = Math.max(1, ...topApps.map((a) => a.count));
const mostActiveUsers = adminStats?.mostActiveUsers ?? [];
const maxActiveUserCount = Math.max(1, ...mostActiveUsers.map((u) => u.count));
const rangeDays = adminStats?.rangeDays ?? (statsRange === "90d" ? 90 : statsRange === "30d" ? 30 : statsRange === "custom" ? 7 : 7);
const formatRangeDate = (iso: string) => {
try {
return formatDateUtil(iso + "T00:00:00Z", lang, { month: "short", day: "numeric" });
} catch {
return iso;
}
};
const rangeLabel =
statsRange === "custom" && adminStats?.rangeFrom && adminStats?.rangeTo
? t("admin.dashboard.range.customLabel", {
from: formatRangeDate(adminStats.rangeFrom),
to: formatRangeDate(adminStats.rangeTo),
})
: t(`admin.dashboard.range.${statsRange}`);
const prevRangeLabel =
statsRange === "custom"
? t("admin.dashboard.prevRange.custom", { count: rangeDays })
: t(`admin.dashboard.prevRange.${statsRange}`);
const dayLabel = (iso: string) => {
try {
return formatWeekdayUtil(new Date(iso + "T00:00:00Z"), lang, "short");
} catch {
return "";
}
};
const showCounts = rangeDays <= 14;
const labelStride = rangeDays <= 7 ? 1 : rangeDays <= 30 ? 5 : 15;
const gapClass = rangeDays <= 7 ? "gap-1.5" : rangeDays <= 30 ? "gap-1" : "gap-px";
const monthDayLabel = (iso: string) => {
try {
return formatDateUtil(iso + "T00:00:00Z", lang, { month: "short", day: "numeric" });
} catch {
return iso.slice(5);
}
};
const renderChart = (
title: string,
series: { date: string; count: number }[],
max: number,
accent: string,
subtitle?: string,
) => (
<div className="glass-panel rounded-2xl p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold text-foreground truncate">{title}</div>
<div className="text-xs text-muted-foreground shrink-0">{subtitle ?? rangeLabel}</div>
</div>
<div className={cn("flex items-end justify-between h-28 px-1", gapClass)} dir="ltr">
{series.map((s, idx) => {
const heightPct = (s.count / max) * 100;
const showLabel =
rangeDays <= 7
? true
: idx === series.length - 1 || (series.length - 1 - idx) % labelStride === 0;
return (
<div key={s.date} className="flex-1 flex flex-col items-center gap-1.5 min-w-0">
{showCounts && (
<div className="text-[10px] font-medium text-foreground leading-none">{s.count}</div>
)}
<div className="w-full flex-1 flex items-end">
<div
className={cn("w-full rounded-t-md transition-all bg-gradient-to-t", accent)}
style={{ height: `${Math.max(heightPct, s.count > 0 ? 6 : 2)}%` }}
/>
</div>
<div className="text-[10px] text-muted-foreground truncate w-full text-center">
{showLabel ? (rangeDays <= 14 ? dayLabel(s.date) : monthDayLabel(s.date)) : ""}
</div>
</div>
);
})}
</div>
</div>
);
const rangeOptions: { value: "7d" | "30d" | "90d" | "custom"; label: string }[] = [
{ value: "7d", label: t("admin.dashboard.range.7d") },
{ value: "30d", label: t("admin.dashboard.range.30d") },
{ value: "90d", label: t("admin.dashboard.range.90d") },
{ value: "custom", label: t("admin.dashboard.range.custom") },
];
const customInputsValid =
/^\d{4}-\d{2}-\d{2}$/.test(customFrom) &&
/^\d{4}-\d{2}-\d{2}$/.test(customTo) &&
customFrom <= customTo;
return (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{stats.map(({ label, value, Icon, tint }) => (
<div key={label} className="glass-panel rounded-2xl p-4 flex items-center gap-3">
<div className={cn("w-10 h-10 rounded-xl flex items-center justify-center shrink-0", tint)}>
<Icon size={20} />
</div>
<div className="min-w-0">
<div className="text-xs text-muted-foreground truncate">{label}</div>
<div className="text-xl font-semibold text-foreground truncate">{value}</div>
</div>
</div>
))}
</div>
<div className="glass-panel rounded-2xl p-3 space-y-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="text-sm font-semibold text-foreground">
{t("admin.dashboard.trends")}
</div>
<div className="inline-flex rounded-xl bg-slate-100/80 p-1" role="group" aria-label={t("admin.dashboard.rangeSelector")}>
{rangeOptions.map((opt) => {
const active = statsRange === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => onStatsRangeChange(opt.value)}
aria-pressed={active}
className={cn(
"px-3 py-1.5 text-xs rounded-lg transition-all font-medium",
active
? "bg-white text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
);
})}
</div>
</div>
{statsRange === "custom" && (
<div className="flex items-end gap-2 flex-wrap" dir="ltr">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t("admin.dashboard.customRange.from")}
</Label>
<Input
type="date"
value={customFrom}
max={customTo || undefined}
onChange={(e) => onCustomFromChange(e.target.value)}
className="bg-white/70 border-slate-200 h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t("admin.dashboard.customRange.to")}
</Label>
<Input
type="date"
value={customTo}
min={customFrom || undefined}
onChange={(e) => onCustomToChange(e.target.value)}
className="bg-white/70 border-slate-200 h-9 text-sm"
/>
</div>
<Button
size="sm"
onClick={onApplyCustom}
disabled={!customInputsValid}
className="h-9"
>
{t("admin.dashboard.customRange.apply")}
</Button>
{!customInputsValid && (
<div className="text-xs text-destructive">
{t("admin.dashboard.customRange.invalid")}
</div>
)}
</div>
)}
{adminStatsErrorMessage && (
<div
role="alert"
className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-lg px-3 py-2"
>
{t("admin.dashboard.customRange.loadError")}
<div className="mt-0.5 font-mono text-[11px] opacity-80 break-words">
{adminStatsErrorMessage}
</div>
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="glass-panel rounded-2xl p-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 text-sky-600 bg-sky-100/70">
<UserPlus size={20} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs text-muted-foreground truncate">
{t("admin.dashboard.newUsersInRange", { range: rangeLabel })}
</div>
<div className="flex items-baseline gap-2">
<div className="text-xl font-semibold text-foreground">{newUsers}</div>
{userDelta !== 0 && (
<div
className={cn(
"text-xs flex items-center gap-0.5 font-medium",
userDelta > 0 ? "text-emerald-600" : "text-rose-600",
)}
>
{userDelta > 0 ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
{userDeltaPct !== null
? `${userDelta > 0 ? "+" : ""}${userDeltaPct}%`
: `${userDelta > 0 ? "+" : ""}${userDelta}`}
</div>
)}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
{t("admin.dashboard.vsPrevRange", { range: prevRangeLabel, count: prevUsers })}
</div>
</div>
</div>
<div className="glass-panel rounded-2xl p-4 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 text-amber-600 bg-amber-100/70">
<Activity size={20} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs text-muted-foreground truncate">{t("admin.dashboard.activeServices")}</div>
<div className="flex items-baseline gap-2">
<div className="text-xl font-semibold text-foreground">{activeServices}</div>
<div className="text-xs text-muted-foreground">
/ {activeServices + inactiveServices}
</div>
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
{t("admin.dashboard.inactiveServices", { count: inactiveServices })}
</div>
</div>
</div>
</div>
{renderChart(
t("admin.dashboard.signupsTrend"),
signups,
maxSignup,
"from-primary/40 to-primary",
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{renderChart(
t("admin.dashboard.appOpensTrend"),
opens,
maxOpens,
"from-sky-300/40 to-sky-500",
t("admin.dashboard.appOpensSummaryRanged", {
count: opensTotal,
range: rangeLabel,
delta:
opensDelta === 0
? "±0"
: opensDeltaPct !== null
? `${opensDelta > 0 ? "+" : ""}${opensDeltaPct}%`
: `${opensDelta > 0 ? "+" : ""}${opensDelta}`,
}),
)}
{renderChart(
t("admin.dashboard.servicesCreatedTrend"),
servicesCreated,
maxServicesCreated,
"from-amber-300/40 to-amber-500",
t("admin.dashboard.servicesCreatedSummaryRanged", { count: servicesCreatedTotal, range: rangeLabel }),
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div className="glass-panel rounded-2xl p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold text-foreground truncate">
{t("admin.dashboard.topApps")}
</div>
<div className="text-xs text-muted-foreground shrink-0">
{t("admin.dashboard.topAppsSubtitle", { range: rangeLabel })}
</div>
</div>
{topApps.length === 0 ? (
<div className="text-xs text-muted-foreground py-4 text-center">
{t("admin.dashboard.leaderboardEmpty")}
</div>
) : (
<ol className="space-y-2">
{topApps.map((app, idx) => {
const widthPct = (app.count / maxTopAppCount) * 100;
const appName = lang === "ar" ? app.nameAr : app.nameEn;
return (
<li key={app.appId}>
<button
type="button"
onClick={() => setDrillIn({ kind: "app", appId: app.appId })}
aria-label={t("admin.dashboard.viewAppDetails", { name: appName })}
className="w-full text-start flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 hover:bg-white hover:border-slate-300 hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary transition-all cursor-pointer"
>
<div className="text-xs font-semibold text-muted-foreground w-5 text-center shrink-0">
{idx + 1}
</div>
<div
className="w-8 h-8 rounded-lg shrink-0"
style={{ backgroundColor: `${app.color}40` }}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-foreground truncate">
{appName}
</div>
<div className="text-xs font-semibold text-foreground shrink-0 tabular-nums">
{app.count}
</div>
</div>
<div className="mt-1 h-1.5 rounded-full bg-slate-100 overflow-hidden" dir="ltr">
<div
className="h-full rounded-full bg-gradient-to-r from-sky-300 to-sky-500"
style={{ width: `${widthPct}%` }}
/>
</div>
</div>
</button>
</li>
);
})}
</ol>
)}
</div>
<div className="glass-panel rounded-2xl p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold text-foreground truncate">
{t("admin.dashboard.mostActiveUsers")}
</div>
<div className="text-xs text-muted-foreground shrink-0">
{t("admin.dashboard.mostActiveUsersSubtitle", { range: rangeLabel })}
</div>
</div>
{mostActiveUsers.length === 0 ? (
<div className="text-xs text-muted-foreground py-4 text-center">
{t("admin.dashboard.leaderboardEmpty")}
</div>
) : (
<ol className="space-y-2">
{mostActiveUsers.map((u, idx) => {
const widthPct = (u.count / maxActiveUserCount) * 100;
const displayName =
(lang === "ar" ? u.displayNameAr : u.displayNameEn) ?? u.username;
const initial = (displayName || u.username || "?").trim().charAt(0).toUpperCase();
const avatarSrc = resolveServiceImageUrl(u.avatarUrl);
return (
<li key={u.userId}>
<button
type="button"
onClick={() => setDrillIn({ kind: "user", userId: u.userId })}
aria-label={t("admin.dashboard.viewUserDetails", { name: displayName })}
className="w-full text-start flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 hover:bg-white hover:border-slate-300 hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary transition-all cursor-pointer"
>
<div className="text-xs font-semibold text-muted-foreground w-5 text-center shrink-0">
{idx + 1}
</div>
<LeaderboardAvatar src={avatarSrc} initial={initial} alt={displayName} />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground truncate">
{displayName}
</div>
{displayName !== u.username && (
<div className="text-[11px] text-muted-foreground truncate">
@{u.username}
</div>
)}
</div>
<div className="text-xs font-semibold text-foreground shrink-0 tabular-nums">
{u.count}
</div>
</div>
<div className="mt-1 h-1.5 rounded-full bg-slate-100 overflow-hidden" dir="ltr">
<div
className="h-full rounded-full bg-gradient-to-r from-emerald-300 to-emerald-500"
style={{ width: `${widthPct}%` }}
/>
</div>
</div>
</button>
</li>
);
})}
</ol>
)}
</div>
</div>
<div className="glass-panel rounded-2xl p-4 space-y-3">
<div className="text-sm font-semibold text-foreground">
{t("admin.dashboard.recentActivity")}
</div>
<div className="space-y-2">
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/60 border border-slate-200/60">
<div className="w-9 h-9 rounded-lg flex items-center justify-center bg-emerald-100/70 text-emerald-600 shrink-0">
<UsersIcon size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs text-muted-foreground">{t("admin.dashboard.latestUser")}</div>
<div className="text-sm font-medium text-foreground truncate">
{latestUser?.username ?? t("admin.dashboard.none")}
</div>
</div>
{latestUser?.createdAt && (
<div className="text-xs text-muted-foreground shrink-0">{formatDate(latestUser.createdAt)}</div>
)}
</div>
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/60 border border-slate-200/60">
<div className="w-9 h-9 rounded-lg flex items-center justify-center bg-indigo-100/70 text-indigo-600 shrink-0">
<Grid2X2 size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs text-muted-foreground">{t("admin.dashboard.latestApp")}</div>
<div className="text-sm font-medium text-foreground truncate">
{latestApp ? (lang === "ar" ? latestApp.nameAr : latestApp.nameEn) : t("admin.dashboard.none")}
</div>
</div>
{latestApp?.createdAt && (
<div className="text-xs text-muted-foreground shrink-0">{formatDate(latestApp.createdAt)}</div>
)}
</div>
</div>
</div>
{drillIn?.kind === "app" && (
<AppOpensDrillIn
appId={drillIn.appId}
lang={lang}
rangeLabel={rangeLabel}
statsQueryParams={statsQueryParams}
onClose={() => setDrillIn(null)}
onJump={(id) => {
setDrillIn(null);
onSelectApp(id);
}}
onJumpUser={(id) => {
setDrillIn(null);
onSelectUser(id);
}}
/>
)}
{drillIn?.kind === "user" && (
<UserOpensDrillIn
userId={drillIn.userId}
lang={lang}
rangeLabel={rangeLabel}
statsQueryParams={statsQueryParams}
onClose={() => setDrillIn(null)}
onJump={(id) => {
setDrillIn(null);
onSelectUser(id);
}}
onJumpApp={(id) => {
setDrillIn(null);
onSelectApp(id);
}}
/>
)}
</div>
);
}
function DrillInShell({
title,
subtitle,
onClose,
children,
footer,
}: {
title: string;
subtitle: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}) {
const { t } = useTranslation();
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
return (
<div
className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
onClick={onClose}
>
<div
className="glass-panel rounded-3xl p-5 w-full max-w-md max-h-[85vh] flex flex-col gap-3"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-base font-semibold text-foreground truncate">{title}</div>
<div className="text-xs text-muted-foreground truncate">{subtitle}</div>
</div>
<button
type="button"
onClick={onClose}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded-lg hover:bg-slate-100"
aria-label={t("common.close")}
>
{t("common.close")}
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1">{children}</div>
{footer && <div className="pt-2 border-t border-slate-200/70">{footer}</div>}
</div>
</div>
);
}
function formatOpenTimestamp(iso: string, lang: string): string {
try {
return formatDateUtil(iso, lang, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function AppOpensDrillIn({
appId,
lang,
rangeLabel,
statsQueryParams,
onClose,
onJump,
onJumpUser,
}: {
appId: number;
lang: string;
rangeLabel: string;
statsQueryParams: GetAdminStatsParams;
onClose: () => void;
onJump: (appId: number) => void;
onJumpUser: (userId: number) => void;
}) {
const { t } = useTranslation();
const { data, isLoading, error } = useGetAdminAppOpensByApp(appId, statsQueryParams, {
query: {
queryKey: getGetAdminAppOpensByAppQueryKey(appId, statsQueryParams),
retry: false,
},
});
type ExtraOpen = AdminAppOpensByApp["opens"][number];
const [extraOpens, setExtraOpens] = useState<ExtraOpen[]>([]);
const [extraNextOffset, setExtraNextOffset] = useState<number | null>(null);
const [hasExtras, setHasExtras] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
const paramsKey = JSON.stringify(statsQueryParams);
useEffect(() => {
setExtraOpens([]);
setExtraNextOffset(null);
setHasExtras(false);
setLoadMoreError(null);
setIsLoadingMore(false);
}, [appId, paramsKey]);
const errorMessage = error ? (error as { message?: string }).message ?? null : null;
const appName = data ? (lang === "ar" ? data.nameAr : data.nameEn) : "";
const title = appName
? t("admin.dashboard.drillIn.appTitle", { name: appName })
: t("admin.dashboard.drillIn.loading");
const subtitle = data
? t("admin.dashboard.drillIn.subtitle", { count: data.totalCount, range: rangeLabel })
: rangeLabel;
const opens = data ? [...data.opens, ...extraOpens] : [];
const nextOffset = hasExtras ? extraNextOffset : data?.nextOffset ?? null;
const onLoadMore = async () => {
if (nextOffset === null || isLoadingMore) return;
setIsLoadingMore(true);
setLoadMoreError(null);
try {
const next = await getAdminAppOpensByApp(appId, {
...statsQueryParams,
offset: nextOffset,
});
setExtraOpens((prev) => [...prev, ...next.opens]);
setExtraNextOffset(next.nextOffset);
setHasExtras(true);
} catch (e) {
setLoadMoreError((e as { message?: string }).message ?? "Error");
} finally {
setIsLoadingMore(false);
}
};
return (
<DrillInShell
title={title}
subtitle={subtitle}
onClose={onClose}
footer={
<div className="flex justify-end">
<Button size="sm" variant="outline" onClick={() => onJump(appId)}>
{t("admin.dashboard.drillIn.editApp")}
</Button>
</div>
}
>
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 size={18} className="animate-spin" />
</div>
) : errorMessage ? (
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-lg px-3 py-2">
{t("admin.dashboard.drillIn.loadError")}
<div className="mt-0.5 font-mono text-[11px] opacity-80 break-words">{errorMessage}</div>
</div>
) : !data || opens.length === 0 ? (
<div className="text-xs text-muted-foreground py-8 text-center">
{t("admin.dashboard.drillIn.empty")}
</div>
) : (
<>
<ol className="space-y-1.5">
{opens.map((o) => {
const display =
(lang === "ar" ? o.displayNameAr : o.displayNameEn) ?? o.username;
const initial = (display || o.username || "?").trim().charAt(0).toUpperCase();
const avatar = resolveServiceImageUrl(o.avatarUrl);
return (
<li key={o.id}>
<button
type="button"
onClick={() => onJumpUser(o.userId)}
aria-label={t("admin.dashboard.viewUserDetails", { name: display })}
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 w-full text-start hover:bg-white hover:border-slate-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-colors"
>
<LeaderboardAvatar src={avatar} initial={initial} alt={display} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground truncate">{display}</div>
{display !== o.username && (
<div className="text-[11px] text-muted-foreground truncate">@{o.username}</div>
)}
</div>
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
{formatOpenTimestamp(o.createdAt, lang)}
</div>
</button>
</li>
);
})}
</ol>
<LoadMoreSection
shownCount={opens.length}
totalCount={data.totalCount}
nextOffset={nextOffset}
isLoadingMore={isLoadingMore}
loadMoreError={loadMoreError}
onLoadMore={onLoadMore}
/>
</>
)}
</DrillInShell>
);
}
function LoadMoreSection({
shownCount,
totalCount,
nextOffset,
isLoadingMore,
loadMoreError,
onLoadMore,
}: {
shownCount: number;
totalCount: number;
nextOffset: number | null;
isLoadingMore: boolean;
loadMoreError: string | null;
onLoadMore: () => void;
}) {
const { t } = useTranslation();
return (
<div className="mt-3 flex flex-col items-center gap-1.5">
<div className="text-[11px] text-muted-foreground tabular-nums">
{t("admin.dashboard.drillIn.shownOf", { shown: shownCount, total: totalCount })}
</div>
{nextOffset !== null && (
<Button
size="sm"
variant="outline"
onClick={onLoadMore}
disabled={isLoadingMore}
className="gap-2"
>
{isLoadingMore && <Loader2 size={14} className="animate-spin" />}
{t("admin.dashboard.drillIn.loadMore")}
</Button>
)}
{loadMoreError && (
<div className="text-[11px] text-destructive">
{t("admin.dashboard.drillIn.loadMoreError")}
</div>
)}
</div>
);
}
function UserOpensDrillIn({
userId,
lang,
rangeLabel,
statsQueryParams,
onClose,
onJump,
onJumpApp,
}: {
userId: number;
lang: string;
rangeLabel: string;
statsQueryParams: GetAdminStatsParams;
onClose: () => void;
onJump: (userId: number) => void;
onJumpApp: (appId: number) => void;
}) {
const { t } = useTranslation();
const { data, isLoading, error } = useGetAdminAppOpensByUser(userId, statsQueryParams, {
query: {
queryKey: getGetAdminAppOpensByUserQueryKey(userId, statsQueryParams),
retry: false,
},
});
type ExtraOpen = AdminAppOpensByUser["opens"][number];
const [extraOpens, setExtraOpens] = useState<ExtraOpen[]>([]);
const [extraNextOffset, setExtraNextOffset] = useState<number | null>(null);
const [hasExtras, setHasExtras] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
const paramsKey = JSON.stringify(statsQueryParams);
useEffect(() => {
setExtraOpens([]);
setExtraNextOffset(null);
setHasExtras(false);
setLoadMoreError(null);
setIsLoadingMore(false);
}, [userId, paramsKey]);
const errorMessage = error ? (error as { message?: string }).message ?? null : null;
const displayName = data
? (lang === "ar" ? data.displayNameAr : data.displayNameEn) ?? data.username
: "";
const opens = data ? [...data.opens, ...extraOpens] : [];
const nextOffset = hasExtras ? extraNextOffset : data?.nextOffset ?? null;
const onLoadMore = async () => {
if (nextOffset === null || isLoadingMore) return;
setIsLoadingMore(true);
setLoadMoreError(null);
try {
const next = await getAdminAppOpensByUser(userId, {
...statsQueryParams,
offset: nextOffset,
});
setExtraOpens((prev) => [...prev, ...next.opens]);
setExtraNextOffset(next.nextOffset);
setHasExtras(true);
} catch (e) {
setLoadMoreError((e as { message?: string }).message ?? "Error");
} finally {
setIsLoadingMore(false);
}
};
const title = displayName
? t("admin.dashboard.drillIn.userTitle", { name: displayName })
: t("admin.dashboard.drillIn.loading");
const subtitle = data
? t("admin.dashboard.drillIn.subtitle", { count: data.totalCount, range: rangeLabel })
: rangeLabel;
return (
<DrillInShell
title={title}
subtitle={subtitle}
onClose={onClose}
footer={
<div className="flex justify-end">
<Button size="sm" variant="outline" onClick={() => onJump(userId)}>
{t("admin.dashboard.drillIn.viewUser")}
</Button>
</div>
}
>
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 size={18} className="animate-spin" />
</div>
) : errorMessage ? (
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-lg px-3 py-2">
{t("admin.dashboard.drillIn.loadError")}
<div className="mt-0.5 font-mono text-[11px] opacity-80 break-words">{errorMessage}</div>
</div>
) : !data || opens.length === 0 ? (
<div className="text-xs text-muted-foreground py-8 text-center">
{t("admin.dashboard.drillIn.empty")}
</div>
) : (
<>
<ol className="space-y-1.5">
{opens.map((o) => {
const name = lang === "ar" ? o.nameAr : o.nameEn;
return (
<li key={o.id}>
<button
type="button"
onClick={() => onJumpApp(o.appId)}
aria-label={t("admin.dashboard.viewAppDetails", { name })}
className="flex items-center gap-3 p-2 rounded-xl bg-white/60 border border-slate-200/60 w-full text-start hover:bg-white hover:border-slate-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-colors"
>
<div
className="w-8 h-8 rounded-lg shrink-0"
style={{ backgroundColor: `${o.color}40` }}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground truncate">{name}</div>
</div>
<div className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
{formatOpenTimestamp(o.createdAt, lang)}
</div>
</button>
</li>
);
})}
</ol>
<LoadMoreSection
shownCount={opens.length}
totalCount={data.totalCount}
nextOffset={nextOffset}
isLoadingMore={isLoadingMore}
loadMoreError={loadMoreError}
onLoadMore={onLoadMore}
/>
</>
)}
</DrillInShell>
);
}
// ===== Users Management Panel =====
type UserSortKey = "username" | "email" | "createdAt";
function UsersPanel({
currentUserId,
highlightedUserId,
userRowRefs,
onIssueResetLink,
}: {
currentUserId: number;
highlightedUserId: number | null;
userRowRefs: React.MutableRefObject<Map<number, HTMLDivElement>>;
onIssueResetLink: (userId: number, username: string) => void;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [groupFilter, setGroupFilter] = useState<number | "all">("all");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
const [sortKey, setSortKey] = useState<UserSortKey>("username");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [editingUser, setEditingUser] = useState<UserProfile | null>(null);
const [userDeleteTarget, setUserDeleteTarget] = useState<UserProfile | null>(null);
const [userDeleteConflict, setUserDeleteConflict] = useState<UserDeletionConflict | null>(null);
const [newUserOpen, setNewUserOpen] = useState(false);
const [newUserForm, setNewUserForm] = useState<{
username: string;
email: string;
password: string;
groupIds: number[];
}>({ username: "", email: "", password: "", groupIds: [] });
const listParams = useMemo<ListUsersParams>(() => {
const p: ListUsersParams = {};
if (search.trim()) p.q = search.trim();
if (groupFilter !== "all") p.groupId = groupFilter;
if (statusFilter === "inactive") p.status = ListUsersStatus.disabled;
else if (statusFilter === "active") p.status = ListUsersStatus.active;
return p;
}, [search, groupFilter, statusFilter]);
const { data: users } = useListUsers(listParams, { query: { queryKey: getListUsersQueryKey(listParams) } });
const { data: groups } = useListGroups(undefined, { query: { queryKey: getListGroupsQueryKey() } });
const createUser = useCreateUser();
const updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
const addUserRole = useAddUserRole();
const removeUserRole = useRemoveUserRole();
const filtered = (users ?? []).slice().sort((a, b) => {
const av =
sortKey === "createdAt" ? a.createdAt : sortKey === "email" ? a.email : a.username;
const bv =
sortKey === "createdAt" ? b.createdAt : sortKey === "email" ? b.email : b.username;
const cmp = String(av).localeCompare(String(bv));
return sortDir === "asc" ? cmp : -cmp;
});
const invalidateUsers = () =>
queryClient.invalidateQueries({ queryKey: [`/api/users`] });
const closeUserDeleteDialog = () => {
setUserDeleteTarget(null);
setUserDeleteConflict(null);
};
const performUserDelete = (target: UserProfile, force: boolean) => {
deleteUser.mutate(
{ id: target.id, params: force ? { force: true } : undefined },
{
onSuccess: () => {
invalidateUsers();
toast({ title: t("common.success") });
closeUserDeleteDialog();
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409 && err.data) {
setUserDeleteConflict(err.data as UserDeletionConflict);
return;
}
toast({ title: t("common.error"), variant: "destructive" });
},
},
);
};
return (
<div className="space-y-3">
<div className="glass-panel rounded-2xl p-3 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<Input
placeholder={t("admin.users.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="bg-white/70 border-slate-200 flex-1 min-w-[180px]"
data-testid="user-search"
/>
<select
value={groupFilter === "all" ? "all" : String(groupFilter)}
onChange={(e) => setGroupFilter(e.target.value === "all" ? "all" : Number(e.target.value))}
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm"
data-testid="user-group-filter"
>
<option value="all">{t("admin.users.allGroups")}</option>
{groups?.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as "all" | "active" | "inactive")}
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm"
data-testid="user-status-filter"
>
<option value="all">{t("admin.users.allStatuses")}</option>
<option value="active">{t("admin.users.statusActive")}</option>
<option value="inactive">{t("admin.users.statusInactive")}</option>
</select>
<Button size="sm" onClick={() => { setNewUserForm({ username: "", email: "", password: "", groupIds: [] }); setNewUserOpen(true); }}>
<Plus size={14} className="me-1" />
{t("admin.addUser")}
</Button>
</div>
<div className="text-xs text-muted-foreground">
{t("admin.users.showing", { count: filtered.length, total: users?.length ?? 0 })}
</div>
</div>
{/* Table (desktop) / cards (mobile) */}
<div className="overflow-hidden rounded-2xl glass-panel">
<div className="hidden md:grid grid-cols-[1fr_1fr_1fr_1fr_auto] gap-3 px-4 py-2 text-xs font-medium text-muted-foreground border-b border-slate-200/70">
{(["username", "email", "createdAt"] as UserSortKey[]).map((k) => (
<button
key={k}
onClick={() => {
if (sortKey === k) setSortDir(sortDir === "asc" ? "desc" : "asc");
else { setSortKey(k); setSortDir("asc"); }
}}
className="text-start uppercase tracking-wider hover:text-foreground"
>
{t(`admin.users.col.${k}`)}
{sortKey === k && (sortDir === "asc" ? " ▲" : " ▼")}
</button>
))}
<div className="uppercase tracking-wider">{t("admin.users.col.groups")}</div>
<div className="uppercase tracking-wider text-end">{t("admin.users.col.actions")}</div>
</div>
<div className="divide-y divide-slate-200/70">
{filtered.map((u) => (
<div
key={u.id}
ref={(node) => {
if (node) userRowRefs.current.set(u.id, node);
else userRowRefs.current.delete(u.id);
}}
className={cn(
"grid grid-cols-1 md:grid-cols-[1fr_1fr_1fr_1fr_auto] gap-2 md:gap-3 px-4 py-3 items-center transition-all",
highlightedUserId === u.id && "bg-primary/10 ring-2 ring-primary",
)}
data-testid={`user-row-${u.id}`}
>
<div className="min-w-0">
<div className="font-medium text-sm text-foreground truncate">
{u.username}
{!u.isActive && (
<span className="ms-2 text-[10px] uppercase px-1.5 py-0.5 rounded-full bg-slate-200 text-muted-foreground">
{t("admin.users.statusInactive")}
</span>
)}
</div>
{(u.displayNameAr || u.displayNameEn) && (
<div className="text-[11px] text-muted-foreground truncate" data-testid={`user-displayname-${u.id}`}>
{u.displayNameAr || u.displayNameEn}
</div>
)}
</div>
<div className="min-w-0">
<div className="text-xs text-muted-foreground truncate">{u.email}</div>
{u.preferredLanguage && (
<div className="text-[11px] uppercase tracking-wide text-muted-foreground" data-testid={`user-lang-${u.id}`}>
{u.preferredLanguage}
</div>
)}
</div>
<div className="text-xs text-muted-foreground">
{u.createdAt ? new Date(u.createdAt).toLocaleDateString() : "—"}
</div>
<div className="flex flex-wrap gap-1">
{u.groups?.length ? u.groups.map((g) => (
<span key={g.id} className="text-[10px] px-2 py-0.5 rounded-full bg-primary/15 text-primary">
{g.name}
</span>
)) : <span className="text-xs text-muted-foreground"></span>}
</div>
<div className="flex items-center gap-1 justify-end">
<Switch
checked={u.isActive}
onCheckedChange={(v) =>
updateUser.mutate(
{ id: u.id, data: { isActive: v } },
{ onSuccess: invalidateUsers },
)
}
/>
<button
onClick={() => setEditingUser(u)}
title={t("admin.users.editGroups")}
className="p-1.5 hover:bg-slate-100 rounded-lg text-muted-foreground hover:text-foreground"
data-testid={`user-edit-${u.id}`}
>
<Pencil size={14} />
</button>
<button
onClick={() => onIssueResetLink(u.id, u.username)}
title={t("admin.issueResetLink")}
className="p-1.5 hover:bg-primary/20 rounded-lg text-muted-foreground hover:text-primary"
>
<KeyRound size={14} />
</button>
<label className="flex items-center gap-1 text-[10px] text-muted-foreground select-none">
<span className="hidden lg:inline">{t("admin.orderReceiverRole")}</span>
<Switch
checked={u.roles?.includes("order_receiver") ?? false}
onCheckedChange={(v) => {
const opts = {
onSuccess: invalidateUsers,
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
};
if (v) addUserRole.mutate({ id: u.id, data: { roleName: "order_receiver" } }, opts);
else removeUserRole.mutate({ id: u.id, roleName: "order_receiver" }, opts);
}}
/>
</label>
{u.id !== currentUserId && (
<button
onClick={() => {
// Pre-populate conflict from list-row counts so the
// warning dialog shows on the FIRST click. Falls back
// to the lazy 409 round-trip if counts are missing.
const noteCount = u.noteCount ?? 0;
const orderCount = u.orderCount ?? 0;
const conversationCount = u.conversationCount ?? 0;
const messageCount = u.messageCount ?? 0;
const hasDeps =
noteCount > 0 ||
orderCount > 0 ||
conversationCount > 0 ||
messageCount > 0;
setUserDeleteConflict(
hasDeps
? {
error: "User has dependent records",
noteCount,
orderCount,
conversationCount,
messageCount,
}
: null,
);
setUserDeleteTarget(u);
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
data-testid={`user-delete-${u.id}`}
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
))}
{filtered.length === 0 && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{t("admin.users.empty")}
</div>
)}
</div>
</div>
{newUserOpen && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3">
<h2 className="font-semibold text-foreground">{t("admin.addUser")}</h2>
<div className="space-y-1">
<Label>{t("auth.username")}</Label>
<Input value={newUserForm.username} onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })} className="bg-white/70 border-slate-200" />
</div>
<div className="space-y-1">
<Label>{t("auth.email")}</Label>
<Input type="email" value={newUserForm.email} onChange={(e) => setNewUserForm({ ...newUserForm, email: e.target.value })} className="bg-white/70 border-slate-200" />
</div>
<div className="space-y-1">
<Label>{t("auth.password")}</Label>
<Input type="password" value={newUserForm.password} onChange={(e) => setNewUserForm({ ...newUserForm, password: e.target.value })} className="bg-white/70 border-slate-200" />
</div>
<div className="space-y-1">
<Label>{t("admin.users.col.groups")}</Label>
<div className="max-h-40 overflow-y-auto rounded-xl border border-slate-200 bg-white/70 p-2 space-y-1">
{(groups ?? []).length === 0 && (
<div className="text-xs text-muted-foreground px-1 py-2">{t("admin.groups.empty")}</div>
)}
{(groups ?? []).map((g) => {
const checked = newUserForm.groupIds.includes(g.id);
return (
<label key={g.id} className="flex items-center gap-2 text-sm cursor-pointer px-1 py-1 rounded hover:bg-slate-100/60">
<input
type="checkbox"
checked={checked}
onChange={(e) => {
setNewUserForm((prev) => ({
...prev,
groupIds: e.target.checked
? [...prev.groupIds, g.id]
: prev.groupIds.filter((id) => id !== g.id),
}));
}}
/>
<span>{g.name}</span>
</label>
);
})}
</div>
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={() => setNewUserOpen(false)}>{t("common.cancel")}</Button>
<Button
className="flex-1"
onClick={() => {
createUser.mutate(
{
data: {
username: newUserForm.username,
email: newUserForm.email,
password: newUserForm.password,
...(newUserForm.groupIds.length > 0 ? { groupIds: newUserForm.groupIds } : {}),
},
},
{
onSuccess: () => {
invalidateUsers();
setNewUserOpen(false);
setNewUserForm({ username: "", email: "", password: "", groupIds: [] });
toast({ title: t("common.success") });
},
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
);
}}
>
{t("common.save")}
</Button>
</div>
</div>
</div>
)}
{editingUser && (
<UserGroupsEditor
user={editingUser}
groups={groups ?? []}
onClose={() => setEditingUser(null)}
onSaved={() => { invalidateUsers(); setEditingUser(null); }}
/>
)}
{userDeleteTarget && (() => {
const target = userDeleteTarget;
const items: string[] = [];
if (userDeleteConflict) {
if (userDeleteConflict.noteCount > 0)
items.push(t("admin.deleteUser.noteCount", { count: userDeleteConflict.noteCount }));
if (userDeleteConflict.orderCount > 0)
items.push(t("admin.deleteUser.orderCount", { count: userDeleteConflict.orderCount }));
if (userDeleteConflict.conversationCount > 0)
items.push(t("admin.deleteUser.conversationCount", { count: userDeleteConflict.conversationCount }));
if (userDeleteConflict.messageCount > 0)
items.push(t("admin.deleteUser.messageCount", { count: userDeleteConflict.messageCount }));
}
return (
<DeletionWarningDialog
title={t("admin.deleteUser.title", { name: target.username })}
warning={t("admin.deleteUser.warning")}
forceHint={t("admin.deleteUser.forceHint")}
emptyBody={t("admin.deleteUser.emptyBody")}
confirmLabel={t("admin.deleteUser.confirm")}
anywayLabel={t("admin.deleteUser.anyway")}
items={items}
showWarning={userDeleteConflict !== null}
isPending={deleteUser.isPending}
onCancel={closeUserDeleteDialog}
onConfirm={() => performUserDelete(target, userDeleteConflict !== null)}
testId="user-delete-dialog"
confirmTestId="user-delete-confirm"
/>
);
})()}
</div>
);
}
type UserEditChangeKey =
| "displayNameAr"
| "displayNameEn"
| "preferredLanguage"
| "isActive";
function UserGroupsEditor({
user,
groups,
onClose,
onSaved,
}: {
user: UserProfile;
groups: Group[];
onClose: () => void;
onSaved: () => void;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const updateUser = useUpdateUser();
const original = useMemo(
() => ({
groupIds: new Set<number>(user.groups?.map((g) => g.id) ?? []),
displayNameAr: user.displayNameAr ?? "",
displayNameEn: user.displayNameEn ?? "",
preferredLanguage: user.preferredLanguage || "ar",
isActive: user.isActive,
}),
[user.id],
);
const [selected, setSelected] = useState<Set<number>>(new Set(original.groupIds));
const [displayNameAr, setDisplayNameAr] = useState(original.displayNameAr);
const [displayNameEn, setDisplayNameEn] = useState(original.displayNameEn);
const [preferredLanguage, setPreferredLanguage] = useState(original.preferredLanguage);
const [isActive, setIsActive] = useState(original.isActive);
const [groupSearch, setGroupSearch] = useState("");
const [reviewOpen, setReviewOpen] = useState(false);
useEffect(() => {
if (!reviewOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setReviewOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [reviewOpen]);
const toggle = (id: number) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelected(next);
};
const visibleGroups = groups.filter((g) =>
!groupSearch.trim() || g.name.toLowerCase().includes(groupSearch.trim().toLowerCase()),
);
const groupNameById = useMemo(() => {
const map = new Map<number, string>();
for (const g of groups) map.set(g.id, g.name);
return map;
}, [groups]);
const fieldChanges = useMemo(() => {
const out: { key: UserEditChangeKey; before: unknown; after: unknown }[] = [];
if (displayNameAr.trim() !== original.displayNameAr.trim()) {
out.push({ key: "displayNameAr", before: original.displayNameAr, after: displayNameAr });
}
if (displayNameEn.trim() !== original.displayNameEn.trim()) {
out.push({ key: "displayNameEn", before: original.displayNameEn, after: displayNameEn });
}
if (preferredLanguage !== original.preferredLanguage) {
out.push({ key: "preferredLanguage", before: original.preferredLanguage, after: preferredLanguage });
}
if (isActive !== original.isActive) {
out.push({ key: "isActive", before: original.isActive, after: isActive });
}
return out;
}, [displayNameAr, displayNameEn, preferredLanguage, isActive, original]);
const groupDelta = useMemo(() => {
const added: string[] = [];
const removed: string[] = [];
for (const id of selected) {
if (!original.groupIds.has(id)) added.push(groupNameById.get(id) ?? `#${id}`);
}
for (const id of original.groupIds) {
if (!selected.has(id)) removed.push(groupNameById.get(id) ?? `#${id}`);
}
return { added, removed };
}, [selected, original.groupIds, groupNameById]);
const hasChanges =
fieldChanges.length > 0 || groupDelta.added.length > 0 || groupDelta.removed.length > 0;
const fieldLabel = (key: UserEditChangeKey): string => {
switch (key) {
case "displayNameAr":
return t("admin.users.col.displayNameAr");
case "displayNameEn":
return t("admin.users.col.displayNameEn");
case "preferredLanguage":
return t("admin.users.col.language");
case "isActive":
return t("admin.users.statusActive");
}
};
const renderValue = (key: UserEditChangeKey, value: unknown): string => {
if (key === "isActive") {
return value
? t("admin.users.statusActive")
: t("admin.users.statusInactive");
}
if (key === "preferredLanguage") {
return value === "ar" ? "العربية" : "English";
}
const str = typeof value === "string" ? value.trim() : String(value ?? "");
return str === "" ? t("common.empty") : str;
};
const handleConfirmSave = () => {
updateUser.mutate(
{
id: user.id,
data: {
groupIds: Array.from(selected),
displayNameAr: displayNameAr.trim() || null,
displayNameEn: displayNameEn.trim() || null,
preferredLanguage,
isActive,
},
},
{
onSuccess: () => {
toast({ title: t("common.success") });
setReviewOpen(false);
onSaved();
},
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
);
};
return (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-md space-y-3 max-h-[85vh] overflow-y-auto">
<h2 className="font-semibold text-foreground">{t("admin.users.editUserGroups", { username: user.username })}</h2>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">{t("admin.users.col.displayNameAr")}</Label>
<Input
value={displayNameAr}
onChange={(e) => setDisplayNameAr(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid="edit-user-display-ar"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.users.col.displayNameEn")}</Label>
<Input
value={displayNameEn}
onChange={(e) => setDisplayNameEn(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid="edit-user-display-en"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2 items-end">
<div className="space-y-1">
<Label className="text-xs">{t("admin.users.col.language")}</Label>
<select
value={preferredLanguage}
onChange={(e) => setPreferredLanguage(e.target.value)}
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full"
data-testid="edit-user-language"
>
<option value="ar">العربية</option>
<option value="en">English</option>
</select>
</div>
<label className="flex items-center gap-2 pb-2 text-sm">
<Switch checked={isActive} onCheckedChange={setIsActive} />
<span>{t("admin.users.statusActive")}</span>
</label>
</div>
<div className="pt-1">
<Label className="text-xs">{t("admin.users.editGroups")}</Label>
<Input
placeholder={t("admin.groups.searchPlaceholder")}
value={groupSearch}
onChange={(e) => setGroupSearch(e.target.value)}
className="bg-white/70 border-slate-200 mt-1 mb-2"
data-testid="edit-user-group-search"
/>
<div className="space-y-1 max-h-48 overflow-y-auto">
{visibleGroups.map((g) => (
<label key={g.id} className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-slate-100 cursor-pointer">
<input type="checkbox" checked={selected.has(g.id)} onChange={() => toggle(g.id)} />
<span className="text-sm flex-1">{g.name}</span>
{g.isSystem && <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-200 text-muted-foreground">{t("admin.groups.system")}</span>}
</label>
))}
{visibleGroups.length === 0 && <p className="text-sm text-muted-foreground p-2">{t("admin.groups.empty")}</p>}
</div>
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={onClose}>{t("common.cancel")}</Button>
<Button
className="flex-1"
disabled={!hasChanges}
title={hasChanges ? undefined : t("admin.users.review.noChanges")}
onClick={() => setReviewOpen(true)}
data-testid="edit-user-save"
>
{t("common.save")}
</Button>
</div>
</div>
{reviewOpen && (
<div
className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
data-testid="edit-user-review-dialog"
role="dialog"
aria-modal="true"
>
<div className="glass-panel rounded-3xl p-6 w-full max-w-md space-y-4 max-h-[85vh] overflow-y-auto">
<div className="space-y-1">
<h3 className="font-semibold text-foreground">{t("admin.users.review.title")}</h3>
<p className="text-xs text-muted-foreground">
{t("admin.users.review.subtitle", { username: user.username })}
</p>
</div>
<ul className="space-y-2 text-sm">
{fieldChanges.map((c) => (
<li
key={c.key}
className="flex flex-wrap items-baseline gap-x-2 gap-y-1"
data-testid={`edit-user-review-change-${c.key}`}
>
<span className="text-muted-foreground">{fieldLabel(c.key)}:</span>
<span className="line-through text-muted-foreground/70">
{renderValue(c.key, c.before)}
</span>
<span className="text-muted-foreground"></span>
<span className="font-medium text-foreground">
{renderValue(c.key, c.after)}
</span>
</li>
))}
</ul>
{groupDelta.added.length > 0 && (
<div data-testid="edit-user-review-group-added" className="space-y-1">
<h4 className="text-xs font-medium text-foreground">
{t("admin.users.review.groupsAdded")}
</h4>
<div className="flex flex-wrap gap-1">
{groupDelta.added.map((name) => (
<span
key={`add-${name}`}
className="text-[11px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800"
>
{name}
</span>
))}
</div>
</div>
)}
{groupDelta.removed.length > 0 && (
<div data-testid="edit-user-review-group-removed" className="space-y-1">
<h4 className="text-xs font-medium text-foreground">
{t("admin.users.review.groupsRemoved")}
</h4>
<div className="flex flex-wrap gap-1">
{groupDelta.removed.map((name) => (
<span
key={`rem-${name}`}
className="text-[11px] px-2 py-0.5 rounded-full bg-rose-100 text-rose-800 line-through"
>
{name}
</span>
))}
</div>
</div>
)}
<div className="flex gap-2 pt-1">
<Button
variant="outline"
className="flex-1"
disabled={updateUser.isPending}
onClick={() => setReviewOpen(false)}
data-testid="edit-user-review-back"
>
{t("admin.users.review.back")}
</Button>
<Button
className="flex-1"
disabled={updateUser.isPending}
onClick={handleConfirmSave}
data-testid="edit-user-review-confirm"
>
{updateUser.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
t("admin.users.review.confirm")
)}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
// ===== Groups Management Panel =====
function GroupsPanel() {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: groups } = useListGroups(undefined, { query: { queryKey: getListGroupsQueryKey() } });
const createGroup = useCreateGroup();
const deleteGroup = useDeleteGroup();
const [editingGroupId, setEditingGroupId] = useState<number | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" });
const [groupSearch, setGroupSearch] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Group | null>(null);
const [conflictCounts, setConflictCounts] = useState<{
memberCount: number;
appCount: number;
roleCount: number;
} | null>(null);
const invalidate = () => queryClient.invalidateQueries({ queryKey: getListGroupsQueryKey() });
const closeDeleteDialog = () => {
setDeleteTarget(null);
setConflictCounts(null);
};
const performDelete = (group: Group, force: boolean) => {
deleteGroup.mutate(
{ id: group.id, params: force ? { force: true } : undefined },
{
onSuccess: () => {
invalidate();
toast({ title: t("common.success") });
closeDeleteDialog();
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409 && err.data) {
const data = err.data as GroupDeletionConflict;
setConflictCounts({
memberCount: data.memberCount,
appCount: data.appCount,
roleCount: data.roleCount,
});
return;
}
toast({ title: t("common.error"), variant: "destructive" });
},
},
);
};
const visibleGroups = (groups ?? []).filter((g) =>
!groupSearch.trim() ||
g.name.toLowerCase().includes(groupSearch.trim().toLowerCase()) ||
(g.descriptionAr ?? "").toLowerCase().includes(groupSearch.trim().toLowerCase()) ||
(g.descriptionEn ?? "").toLowerCase().includes(groupSearch.trim().toLowerCase()),
);
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Input
placeholder={t("admin.groups.searchPlaceholder")}
value={groupSearch}
onChange={(e) => setGroupSearch(e.target.value)}
className="bg-white/70 border-slate-200 flex-1"
data-testid="group-search"
/>
<Button size="sm" onClick={() => { setCreateForm({ name: "", descriptionAr: "", descriptionEn: "" }); setCreateOpen(true); }} data-testid="create-group-btn">
<Plus size={14} className="me-1" />
{t("admin.groups.addGroup")}
</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
{visibleGroups.map((g) => (
<div key={g.id} className="glass-panel rounded-2xl p-4 space-y-2" data-testid={`group-card-${g.id}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold text-foreground flex items-center gap-2">
<Shield size={16} className="text-primary" />
<span className="truncate">{g.name}</span>
{g.isSystem && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase tracking-wider">
{t("admin.groups.system")}
</span>
)}
</div>
{(g.descriptionAr || g.descriptionEn) && (
<p className="text-xs text-muted-foreground mt-1 truncate">
{g.descriptionEn || g.descriptionAr}
</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => setEditingGroupId(g.id)}
className="p-1.5 hover:bg-slate-100 rounded-lg text-muted-foreground hover:text-foreground"
data-testid={`edit-group-${g.id}`}
>
<Pencil size={14} />
</button>
{!g.isSystem && (
<button
onClick={() => {
setConflictCounts(null);
setDeleteTarget(g);
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
data-testid={`delete-group-${g.id}`}
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground pt-1">
<span>{t("admin.groups.members", { count: g.memberCount })}</span>
<span></span>
<span>{t("admin.groups.appsCount", { count: g.appCount })}</span>
<span></span>
<span>{t("admin.groups.rolesCount", { count: g.roleCount })}</span>
</div>
</div>
))}
{visibleGroups.length === 0 && (
<div className="glass-panel rounded-2xl p-6 text-center text-sm text-muted-foreground md:col-span-2">
{t("admin.groups.empty")}
</div>
)}
</div>
{createOpen && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3">
<h2 className="font-semibold text-foreground">{t("admin.groups.addGroup")}</h2>
<div className="space-y-1">
<Label>{t("admin.groups.name")}</Label>
<Input value={createForm.name} onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })} className="bg-white/70 border-slate-200" data-testid="group-name-input" />
</div>
<div className="space-y-1">
<Label>{t("admin.groups.descriptionAr")}</Label>
<Input value={createForm.descriptionAr} onChange={(e) => setCreateForm({ ...createForm, descriptionAr: e.target.value })} className="bg-white/70 border-slate-200" dir="rtl" />
</div>
<div className="space-y-1">
<Label>{t("admin.groups.descriptionEn")}</Label>
<Input value={createForm.descriptionEn} onChange={(e) => setCreateForm({ ...createForm, descriptionEn: e.target.value })} className="bg-white/70 border-slate-200" dir="ltr" />
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={() => setCreateOpen(false)}>{t("common.cancel")}</Button>
<Button
className="flex-1"
disabled={!createForm.name.trim()}
onClick={() =>
createGroup.mutate(
{ data: { name: createForm.name.trim(), descriptionAr: createForm.descriptionAr || null, descriptionEn: createForm.descriptionEn || null } },
{
onSuccess: () => { invalidate(); setCreateOpen(false); toast({ title: t("common.success") }); },
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
)
}
data-testid="create-group-submit"
>
{t("common.save")}
</Button>
</div>
</div>
</div>
)}
{editingGroupId != null && (
<GroupDetailEditor
groupId={editingGroupId}
onClose={() => setEditingGroupId(null)}
onSaved={() => {
invalidate();
queryClient.invalidateQueries({ queryKey: getGetGroupQueryKey(editingGroupId) });
setEditingGroupId(null);
}}
/>
)}
{deleteTarget && (() => {
const counts = conflictCounts ?? {
memberCount: deleteTarget.memberCount,
appCount: deleteTarget.appCount,
roleCount: deleteTarget.roleCount,
};
const isNonEmpty =
counts.memberCount > 0 || counts.appCount > 0 || counts.roleCount > 0;
const showWarning = conflictCounts !== null || isNonEmpty;
return (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3" data-testid="delete-group-dialog">
<h2 className="font-semibold text-foreground">
{t("admin.groups.deleteTitle", { name: deleteTarget.name })}
</h2>
{showWarning ? (
<>
<p className="text-sm text-destructive">
{t("admin.groups.deleteWarning")}
</p>
<ul className="text-sm text-foreground space-y-1 ps-4 list-disc">
<li>{t("admin.groups.members", { count: counts.memberCount })}</li>
<li>{t("admin.groups.appsCount", { count: counts.appCount })}</li>
<li>{t("admin.groups.rolesCount", { count: counts.roleCount })}</li>
</ul>
<p className="text-xs text-muted-foreground">
{t("admin.groups.deleteForceHint")}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
{t("admin.groups.deleteEmptyBody")}
</p>
)}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
className="flex-1"
onClick={closeDeleteDialog}
disabled={deleteGroup.isPending}
>
{t("common.cancel")}
</Button>
<Button
className="flex-1"
variant="destructive"
disabled={deleteGroup.isPending}
onClick={() => performDelete(deleteTarget, showWarning)}
data-testid="confirm-delete-group"
>
{showWarning
? t("admin.groups.deleteAnyway")
: t("admin.groups.deleteConfirm")}
</Button>
</div>
</div>
</div>
);
})()}
</div>
);
}
function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onClose: () => void; onSaved: () => void }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { toast } = useToast();
const { data: group } = useGetGroup(groupId, { query: { queryKey: getGetGroupQueryKey(groupId) } });
const { data: apps } = useQuery({ queryKey: ADMIN_APPS_KEY, queryFn: fetchAdminApps });
const { data: users } = useListUsers(undefined, { query: { queryKey: getListUsersQueryKey() } });
const { data: roles } = useListRoles({ query: { queryKey: getListRolesQueryKey() } });
const updateGroup = useUpdateGroup();
const [name, setName] = useState("");
const [descriptionAr, setDescriptionAr] = useState("");
const [descriptionEn, setDescriptionEn] = useState("");
const [appIds, setAppIds] = useState<Set<number>>(new Set());
const [userIds, setUserIds] = useState<Set<number>>(new Set());
const [roleIds, setRoleIds] = useState<Set<number>>(new Set());
const [tab, setTab] = useState<"info" | "apps" | "users" | "roles">("info");
const [userSearch, setUserSearch] = useState("");
const visibleUsers = (users ?? []).filter((u) => {
const q = userSearch.trim().toLowerCase();
if (!q) return true;
return (
u.username.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
(u.displayNameAr ?? "").toLowerCase().includes(q) ||
(u.displayNameEn ?? "").toLowerCase().includes(q)
);
});
useEffect(() => {
if (group) {
setName(group.name);
setDescriptionAr(group.descriptionAr ?? "");
setDescriptionEn(group.descriptionEn ?? "");
setAppIds(new Set(group.appIds));
setUserIds(new Set(group.userIds));
setRoleIds(new Set(group.roleIds));
}
}, [group]);
const toggle = (set: Set<number>, id: number, setter: (s: Set<number>) => void) => {
const next = new Set(set);
if (next.has(id)) next.delete(id);
else next.add(id);
setter(next);
};
if (!group) {
return (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-md text-center">
<Loader2 className="animate-spin mx-auto" />
</div>
</div>
);
}
return (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-lg space-y-3 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between gap-2">
<h2 className="font-semibold text-foreground flex items-center gap-2">
<Shield size={18} className="text-primary" />
{t("admin.groups.editGroup")}: {group.name}
</h2>
{group.isSystem && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase tracking-wider">
{t("admin.groups.system")}
</span>
)}
</div>
<div className="flex border-b border-slate-200/70">
{(["info", "apps", "users", "roles"] as const).map((k) => (
<button
key={k}
onClick={() => setTab(k)}
className={cn(
"px-3 py-2 text-sm border-b-2 transition-colors",
tab === k ? "border-primary text-primary font-semibold" : "border-transparent text-muted-foreground hover:text-foreground",
)}
data-testid={`group-tab-${k}`}
>
{t(`admin.groups.tab.${k}`)}
</button>
))}
</div>
{tab === "info" && (
<div className="space-y-2">
<div className="space-y-1">
<Label>{t("admin.groups.name")}</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} disabled={group.isSystem} className="bg-white/70 border-slate-200" />
</div>
<div className="space-y-1">
<Label>{t("admin.groups.descriptionAr")}</Label>
<Input value={descriptionAr} onChange={(e) => setDescriptionAr(e.target.value)} className="bg-white/70 border-slate-200" dir="rtl" />
</div>
<div className="space-y-1">
<Label>{t("admin.groups.descriptionEn")}</Label>
<Input value={descriptionEn} onChange={(e) => setDescriptionEn(e.target.value)} className="bg-white/70 border-slate-200" dir="ltr" />
</div>
</div>
)}
{tab === "apps" && (
<div className="space-y-1 max-h-72 overflow-y-auto">
<p className="text-xs text-muted-foreground px-1 pb-2">{t("admin.groups.appsHint")}</p>
{apps?.map((a) => (
<label key={a.id} className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-slate-100 cursor-pointer">
<input type="checkbox" checked={appIds.has(a.id)} onChange={() => toggle(appIds, a.id, setAppIds)} />
<span className="text-sm flex-1">{lang === "ar" ? a.nameAr : a.nameEn}</span>
<span className="text-[10px] text-muted-foreground">{a.slug}</span>
</label>
))}
</div>
)}
{tab === "roles" && (
<div className="space-y-1 max-h-72 overflow-y-auto">
<p className="text-xs text-muted-foreground px-1 pb-2">{t("admin.groups.rolesHint")}</p>
{roles?.map((r) => {
const primaryDesc = lang === "ar" ? r.descriptionAr : r.descriptionEn;
const secondaryDesc = lang === "ar" ? r.descriptionEn : r.descriptionAr;
const secondaryDir = lang === "ar" ? "ltr" : "rtl";
const heading = primaryDesc || secondaryDesc || r.name;
const showSecondary = !!(primaryDesc && secondaryDesc);
return (
<Tooltip key={r.id}>
<TooltipTrigger asChild>
<label
className="flex items-start gap-2 px-3 py-2 rounded-lg hover:bg-slate-100 cursor-pointer"
data-testid={`group-role-${r.id}`}
>
<input
type="checkbox"
className="mt-1"
checked={roleIds.has(r.id)}
onChange={() => toggle(roleIds, r.id, setRoleIds)}
/>
<span className="flex-1 min-w-0">
<span className="block text-sm">{heading}</span>
{showSecondary && (
<span
className="block text-xs text-muted-foreground"
dir={secondaryDir}
data-testid={`group-role-${r.id}-secondary`}
>
{secondaryDesc}
</span>
)}
</span>
<span className="text-[10px] text-muted-foreground mt-1 shrink-0 font-mono">
{r.name}
</span>
</label>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="space-y-1 text-xs">
{r.descriptionAr && <div dir="rtl">{r.descriptionAr}</div>}
{r.descriptionEn && <div dir="ltr">{r.descriptionEn}</div>}
<div className="opacity-70 font-mono" dir="ltr">{r.name}</div>
</div>
</TooltipContent>
</Tooltip>
);
})}
{(!roles || roles.length === 0) && (
<p className="text-sm text-muted-foreground p-2">{t("admin.groups.rolesEmpty")}</p>
)}
</div>
)}
{tab === "users" && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground px-1">{t("admin.groups.usersHint")}</p>
<Input
placeholder={t("admin.users.searchPlaceholder")}
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid="group-user-search"
/>
<div className="space-y-1 max-h-60 overflow-y-auto">
{visibleUsers.map((u) => (
<label key={u.id} className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-slate-100 cursor-pointer">
<input type="checkbox" checked={userIds.has(u.id)} onChange={() => toggle(userIds, u.id, setUserIds)} />
<span className="text-sm flex-1">{u.username}</span>
<span className="text-[10px] text-muted-foreground">{u.email}</span>
</label>
))}
{visibleUsers.length === 0 && (
<p className="text-sm text-muted-foreground p-2">{t("admin.users.empty")}</p>
)}
</div>
</div>
)}
<div className="flex gap-2 pt-2 border-t border-slate-200/70">
<Button variant="outline" className="flex-1" onClick={onClose}>{t("common.cancel")}</Button>
<Button
className="flex-1"
onClick={() =>
updateGroup.mutate(
{
id: group.id,
data: {
name: group.isSystem ? undefined : name.trim() || undefined,
descriptionAr: descriptionAr || null,
descriptionEn: descriptionEn || null,
appIds: Array.from(appIds),
userIds: Array.from(userIds),
roleIds: Array.from(roleIds),
},
},
{
onSuccess: () => { toast({ title: t("common.success") }); onSaved(); },
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
)
}
data-testid="save-group"
>
{t("common.save")}
</Button>
</div>
</div>
</div>
);
}
const ROLES_PROTECTED_NAMES = new Set(["admin", "user", "order_receiver"]);
function permissionLabel(
id: number,
permissionsById: Map<number, Permission>,
): string {
const p = permissionsById.get(id);
return p ? p.name : `#${id}`;
}
function RolePermissionHistory({
entries,
loading,
permissionsById,
language,
}: {
entries: RolePermissionAuditEntry[] | null;
loading: boolean;
permissionsById: Map<number, Permission>;
language: string;
}) {
const { t } = useTranslation();
return (
<div className="space-y-1 pt-2" data-testid="role-history-section">
<Label>{t("admin.roles.historyTitle")}</Label>
<p className="text-xs text-muted-foreground">{t("admin.roles.historyHint")}</p>
<div
className="border border-slate-200 rounded-xl bg-white/60 max-h-56 overflow-y-auto divide-y divide-slate-100"
data-testid="role-history-list"
>
{loading ? (
<div className="flex justify-center py-6 text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
) : !entries || entries.length === 0 ? (
<p
className="text-xs text-muted-foreground p-3 text-center"
data-testid="role-history-empty"
>
{t("admin.roles.historyEmpty")}
</p>
) : (
entries.map((entry) => {
const actor = entry.actor;
const ar = actor?.displayNameAr ?? null;
const en = actor?.displayNameEn ?? null;
const preferred =
language === "ar" ? ar ?? en : en ?? ar;
const actorName =
actor && (preferred?.trim() ? preferred : actor.username);
return (
<div
key={entry.id}
className="p-2.5 text-xs space-y-1"
data-testid={`role-history-entry-${entry.id}`}
>
<div className="flex items-center justify-between gap-2 text-foreground">
<span className="font-medium" data-testid={`role-history-actor-${entry.id}`}>
{actorName ?? t("admin.roles.historyActorUnknown")}
</span>
<span
className="text-muted-foreground"
data-testid={`role-history-time-${entry.id}`}
>
{formatAuditTimestamp(entry.createdAt, language)}
</span>
</div>
{entry.addedPermissionIds.length > 0 && (
<div
className="text-emerald-700"
data-testid={`role-history-added-${entry.id}`}
>
<span className="font-semibold">
{t("admin.roles.historyAdded", {
count: entry.addedPermissionIds.length,
})}
</span>{" "}
<span className="font-mono break-all">
{entry.addedPermissionIds
.map((id) => permissionLabel(id, permissionsById))
.join(", ")}
</span>
</div>
)}
{entry.removedPermissionIds.length > 0 && (
<div
className="text-rose-700"
data-testid={`role-history-removed-${entry.id}`}
>
<span className="font-semibold">
{t("admin.roles.historyRemoved", {
count: entry.removedPermissionIds.length,
})}
</span>{" "}
<span className="font-mono break-all">
{entry.removedPermissionIds
.map((id) => permissionLabel(id, permissionsById))
.join(", ")}
</span>
</div>
)}
<div
className="text-muted-foreground"
data-testid={`role-history-total-${entry.id}`}
>
{t("admin.roles.historyTotal", {
count: entry.newPermissionIds.length,
})}
</div>
</div>
);
})
)}
</div>
</div>
);
}
function RolesPanel() {
const { t, i18n } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: roles } = useListRoles({ query: { queryKey: getListRolesQueryKey() } });
const { data: allPermissions } = useListPermissions({
query: { queryKey: getListPermissionsQueryKey() },
});
const createRole = useCreateRole();
const updateRole = useUpdateRole();
const deleteRole = useDeleteRole();
const replaceRolePermissions = useReplaceRolePermissions();
const previewRolePermissionsImpact = usePreviewRolePermissionsImpact();
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" });
const [editingId, setEditingId] = useState<number | null>(null);
const [editingIsSystem, setEditingIsSystem] = useState(false);
const [editForm, setEditForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" });
const [originalEditName, setOriginalEditName] = useState("");
const [editPermissionIds, setEditPermissionIds] = useState<Set<number>>(new Set());
const [initialPermissionIds, setInitialPermissionIds] = useState<Set<number>>(new Set());
const [deleteTarget, setDeleteTarget] = useState<{ id: number; name: string } | null>(null);
const [deleteConflict, setDeleteConflict] = useState<{ userCount: number; groupCount: number } | null>(null);
const [permissionsImpact, setPermissionsImpact] = useState<RolePermissionsImpact | null>(null);
const [impactLoading, setImpactLoading] = useState(false);
const [impactError, setImpactError] = useState(false);
const [confirmRemoval, setConfirmRemoval] = useState(false);
const impactRequestSeq = useRef(0);
const editingPermissionsId = editingId ?? 0;
const { data: editingRolePermissions, isLoading: editingPermsLoading } = useGetRolePermissions(
editingPermissionsId,
{
query: {
queryKey: getGetRolePermissionsQueryKey(editingPermissionsId),
enabled: editingId != null,
},
},
);
const { data: editingRoleUsage } = useGetRoleUsage(editingPermissionsId, {
query: {
queryKey: getGetRoleUsageQueryKey(editingPermissionsId),
enabled: editingId != null && !editingIsSystem,
},
});
const auditLimit = 5;
const { data: editingRoleAudit, isLoading: editingAuditLoading } =
useGetRolePermissionAudit(
editingPermissionsId,
{ limit: auditLimit },
{
query: {
queryKey: getGetRolePermissionAuditQueryKey(editingPermissionsId, {
limit: auditLimit,
}),
enabled: editingId != null,
},
},
);
const permissionsById = useMemo(() => {
const map = new Map<number, Permission>();
for (const p of allPermissions ?? []) map.set(p.id, p);
return map;
}, [allPermissions]);
useEffect(() => {
if (editingId == null) return;
if (!editingRolePermissions) return;
const ids = new Set(editingRolePermissions.map((p) => p.id));
setEditPermissionIds(ids);
setInitialPermissionIds(ids);
}, [editingId, editingRolePermissions]);
const invalidate = () => queryClient.invalidateQueries({ queryKey: getListRolesQueryKey() });
const visibleRoles = (roles ?? []).filter((r) => {
const q = search.trim().toLowerCase();
if (!q) return true;
return (
r.name.toLowerCase().includes(q) ||
(r.descriptionAr ?? "").toLowerCase().includes(q) ||
(r.descriptionEn ?? "").toLowerCase().includes(q)
);
});
const handleCreate = () => {
const name = createForm.name.trim();
if (!name) return;
createRole.mutate(
{
data: {
name,
descriptionAr: createForm.descriptionAr.trim() || null,
descriptionEn: createForm.descriptionEn.trim() || null,
},
},
{
onSuccess: () => {
invalidate();
setCreateOpen(false);
setCreateForm({ name: "", descriptionAr: "", descriptionEn: "" });
toast({ title: t("common.success") });
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409) {
toast({ title: t("admin.roles.errorNameTaken"), variant: "destructive" });
return;
}
if (err instanceof ApiError && err.status === 400) {
toast({ title: t("admin.roles.errorInvalidName"), variant: "destructive" });
return;
}
toast({ title: t("common.error"), variant: "destructive" });
},
},
);
};
const beginEdit = (role: {
id: number;
name: string;
isSystem: boolean;
descriptionAr?: string | null;
descriptionEn?: string | null;
}) => {
setEditingId(role.id);
setEditingIsSystem(role.isSystem || ROLES_PROTECTED_NAMES.has(role.name));
setEditForm({
name: role.name,
descriptionAr: role.descriptionAr ?? "",
descriptionEn: role.descriptionEn ?? "",
});
setOriginalEditName(role.name);
};
const closeEditDialog = () => {
setEditingId(null);
setEditPermissionIds(new Set());
setInitialPermissionIds(new Set());
setOriginalEditName("");
setPermissionsImpact(null);
setImpactLoading(false);
setImpactError(false);
setConfirmRemoval(false);
};
const editNameTrimmed = editForm.name.trim();
const isRenaming =
!editingIsSystem &&
originalEditName.length > 0 &&
editNameTrimmed.length > 0 &&
editNameTrimmed !== originalEditName;
const permissionsChanged = (() => {
if (editPermissionIds.size !== initialPermissionIds.size) return true;
for (const id of editPermissionIds) {
if (!initialPermissionIds.has(id)) return true;
}
return false;
})();
const removedPermissionIds = useMemo(() => {
const removed: number[] = [];
for (const id of initialPermissionIds) {
if (!editPermissionIds.has(id)) removed.push(id);
}
return removed;
}, [editPermissionIds, initialPermissionIds]);
const hasRemovals = removedPermissionIds.length > 0;
// Debounced on-demand fetch of impact preview when removals are present.
useEffect(() => {
if (editingId == null) return;
if (editingIsSystem) return;
if (!hasRemovals) {
setPermissionsImpact(null);
setImpactLoading(false);
setImpactError(false);
return;
}
setImpactLoading(true);
setImpactError(false);
const editingRoleId = editingId;
const candidate = Array.from(editPermissionIds);
const requestId = ++impactRequestSeq.current;
const handle = setTimeout(() => {
previewRolePermissionsImpact.mutate(
{ id: editingRoleId, data: { permissionIds: candidate } },
{
onSuccess: (data) => {
if (impactRequestSeq.current !== requestId) return;
setPermissionsImpact(data);
setImpactError(false);
setImpactLoading(false);
},
onError: () => {
if (impactRequestSeq.current !== requestId) return;
setPermissionsImpact(null);
setImpactError(true);
setImpactLoading(false);
},
},
);
}, 350);
return () => clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editingId, editingIsSystem, hasRemovals, editPermissionIds, initialPermissionIds]);
const handleEdit = () => {
if (editingId == null) return;
if (
!editingIsSystem &&
hasRemovals &&
!confirmRemoval &&
permissionsImpact &&
permissionsImpact.totalAffectedUsers > 0
) {
setConfirmRemoval(true);
return;
}
const editingRoleId = editingId;
const name = editForm.name.trim();
if (!name) return;
const savePermissionsThenClose = () => {
if (editingIsSystem || !permissionsChanged) {
invalidate();
closeEditDialog();
toast({ title: t("common.success") });
return;
}
replaceRolePermissions.mutate(
{
id: editingRoleId,
data: { permissionIds: Array.from(editPermissionIds) },
},
{
onSuccess: () => {
invalidate();
queryClient.invalidateQueries({
queryKey: getGetRolePermissionsQueryKey(editingRoleId),
});
queryClient.invalidateQueries({
queryKey: getGetRolePermissionAuditQueryKey(editingRoleId, {
limit: auditLimit,
}),
});
closeEditDialog();
toast({ title: t("common.success") });
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 400) {
const code = (err.data as { code?: string } | null)?.code;
const key =
code === "system_role_permissions"
? "admin.roles.errorSystemPermissions"
: "admin.roles.errorPermissionsSave";
toast({ title: t(key), variant: "destructive" });
return;
}
toast({ title: t("admin.roles.errorPermissionsSave"), variant: "destructive" });
},
},
);
};
updateRole.mutate(
{
id: editingRoleId,
data: {
...(editingIsSystem ? {} : { name }),
descriptionAr: editForm.descriptionAr.trim() || null,
descriptionEn: editForm.descriptionEn.trim() || null,
},
},
{
onSuccess: () => {
savePermissionsThenClose();
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409) {
toast({ title: t("admin.roles.errorNameTaken"), variant: "destructive" });
return;
}
if (err instanceof ApiError && err.status === 400) {
const code = (err.data as { code?: string } | null)?.code;
const key = code === "system_role_rename" ? "admin.roles.errorSystemRename" : "admin.roles.errorInvalidName";
toast({ title: t(key), variant: "destructive" });
return;
}
toast({ title: t("common.error"), variant: "destructive" });
},
},
);
};
const togglePermission = (id: number) => {
if (editingIsSystem) return;
setEditPermissionIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleDelete = () => {
if (!deleteTarget) return;
deleteRole.mutate(
{ id: deleteTarget.id },
{
onSuccess: () => {
invalidate();
setDeleteTarget(null);
setDeleteConflict(null);
toast({ title: t("common.success") });
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409 && err.data) {
const data = err.data as { userCount: number; groupCount: number };
setDeleteConflict({ userCount: data.userCount, groupCount: data.groupCount });
return;
}
toast({ title: t("common.error"), variant: "destructive" });
setDeleteTarget(null);
},
},
);
};
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Input
placeholder={t("admin.roles.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="bg-white/70 border-slate-200 flex-1"
data-testid="role-search"
/>
<Button
size="sm"
onClick={() => {
setCreateForm({ name: "", descriptionAr: "", descriptionEn: "" });
setCreateOpen(true);
}}
data-testid="create-role-btn"
>
<Plus size={14} className="me-1" />
{t("admin.roles.addRole")}
</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
{visibleRoles.map((r) => (
<div key={r.id} className="glass-panel rounded-2xl p-4 space-y-2" data-testid={`role-card-${r.id}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold text-foreground flex items-center gap-2">
<KeyRound size={16} className="text-primary" />
<span className="truncate">{r.name}</span>
{(r.isSystem || ROLES_PROTECTED_NAMES.has(r.name)) && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase tracking-wider">
{t("admin.roles.system")}
</span>
)}
</div>
{(r.descriptionAr || r.descriptionEn) && (
<p className="text-xs text-muted-foreground mt-1">
{r.descriptionEn || r.descriptionAr}
</p>
)}
{r.descriptionAr && r.descriptionEn && (
<p className="text-xs text-muted-foreground mt-0.5" dir="rtl">
{r.descriptionAr}
</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => beginEdit(r)}
className="p-1.5 hover:bg-slate-100 rounded-lg text-muted-foreground hover:text-foreground"
data-testid={`edit-role-${r.id}`}
aria-label={t("admin.roles.edit")}
>
<Pencil size={14} />
</button>
{!(r.isSystem || ROLES_PROTECTED_NAMES.has(r.name)) && (
<button
onClick={() => {
setDeleteConflict(null);
setDeleteTarget({ id: r.id, name: r.name });
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
data-testid={`delete-role-${r.id}`}
aria-label={t("admin.roles.delete")}
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
</div>
))}
{visibleRoles.length === 0 && (
<div className="glass-panel rounded-2xl p-6 text-center text-sm text-muted-foreground md:col-span-2">
{t("admin.roles.empty")}
</div>
)}
</div>
{createOpen && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3" data-testid="create-role-dialog">
<h2 className="font-semibold text-foreground">{t("admin.roles.addRole")}</h2>
<div className="space-y-1">
<Label>{t("admin.roles.name")}</Label>
<Input
value={createForm.name}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
className="bg-white/70 border-slate-200"
placeholder={t("admin.roles.namePlaceholder")}
data-testid="role-name-input"
/>
<p className="text-xs text-muted-foreground">{t("admin.roles.nameHint")}</p>
</div>
<div className="space-y-1">
<Label>{t("admin.roles.descriptionAr")}</Label>
<Input
value={createForm.descriptionAr}
onChange={(e) => setCreateForm({ ...createForm, descriptionAr: e.target.value })}
className="bg-white/70 border-slate-200"
dir="rtl"
data-testid="role-desc-ar-input"
/>
</div>
<div className="space-y-1">
<Label>{t("admin.roles.descriptionEn")}</Label>
<Input
value={createForm.descriptionEn}
onChange={(e) => setCreateForm({ ...createForm, descriptionEn: e.target.value })}
className="bg-white/70 border-slate-200"
dir="ltr"
data-testid="role-desc-en-input"
/>
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={() => setCreateOpen(false)}>
{t("common.cancel")}
</Button>
<Button
className="flex-1"
disabled={!createForm.name.trim() || createRole.isPending}
onClick={handleCreate}
data-testid="create-role-submit"
>
{createRole.isPending ? <Loader2 size={14} className="animate-spin" /> : t("common.save")}
</Button>
</div>
</div>
</div>
)}
{editingId != null && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div
className="glass-panel rounded-3xl p-6 w-full max-w-md space-y-3 max-h-[90vh] overflow-y-auto"
data-testid="edit-role-dialog"
>
<h2 className="font-semibold text-foreground">{t("admin.roles.editRole")}</h2>
<div className="space-y-1">
<Label>{t("admin.roles.name")}</Label>
<Input
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
className="bg-white/70 border-slate-200"
placeholder={t("admin.roles.namePlaceholder")}
disabled={editingIsSystem}
data-testid="role-edit-name"
/>
<p className="text-xs text-muted-foreground">
{editingIsSystem ? t("admin.roles.editSystemNameHint") : t("admin.roles.nameHint")}
</p>
{isRenaming && (
<div
className="mt-2 rounded-xl border border-amber-300 bg-amber-50 p-3 text-xs text-amber-900 space-y-1"
data-testid="role-rename-warning"
role="alert"
>
<p className="font-semibold">
{t("admin.roles.renameWarningTitle", {
from: originalEditName,
to: editNameTrimmed,
})}
</p>
<p>{t("admin.roles.renameWarningBody")}</p>
{editingRoleUsage && (
<p
className="text-amber-800"
data-testid="role-rename-usage"
>
{t("admin.roles.renameUsage", {
users: editingRoleUsage.userCount,
groups: editingRoleUsage.groupCount,
})}
</p>
)}
</div>
)}
</div>
<div className="space-y-1">
<Label>{t("admin.roles.descriptionAr")}</Label>
<Input
value={editForm.descriptionAr}
onChange={(e) => setEditForm({ ...editForm, descriptionAr: e.target.value })}
className="bg-white/70 border-slate-200"
dir="rtl"
data-testid="role-edit-desc-ar"
/>
</div>
<div className="space-y-1">
<Label>{t("admin.roles.descriptionEn")}</Label>
<Input
value={editForm.descriptionEn}
onChange={(e) => setEditForm({ ...editForm, descriptionEn: e.target.value })}
className="bg-white/70 border-slate-200"
dir="ltr"
data-testid="role-edit-desc-en"
/>
</div>
<div className="space-y-1 pt-2">
<Label>{t("admin.roles.permissions")}</Label>
<p className="text-xs text-muted-foreground">
{editingIsSystem
? t("admin.roles.permissionsSystemHint")
: t("admin.roles.permissionsHint")}
</p>
<div
className="border border-slate-200 rounded-xl bg-white/60 max-h-56 overflow-y-auto divide-y divide-slate-100"
data-testid="role-permissions-list"
>
{editingPermsLoading || !allPermissions ? (
<div className="flex justify-center py-6 text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
) : allPermissions.length === 0 ? (
<p className="text-sm text-muted-foreground p-3 text-center">
{t("admin.roles.permissionsEmpty")}
</p>
) : (
allPermissions.map((p) => {
const checked = editPermissionIds.has(p.id);
const desc =
i18n.language === "ar"
? p.descriptionAr ?? p.descriptionEn
: p.descriptionEn ?? p.descriptionAr;
return (
<label
key={p.id}
className={cn(
"flex items-start gap-2 p-2.5 text-sm",
editingIsSystem
? "cursor-not-allowed opacity-80"
: "cursor-pointer hover:bg-slate-50",
)}
data-testid={`role-perm-row-${p.id}`}
>
<input
type="checkbox"
className="mt-0.5"
checked={checked}
disabled={editingIsSystem}
onChange={() => togglePermission(p.id)}
data-testid={`role-perm-checkbox-${p.id}`}
/>
<div className="min-w-0 flex-1">
<div className="font-mono text-xs text-foreground">{p.name}</div>
{desc && (
<p className="text-xs text-muted-foreground mt-0.5">{desc}</p>
)}
</div>
</label>
);
})
)}
</div>
{!editingIsSystem && hasRemovals && (
<div
className="mt-2 rounded-xl border border-amber-300 bg-amber-50 p-3 text-xs text-amber-900 space-y-2"
data-testid="role-perm-impact"
role="status"
aria-live="polite"
>
<p className="font-semibold">
{t("admin.roles.impactTitle", { count: removedPermissionIds.length })}
</p>
{impactLoading ? (
<div className="flex items-center gap-2 text-amber-800">
<Loader2 size={12} className="animate-spin" />
<span>{t("admin.roles.impactLoading")}</span>
</div>
) : impactError || !permissionsImpact ? (
<p
className="text-amber-900"
data-testid="role-perm-impact-error"
>
{t("admin.roles.impactError")}
</p>
) : permissionsImpact.removed.length === 0 ? (
<p>{t("admin.roles.impactNoUsers")}</p>
) : (
<>
<ul
className="space-y-1.5 ps-4 list-disc"
data-testid="role-perm-impact-list"
>
{permissionsImpact.removed.map((item) => (
<li key={item.permissionId}>
<span className="font-mono">{item.permissionName}</span>
{": "}
<span data-testid={`role-perm-impact-counts-${item.permissionId}`}>
{t("admin.roles.impactPerPermission", {
users: item.userCount,
groups: item.groupCount,
})}
</span>
{item.groupCount > 0 && (
<span className="text-amber-800">
{" "}
{t("admin.roles.impactViaGroups", {
groups: item.groups.map((g) => g.name).join(", "),
})}
</span>
)}
</li>
))}
</ul>
{permissionsImpact.totalAffectedUsers > 0 && (
<p
className="font-semibold text-amber-900"
data-testid="role-perm-impact-total"
>
{t("admin.roles.impactTotal", {
count: permissionsImpact.totalAffectedUsers,
})}
</p>
)}
</>
)}
</div>
)}
</div>
<RolePermissionHistory
entries={editingRoleAudit ?? null}
loading={editingAuditLoading}
permissionsById={permissionsById}
language={i18n.language}
/>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={closeEditDialog}>
{t("common.cancel")}
</Button>
<Button
className="flex-1"
disabled={
!editForm.name.trim() ||
updateRole.isPending ||
replaceRolePermissions.isPending ||
(!editingIsSystem && hasRemovals && (impactLoading || impactError))
}
onClick={handleEdit}
data-testid="edit-role-submit"
>
{updateRole.isPending || replaceRolePermissions.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
t("common.save")
)}
</Button>
</div>
</div>
</div>
)}
{confirmRemoval && permissionsImpact && permissionsImpact.totalAffectedUsers > 0 && (
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[60] flex items-center justify-center p-4">
<div
className="glass-panel rounded-3xl p-6 w-full max-w-md space-y-3"
data-testid="role-perm-confirm-dialog"
role="alertdialog"
aria-modal="true"
>
<h2 className="font-semibold text-foreground">
{t("admin.roles.confirmRemovalTitle")}
</h2>
<p className="text-sm text-foreground">
{t("admin.roles.confirmRemovalBody", {
count: permissionsImpact.totalAffectedUsers,
})}
</p>
<ul
className="text-xs text-muted-foreground space-y-1 ps-4 list-disc max-h-40 overflow-y-auto"
>
{permissionsImpact.removed.map((item) => (
<li key={item.permissionId}>
<span className="font-mono text-foreground">{item.permissionName}</span>
{": "}
{t("admin.roles.impactPerPermission", {
users: item.userCount,
groups: item.groupCount,
})}
</li>
))}
</ul>
<div className="flex gap-2 pt-2">
<Button
variant="outline"
className="flex-1"
onClick={() => setConfirmRemoval(false)}
data-testid="role-perm-confirm-cancel"
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
className="flex-1"
disabled={updateRole.isPending || replaceRolePermissions.isPending}
onClick={handleEdit}
data-testid="role-perm-confirm-submit"
>
{updateRole.isPending || replaceRolePermissions.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
t("admin.roles.confirmRemovalAction")
)}
</Button>
</div>
</div>
</div>
)}
{deleteTarget && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3" data-testid="delete-role-dialog">
<h2 className="font-semibold text-foreground">
{t("admin.roles.deleteTitle", { name: deleteTarget.name })}
</h2>
{deleteConflict ? (
<>
<p className="text-sm text-destructive">{t("admin.roles.deleteConflict")}</p>
<ul className="text-sm text-foreground space-y-1 ps-4 list-disc">
<li>{t("admin.roles.usersCount", { count: deleteConflict.userCount })}</li>
<li>{t("admin.roles.groupsCount", { count: deleteConflict.groupCount })}</li>
</ul>
<p className="text-xs text-muted-foreground">{t("admin.roles.deleteConflictHint")}</p>
</>
) : (
<p className="text-sm text-muted-foreground">{t("admin.roles.deleteEmptyBody")}</p>
)}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
className="flex-1"
onClick={() => {
setDeleteTarget(null);
setDeleteConflict(null);
}}
>
{t("common.cancel")}
</Button>
{!deleteConflict && (
<Button
variant="destructive"
className="flex-1"
disabled={deleteRole.isPending}
onClick={handleDelete}
data-testid="delete-role-confirm"
>
{deleteRole.isPending ? <Loader2 size={14} className="animate-spin" /> : t("admin.roles.deleteConfirm")}
</Button>
)}
</div>
</div>
</div>
)}
</div>
);
}
// ===== Audit Log Panel =====
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
function formatAuditTimestamp(value: string | Date, lang: string): string {
const d = value instanceof Date ? value : new Date(value);
return new Intl.DateTimeFormat(lang === "ar" ? "ar-u-nu-latn" : "en-US", {
dateStyle: "medium",
timeStyle: "short",
numberingSystem: "latn",
hour12: false,
}).format(d);
}
function actorLabel(actor: AuditLogEntry["actor"], lang: string): string {
if (!actor) return "—";
const ar = actor.displayNameAr ?? null;
const en = actor.displayNameEn ?? null;
const preferred = lang === "ar" ? ar ?? en : en ?? ar;
return preferred && preferred.trim() ? preferred : actor.username;
}
type AuditTFunction = ReturnType<typeof useTranslation>["t"];
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function asString(v: unknown): string | null {
return typeof v === "string" && v.trim() ? v : null;
}
function asNumber(v: unknown): number | null {
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
function unitLabel(t: AuditTFunction, kind: string, count: number): string {
return t(`admin.audit.unit.${kind}`, { count });
}
function appName(meta: Record<string, unknown>, lang: string, fallbackId: AuditLogEntry["targetId"]): string {
const en = asString(meta.nameEn);
const ar = asString(meta.nameAr);
const slug = asString(meta.slug);
const preferred = lang === "ar" ? ar ?? en : en ?? ar;
return preferred ?? slug ?? `#${fallbackId ?? "?"}`;
}
function linkedAppName(meta: Record<string, unknown>, lang: string): string | null {
const en = asString(meta.appNameEn);
const ar = asString(meta.appNameAr);
const slug = asString(meta.appSlug);
return (lang === "ar" ? ar ?? en : en ?? ar) ?? slug ?? null;
}
function plainName(meta: Record<string, unknown>, fallbackId: AuditLogEntry["targetId"]): string {
return asString(meta.name) ?? `#${fallbackId ?? "?"}`;
}
function changeCount(meta: Record<string, unknown>): number {
const changes = meta.changes;
if (changes && typeof changes === "object" && !Array.isArray(changes)) {
return Object.keys(changes as Record<string, unknown>).length;
}
if (Array.isArray(changes)) return changes.length;
let count = 0;
for (const key of ["members", "apps", "roles"]) {
const diff = meta[key];
if (diff && typeof diff === "object" && !Array.isArray(diff)) {
const d = diff as Record<string, unknown>;
const added = Array.isArray(d.added) ? d.added.length : 0;
const removed = Array.isArray(d.removed) ? d.removed.length : 0;
if (added + removed > 0) count += 1;
}
}
return count;
}
function formatAuditSummary(
entry: AuditLogEntry,
t: AuditTFunction,
lang: string,
): string | null {
const action = entry.action;
const meta = asRecord(entry.metadata);
const targetId = entry.targetId;
switch (action) {
case "app.create":
return t("admin.audit.summary.app.create", {
name: appName(meta, lang, targetId),
});
case "app.update": {
const name = appName(meta, lang, targetId);
const changes = asRecord(meta.changes);
const order = lang === "ar"
? ["nameAr", "nameEn", "slug"]
: ["nameEn", "nameAr", "slug"];
for (const field of order) {
if (!(field in changes)) continue;
const change = asRecord(changes[field]);
const previousName = asString(change.from);
const nextName = asString(change.to);
if (previousName && nextName && previousName !== nextName) {
return t("admin.audit.summary.app.rename", {
previousName,
name: nextName,
});
}
}
return t("admin.audit.summary.app.update", {
name,
changes: unitLabel(t, "change", changeCount(meta)),
});
}
case "app.delete": {
const name = appName(meta, lang, targetId);
if (meta.force) {
const groupCount = asNumber(meta.groupCount) ?? 0;
return t("admin.audit.summary.app.forceDelete", {
name,
groups: unitLabel(t, "group", groupCount),
});
}
return t("admin.audit.summary.app.delete", { name });
}
case "auth.issue_reset_link":
return t("admin.audit.summary.auth.issueResetLink", {
username: asString(meta.username) ?? `#${targetId ?? "?"}`,
});
case "group.create":
return t("admin.audit.summary.group.create", {
name: plainName(meta, targetId),
members: unitLabel(t, "member", asNumber(meta.memberCount) ?? 0),
apps: unitLabel(t, "app", asNumber(meta.appCount) ?? 0),
});
case "group.update": {
const name = plainName(meta, targetId);
const previousName = asString(meta.previousName);
const fieldsChanged = changeCount(meta);
if (previousName && previousName !== name && fieldsChanged <= 1) {
return t("admin.audit.summary.group.rename", {
previousName,
name,
});
}
return t("admin.audit.summary.group.update", {
name,
changes: unitLabel(t, "change", Math.max(1, fieldsChanged)),
});
}
case "group.delete": {
const name = plainName(meta, targetId);
if (meta.force) {
const memberCount = asNumber(meta.memberCount) ?? 0;
return t("admin.audit.summary.group.forceDelete", {
name,
members: unitLabel(t, "member", memberCount),
});
}
return t("admin.audit.summary.group.delete", { name });
}
case "group.user.add": {
const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`;
const username = asString(meta.username);
return username
? t("admin.audit.summary.group.userAdd", { name: groupName, username })
: t("admin.audit.summary.group.userAddById", {
name: groupName,
userId: asNumber(meta.userId) ?? "?",
});
}
case "group.user.remove": {
const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`;
const username = asString(meta.username);
return username
? t("admin.audit.summary.group.userRemove", { name: groupName, username })
: t("admin.audit.summary.group.userRemoveById", {
name: groupName,
userId: asNumber(meta.userId) ?? "?",
});
}
case "group.app.add": {
const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`;
const appNm = linkedAppName(meta, lang);
return appNm
? t("admin.audit.summary.group.appAdd", { name: groupName, appName: appNm })
: t("admin.audit.summary.group.appAddById", {
name: groupName,
appId: asNumber(meta.appId) ?? "?",
});
}
case "group.app.remove": {
const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`;
const appNm = linkedAppName(meta, lang);
return appNm
? t("admin.audit.summary.group.appRemove", { name: groupName, appName: appNm })
: t("admin.audit.summary.group.appRemoveById", {
name: groupName,
appId: asNumber(meta.appId) ?? "?",
});
}
case "group.role.add": {
const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`;
const roleName = asString(meta.roleName);
return roleName
? t("admin.audit.summary.group.roleAdd", { name: groupName, roleName })
: t("admin.audit.summary.group.roleAddById", {
name: groupName,
roleId: asNumber(meta.roleId) ?? "?",
});
}
case "group.role.remove": {
const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`;
const roleName = asString(meta.roleName);
return roleName
? t("admin.audit.summary.group.roleRemove", { name: groupName, roleName })
: t("admin.audit.summary.group.roleRemoveById", {
name: groupName,
roleId: asNumber(meta.roleId) ?? "?",
});
}
case "role.create":
return t("admin.audit.summary.role.create", {
name: plainName(meta, targetId),
});
case "role.update": {
const previousName = asString(meta.previousName);
const name = plainName(meta, targetId);
if (previousName && previousName !== name) {
return t("admin.audit.summary.role.rename", {
previousName,
name,
});
}
return t("admin.audit.summary.role.update", {
name,
changes: unitLabel(t, "change", Math.max(1, changeCount(meta))),
});
}
case "role.delete":
return t("admin.audit.summary.role.delete", {
name: plainName(meta, targetId),
});
case "role.permissions.replace": {
const added = Array.isArray(meta.added) ? meta.added.length : 0;
const removed = Array.isArray(meta.removed) ? meta.removed.length : 0;
return t("admin.audit.summary.role.permissionsReplace", {
name: asString(meta.roleName) ?? `#${targetId ?? "?"}`,
added: unitLabel(t, "permission", added),
removed: unitLabel(t, "permission", removed),
});
}
case "role.permission.add":
return t("admin.audit.summary.role.permissionAdd", {
name: asString(meta.roleName) ?? `#${targetId ?? "?"}`,
permission:
asString(meta.permissionName) ??
(asNumber(meta.permissionId) != null ? `#${meta.permissionId}` : "?"),
});
case "role.permission.remove":
return t("admin.audit.summary.role.permissionRemove", {
name: asString(meta.roleName) ?? `#${targetId ?? "?"}`,
permission:
asString(meta.permissionName) ??
(asNumber(meta.permissionId) != null ? `#${meta.permissionId}` : "?"),
});
case "service.force_delete":
return t("admin.audit.summary.service.forceDelete", {
name: asString(meta.nameEn) ?? `#${targetId ?? "?"}`,
orders: unitLabel(t, "order", asNumber(meta.orderCount) ?? 0),
});
case "settings.update": {
const changes = asRecord(meta.changes);
const totalChanges = Object.keys(changes).length;
if ("registrationOpen" in changes) {
const change = asRecord(changes.registrationOpen);
const opened = change.to === true;
const otherCount = totalChanges - 1;
if (otherCount <= 0) {
return t(
opened
? "admin.audit.summary.settings.registrationOpened"
: "admin.audit.summary.settings.registrationClosed",
);
}
return t(
opened
? "admin.audit.summary.settings.registrationOpenedWith"
: "admin.audit.summary.settings.registrationClosedWith",
{ others: unitLabel(t, "change", otherCount) },
);
}
return t("admin.audit.summary.settings.update", {
changes: unitLabel(t, "change", Math.max(1, totalChanges)),
});
}
case "user.delete": {
const username = asString(meta.username) ?? `#${targetId ?? "?"}`;
return meta.force
? t("admin.audit.summary.user.forceDelete", { username })
: t("admin.audit.summary.user.delete", { username });
}
default:
return null;
}
}
// Map of well-known dependency-count metadata keys → unit i18n root used by
// `unitLabel`. Keys not in this map are skipped from the inline chip strip.
const DEPENDENCY_COUNT_KEYS: Record<string, string> = {
orderCount: "order",
messageCount: "message",
noteCount: "note",
conversationCount: "conversation",
groupCount: "group",
memberCount: "member",
appCount: "app",
roleCount: "role",
openCount: "open",
};
function isForceDeleteEntry(entry: AuditLogEntry): boolean {
const action = entry.action;
if (action.endsWith(".force_delete")) return true;
if (action === "user.delete" || action === "app.delete" || action === "group.delete") {
const meta = asRecord(entry.metadata);
return meta.force === true;
}
return false;
}
function dependencyChips(
entry: AuditLogEntry,
t: AuditTFunction,
): Array<{ key: string; label: string }> {
const meta = asRecord(entry.metadata);
const chips: Array<{ key: string; label: string }> = [];
for (const [metaKey, unitRoot] of Object.entries(DEPENDENCY_COUNT_KEYS)) {
const value = asNumber(meta[metaKey]);
if (value == null || value <= 0) continue;
chips.push({ key: metaKey, label: unitLabel(t, unitRoot, value) });
}
return chips;
}
function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const hasMetadata =
entry.metadata != null && Object.keys(entry.metadata as object).length > 0;
const actor = entry.actor;
const initial = (actorLabel(actor, lang)[0] ?? "?").toUpperCase();
const summary = formatAuditSummary(entry, t, lang);
const isForced = isForceDeleteEntry(entry);
const chips = isForced ? dependencyChips(entry, t) : [];
return (
<div
className="glass-panel rounded-2xl p-3 space-y-2"
data-testid={`audit-row-${entry.id}`}
>
<div className="flex items-start gap-3">
<LeaderboardAvatar
src={actor?.avatarUrl ?? null}
initial={initial}
alt={actor?.username ?? ""}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="font-medium text-sm text-foreground truncate">
{actorLabel(actor, lang)}
</span>
{actor && (
<span className="text-xs text-muted-foreground">
@{actor.username}
</span>
)}
</div>
{summary ? (
<div
className="text-sm text-foreground mt-1 leading-snug"
data-testid={`audit-summary-${entry.id}`}
>
{summary}
</div>
) : (
<div className="text-xs text-muted-foreground mt-1">
{t("admin.audit.target", {
type: entry.targetType,
id: entry.targetId ?? "—",
})}
</div>
)}
<div className="flex flex-wrap items-center gap-1.5 mt-1">
<span
className={cn(
"inline-block text-[11px] font-mono px-2 py-0.5 rounded-md",
isForced
? "bg-rose-100 text-rose-800"
: "bg-amber-100 text-amber-800",
)}
data-testid={`audit-action-${entry.id}`}
>
{entry.action}
</span>
<span className="text-xs text-muted-foreground">
{formatAuditTimestamp(entry.createdAt, lang)}
</span>
</div>
{chips.length > 0 && (
<div
className="flex flex-wrap items-center gap-1.5 mt-1.5"
data-testid={`audit-deps-${entry.id}`}
aria-label={t("admin.audit.dependencies.label")}
>
{chips.map((chip) => (
<span
key={chip.key}
className="inline-block text-[11px] px-2 py-0.5 rounded-md bg-rose-50 text-rose-700 border border-rose-100"
data-testid={`audit-dep-${entry.id}-${chip.key}`}
>
{chip.label}
</span>
))}
</div>
)}
</div>
{hasMetadata && (
<button
onClick={() => setExpanded((v) => !v)}
className="p-1.5 rounded-lg hover:bg-slate-100 text-muted-foreground hover:text-foreground shrink-0"
aria-expanded={expanded}
data-testid={`audit-toggle-${entry.id}`}
>
<ChevronDown
size={16}
className={cn("transition-transform", expanded ? "rotate-180" : "")}
/>
</button>
)}
</div>
{expanded && hasMetadata && (
<pre
className="text-[11px] bg-slate-100/80 rounded-lg p-2 overflow-x-auto leading-snug whitespace-pre-wrap break-all"
dir="ltr"
data-testid={`audit-metadata-${entry.id}`}
>
{JSON.stringify(entry.metadata, null, 2)}
</pre>
)}
</div>
);
}
function AuditLogPanel() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const [actionFilter, setActionFilter] = useState<string>("");
const [forcedOnly, setForcedOnly] = useState<boolean>(false);
const [fromInput, setFromInput] = useState<string>("");
const [toInput, setToInput] = useState<string>("");
const [appliedFrom, setAppliedFrom] = useState<string>("");
const [appliedTo, setAppliedTo] = useState<string>("");
const [pageLimit, setPageLimit] = useState<number>(50);
const [exporting, setExporting] = useState<boolean>(false);
const [exportError, setExportError] = useState<string | null>(null);
const fromValid = !appliedFrom || DATE_RE.test(appliedFrom);
const toValid = !appliedTo || DATE_RE.test(appliedTo);
const orderValid =
!appliedFrom || !appliedTo || appliedFrom <= appliedTo;
const filtersValid = fromValid && toValid && orderValid;
const params: ListAuditLogsParams = {
limit: pageLimit,
offset: 0,
...(forcedOnly
? { forcedOnly: true }
: actionFilter
? { action: actionFilter }
: {}),
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
};
const queryKey = getListAuditLogsQueryKey(params);
const { data, isLoading, isFetching } = useListAuditLogs(params, {
query: { queryKey, enabled: filtersValid },
});
const inputErrors: string[] = [];
if (!fromValid || !toValid) inputErrors.push(t("admin.audit.errors.invalidDate"));
else if (!orderValid) inputErrors.push(t("admin.audit.errors.invertedDates"));
const knownActions = data?.actions ?? [];
const entries = data?.entries ?? [];
const totalCount = data?.totalCount ?? 0;
const showingCount = entries.length;
const hasMore = data?.nextOffset != null;
const applyFilters = () => {
setAppliedFrom(fromInput.trim());
setAppliedTo(toInput.trim());
setPageLimit(50);
};
const resetFilters = () => {
setActionFilter("");
setForcedOnly(false);
setFromInput("");
setToInput("");
setAppliedFrom("");
setAppliedTo("");
setPageLimit(50);
};
const toggleForcedOnly = () => {
setForcedOnly((v) => {
const next = !v;
if (next) setActionFilter("");
setPageLimit(50);
return next;
});
};
const loadMore = () => setPageLimit((n) => Math.min(n + 50, 200));
const handleExportCsv = async () => {
if (!filtersValid || exporting) return;
setExportError(null);
setExporting(true);
try {
const url = getExportAuditLogsCsvUrl({
...(forcedOnly
? { forcedOnly: true }
: actionFilter
? { action: actionFilter }
: {}),
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
});
const res = await fetch(url, {
method: "GET",
credentials: "include",
headers: { Accept: "text/csv" },
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const blob = await res.blob();
const stamp = new Date().toISOString().slice(0, 10);
const filename = `audit-log-${stamp}.csv`;
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// Defer revoke so the browser can finish the download.
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
} catch (err) {
setExportError(
err instanceof Error ? err.message : t("admin.audit.export.failed"),
);
} finally {
setExporting(false);
}
};
return (
<div className="space-y-3" data-testid="audit-log-panel">
<div className="glass-panel rounded-2xl p-3 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={toggleForcedOnly}
aria-pressed={forcedOnly}
title={t("admin.audit.filters.forcedOnlyHint")}
data-testid="audit-forced-only-chip"
className={cn(
"inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border transition-colors",
forcedOnly
? "bg-rose-100 text-rose-800 border-rose-200"
: "bg-white/70 text-foreground border-slate-200 hover:bg-slate-50",
)}
>
<Trash2 size={12} />
{t("admin.audit.filters.forcedOnly")}
</button>
</div>
<div className="grid gap-2 sm:grid-cols-2 md:grid-cols-4">
<div className="space-y-1">
<Label className="text-xs">{t("admin.audit.filters.action")}</Label>
<select
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
disabled={forcedOnly}
className={cn(
"bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full",
forcedOnly && "opacity-60 cursor-not-allowed",
)}
data-testid="audit-action-filter"
>
<option value="">{t("admin.audit.filters.allActions")}</option>
{knownActions.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.audit.filters.from")}</Label>
<Input
type="date"
value={fromInput}
onChange={(e) => setFromInput(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid="audit-from-input"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.audit.filters.to")}</Label>
<Input
type="date"
value={toInput}
onChange={(e) => setToInput(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid="audit-to-input"
/>
</div>
<div className="flex items-end gap-2">
<Button
size="sm"
onClick={applyFilters}
className="flex-1"
data-testid="audit-apply-filters"
>
{t("admin.audit.filters.apply")}
</Button>
<Button
size="sm"
variant="outline"
onClick={resetFilters}
data-testid="audit-reset-filters"
>
{t("admin.audit.filters.reset")}
</Button>
</div>
</div>
<div className="flex items-center justify-end">
<Button
size="sm"
variant="outline"
onClick={handleExportCsv}
disabled={!filtersValid || exporting}
className="gap-1.5"
data-testid="audit-export-csv"
>
{exporting ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
{t("admin.audit.export.button")}
</Button>
</div>
{inputErrors.map((msg, i) => (
<p key={i} className="text-xs text-destructive">
{msg}
</p>
))}
{exportError && (
<p className="text-xs text-destructive" data-testid="audit-export-error">
{t("admin.audit.export.failed")}
</p>
)}
<div className="text-xs text-muted-foreground">
{filtersValid
? t("admin.audit.showing", {
shown: showingCount,
total: totalCount,
})
: t("admin.audit.errors.fixFiltersFirst")}
</div>
</div>
{isLoading ? (
<div className="flex justify-center py-8 text-muted-foreground">
<Loader2 size={18} className="animate-spin" />
</div>
) : entries.length === 0 ? (
<div className="glass-panel rounded-2xl p-6 text-center text-sm text-muted-foreground">
{t("admin.audit.empty")}
</div>
) : (
<div className="space-y-2">
{entries.map((entry) => (
<AuditLogRow key={entry.id} entry={entry} lang={lang} />
))}
</div>
)}
{hasMore && (
<div className="flex justify-center pt-1">
<Button
size="sm"
variant="outline"
disabled={pageLimit >= 200 || isFetching}
onClick={loadMore}
data-testid="audit-load-more"
>
{isFetching ? (
<Loader2 size={14} className="animate-spin" />
) : (
t("admin.audit.loadMore")
)}
</Button>
</div>
)}
</div>
);
}