e0c586c0e2
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
5183 lines
197 KiB
TypeScript
5183 lines
197 KiB
TypeScript
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>
|
||
);
|
||
}
|