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.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 50672f01-2472-4869-a234-ecc811a41e0b
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/eDmI6vt
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
riyadhafraa
2026-05-19 12:11:54 +00:00
parent d17587e096
commit 0bcb2cd28f
3 changed files with 138 additions and 19 deletions
+4 -1
View File
@@ -1526,7 +1526,10 @@
"filter": {
"from": "من",
"to": "إلى",
"actorId": "معرّف المنفّذ"
"actorId": "معرّف المنفّذ",
"actorPlaceholder": "كل المستخدمين",
"actorSearch": "ابحث بالاسم...",
"actorEmpty": "ما فيه نتائج"
},
"pageInfo": "عرض",
"all": "الكل",
+4 -1
View File
@@ -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",
+130 -17
View File
@@ -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">