Improve audit log filtering with a searchable user list and better date formatting
Introduces a combobox for filtering audit logs by actor, replaces multiple date formatting calls with a dedicated helper function, and updates locale files for new filter UI elements.
This commit is contained in:
@@ -1526,7 +1526,10 @@
|
||||
"filter": {
|
||||
"from": "من",
|
||||
"to": "إلى",
|
||||
"actorId": "معرّف المنفّذ"
|
||||
"actorId": "معرّف المنفّذ",
|
||||
"actorPlaceholder": "كل المستخدمين",
|
||||
"actorSearch": "ابحث بالاسم...",
|
||||
"actorEmpty": "ما فيه نتائج"
|
||||
},
|
||||
"pageInfo": "عرض",
|
||||
"all": "الكل",
|
||||
|
||||
@@ -1399,7 +1399,10 @@
|
||||
"filter": {
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"actorId": "Actor ID"
|
||||
"actorId": "Actor ID",
|
||||
"actorPlaceholder": "All users",
|
||||
"actorSearch": "Search by name...",
|
||||
"actorEmpty": "No matches"
|
||||
},
|
||||
"pageInfo": "Showing",
|
||||
"all": "All",
|
||||
|
||||
@@ -117,7 +117,16 @@ import { GripVertical } from "lucide-react";
|
||||
import { EditableCell } from "@/components/editable-cell";
|
||||
import { Composer } from "@/components/notes/composer";
|
||||
import { SendNoteDialog } from "@/components/notes/send-note-dialog";
|
||||
import { useNoteLabels } from "@/lib/notes-api";
|
||||
import { useNoteLabels, type UserSummary } from "@/lib/notes-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
// #486: reuse the same dialog the upcoming-alert pops, so postponing
|
||||
// from the schedule row quick-actions menu shares one implementation.
|
||||
import { PostponeDialog } from "@/components/executive-meetings/upcoming-meeting-alert";
|
||||
@@ -209,6 +218,24 @@ type AuditEntry = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
// #616-followup: localized 12h timestamp for the audit log + PDF archive
|
||||
// listing. Format: dd/mm/yyyy h:mm <ص|م> in Arabic, or h:mm AM/PM in
|
||||
// English. Seconds dropped to keep rows compact, day before month so
|
||||
// Arabic readers don't have to mentally re-order the US m/d/y default.
|
||||
function formatAuditDateTime(iso: string, lang: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const yyyy = d.getFullYear();
|
||||
let h = d.getHours();
|
||||
const min = String(d.getMinutes()).padStart(2, "0");
|
||||
const isPm = h >= 12;
|
||||
h = h % 12 || 12;
|
||||
const suffix = lang === "ar" ? (isPm ? "م" : "ص") : isPm ? "PM" : "AM";
|
||||
return `${dd}/${mm}/${yyyy} ${h}:${min} ${suffix}`;
|
||||
}
|
||||
|
||||
type FontPrefs = {
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
@@ -7933,11 +7960,28 @@ function AuditSection({
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const lang = isRtl ? "ar" : "en";
|
||||
const [date, setDate] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [entityType, setEntityType] = useState("all");
|
||||
const [actionFilter, setActionFilter] = useState("all");
|
||||
const [actorFilter, setActorFilter] = useState("");
|
||||
// #616-followup: actor filter is now a user id picked from a searchable
|
||||
// combobox of active users (replaces the old free-form numeric input —
|
||||
// no one memorises raw user IDs). Empty string = no filter.
|
||||
const [actorFilter, setActorFilter] = useState<number | "">("");
|
||||
const [actorPickerOpen, setActorPickerOpen] = useState(false);
|
||||
const { data: directory } = useQuery<UserSummary[]>({
|
||||
queryKey: ["users", "directory"],
|
||||
queryFn: () => apiJson<UserSummary[]>("/api/users/directory"),
|
||||
enabled: canView,
|
||||
});
|
||||
const actors = directory ?? [];
|
||||
const selectedActor =
|
||||
actorFilter !== "" ? actors.find((u) => u.id === actorFilter) ?? null : null;
|
||||
const actorDisplay = (u: UserSummary) =>
|
||||
(lang === "ar"
|
||||
? u.displayNameAr ?? u.displayNameEn
|
||||
: u.displayNameEn ?? u.displayNameAr) ?? u.username;
|
||||
const [page, setPage] = useState(0);
|
||||
const PAGE_SIZE = 50;
|
||||
useEffect(() => {
|
||||
@@ -7965,7 +8009,7 @@ function AuditSection({
|
||||
if (dateTo) qs.set("dateTo", dateTo);
|
||||
if (entityType !== "all") qs.set("entityType", entityType);
|
||||
if (actionFilter !== "all") qs.set("action", actionFilter);
|
||||
if (actorFilter.trim()) qs.set("actorId", actorFilter.trim());
|
||||
if (actorFilter !== "") qs.set("actorId", String(actorFilter));
|
||||
qs.set("limit", String(PAGE_SIZE));
|
||||
qs.set("offset", String(page * PAGE_SIZE));
|
||||
return apiJson(`/api/executive-meetings/audit-logs?${qs}`);
|
||||
@@ -8003,14 +8047,86 @@ function AuditSection({
|
||||
data-testid="em-audit-date-to"
|
||||
/>
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("executiveMeetings.audit.filter.actorId")}
|
||||
value={actorFilter}
|
||||
onChange={(e) => setActorFilter(e.target.value)}
|
||||
className="w-32 h-9"
|
||||
data-testid="em-audit-actor"
|
||||
/>
|
||||
<Popover open={actorPickerOpen} onOpenChange={setActorPickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={actorPickerOpen}
|
||||
data-testid="em-audit-actor"
|
||||
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white h-9 w-44 text-start flex items-center justify-between gap-2 hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
selectedActor ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{selectedActor
|
||||
? actorDisplay(selectedActor)
|
||||
: t("executiveMeetings.audit.filter.actorPlaceholder")}
|
||||
</span>
|
||||
<ChevronDown size={14} className="shrink-0 opacity-60" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[260px] p-0" align="start">
|
||||
<Command
|
||||
filter={(value, search) => {
|
||||
if (!search) return 1;
|
||||
return value.toLowerCase().includes(search.toLowerCase())
|
||||
? 1
|
||||
: 0;
|
||||
}}
|
||||
>
|
||||
<CommandInput
|
||||
placeholder={t("executiveMeetings.audit.filter.actorSearch")}
|
||||
data-testid="em-audit-actor-search"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("executiveMeetings.audit.filter.actorEmpty")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__any__"
|
||||
onSelect={() => {
|
||||
setActorFilter("");
|
||||
setActorPickerOpen(false);
|
||||
}}
|
||||
data-testid="em-audit-actor-option-any"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{t("executiveMeetings.audit.filter.actorPlaceholder")}
|
||||
</span>
|
||||
</CommandItem>
|
||||
{actors.map((u) => {
|
||||
const display = actorDisplay(u);
|
||||
return (
|
||||
<CommandItem
|
||||
key={u.id}
|
||||
value={`${display} ${u.username} @${u.username}`}
|
||||
onSelect={() => {
|
||||
setActorFilter(u.id);
|
||||
setActorPickerOpen(false);
|
||||
}}
|
||||
data-testid={`em-audit-actor-option-${u.id}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-foreground truncate">
|
||||
{display}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
@{u.username}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Select value={actionFilter} onValueChange={setActionFilter}>
|
||||
<SelectTrigger className="w-36 h-9" data-testid="em-audit-action">
|
||||
<SelectValue />
|
||||
@@ -8087,7 +8203,7 @@ function AuditSection({
|
||||
>
|
||||
<td className="px-3 py-2">{actor}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap">
|
||||
{new Date(e.performedAt).toLocaleString()}
|
||||
{formatAuditDateTime(e.performedAt, lang)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{tWithFallback(
|
||||
@@ -8145,7 +8261,7 @@ function AuditSection({
|
||||
{actor}
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-500 tabular-nums whitespace-nowrap shrink-0">
|
||||
{new Date(e.performedAt).toLocaleString()}
|
||||
{formatAuditDateTime(e.performedAt, lang)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -8366,10 +8482,7 @@ function PdfSection({
|
||||
v{a.version} — {a.archiveDate}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(a.generatedAt).toLocaleString(
|
||||
lang === "ar" ? "ar" : "en",
|
||||
)}{" "}
|
||||
· {who}
|
||||
{formatAuditDateTime(a.generatedAt, lang)} · {who}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user