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:
@@ -1526,7 +1526,10 @@
|
|||||||
"filter": {
|
"filter": {
|
||||||
"from": "من",
|
"from": "من",
|
||||||
"to": "إلى",
|
"to": "إلى",
|
||||||
"actorId": "معرّف المنفّذ"
|
"actorId": "معرّف المنفّذ",
|
||||||
|
"actorPlaceholder": "كل المستخدمين",
|
||||||
|
"actorSearch": "ابحث بالاسم...",
|
||||||
|
"actorEmpty": "ما فيه نتائج"
|
||||||
},
|
},
|
||||||
"pageInfo": "عرض",
|
"pageInfo": "عرض",
|
||||||
"all": "الكل",
|
"all": "الكل",
|
||||||
|
|||||||
@@ -1399,7 +1399,10 @@
|
|||||||
"filter": {
|
"filter": {
|
||||||
"from": "From",
|
"from": "From",
|
||||||
"to": "To",
|
"to": "To",
|
||||||
"actorId": "Actor ID"
|
"actorId": "Actor ID",
|
||||||
|
"actorPlaceholder": "All users",
|
||||||
|
"actorSearch": "Search by name...",
|
||||||
|
"actorEmpty": "No matches"
|
||||||
},
|
},
|
||||||
"pageInfo": "Showing",
|
"pageInfo": "Showing",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
|
|||||||
@@ -117,7 +117,16 @@ import { GripVertical } from "lucide-react";
|
|||||||
import { EditableCell } from "@/components/editable-cell";
|
import { EditableCell } from "@/components/editable-cell";
|
||||||
import { Composer } from "@/components/notes/composer";
|
import { Composer } from "@/components/notes/composer";
|
||||||
import { SendNoteDialog } from "@/components/notes/send-note-dialog";
|
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
|
// #486: reuse the same dialog the upcoming-alert pops, so postponing
|
||||||
// from the schedule row quick-actions menu shares one implementation.
|
// from the schedule row quick-actions menu shares one implementation.
|
||||||
import { PostponeDialog } from "@/components/executive-meetings/upcoming-meeting-alert";
|
import { PostponeDialog } from "@/components/executive-meetings/upcoming-meeting-alert";
|
||||||
@@ -209,6 +218,24 @@ type AuditEntry = {
|
|||||||
} | null;
|
} | 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 = {
|
type FontPrefs = {
|
||||||
fontFamily: string;
|
fontFamily: string;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
@@ -7933,11 +7960,28 @@ function AuditSection({
|
|||||||
t: (k: string) => string;
|
t: (k: string) => string;
|
||||||
}) {
|
}) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const lang = isRtl ? "ar" : "en";
|
||||||
const [date, setDate] = useState("");
|
const [date, setDate] = useState("");
|
||||||
const [dateTo, setDateTo] = useState("");
|
const [dateTo, setDateTo] = useState("");
|
||||||
const [entityType, setEntityType] = useState("all");
|
const [entityType, setEntityType] = useState("all");
|
||||||
const [actionFilter, setActionFilter] = 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, setPage] = useState(0);
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -7965,7 +8009,7 @@ function AuditSection({
|
|||||||
if (dateTo) qs.set("dateTo", dateTo);
|
if (dateTo) qs.set("dateTo", dateTo);
|
||||||
if (entityType !== "all") qs.set("entityType", entityType);
|
if (entityType !== "all") qs.set("entityType", entityType);
|
||||||
if (actionFilter !== "all") qs.set("action", actionFilter);
|
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("limit", String(PAGE_SIZE));
|
||||||
qs.set("offset", String(page * PAGE_SIZE));
|
qs.set("offset", String(page * PAGE_SIZE));
|
||||||
return apiJson(`/api/executive-meetings/audit-logs?${qs}`);
|
return apiJson(`/api/executive-meetings/audit-logs?${qs}`);
|
||||||
@@ -8003,14 +8047,86 @@ function AuditSection({
|
|||||||
data-testid="em-audit-date-to"
|
data-testid="em-audit-date-to"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Popover open={actorPickerOpen} onOpenChange={setActorPickerOpen}>
|
||||||
type="number"
|
<PopoverTrigger asChild>
|
||||||
placeholder={t("executiveMeetings.audit.filter.actorId")}
|
<button
|
||||||
value={actorFilter}
|
type="button"
|
||||||
onChange={(e) => setActorFilter(e.target.value)}
|
aria-haspopup="listbox"
|
||||||
className="w-32 h-9"
|
aria-expanded={actorPickerOpen}
|
||||||
data-testid="em-audit-actor"
|
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}>
|
<Select value={actionFilter} onValueChange={setActionFilter}>
|
||||||
<SelectTrigger className="w-36 h-9" data-testid="em-audit-action">
|
<SelectTrigger className="w-36 h-9" data-testid="em-audit-action">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -8087,7 +8203,7 @@ function AuditSection({
|
|||||||
>
|
>
|
||||||
<td className="px-3 py-2">{actor}</td>
|
<td className="px-3 py-2">{actor}</td>
|
||||||
<td className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap">
|
<td className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap">
|
||||||
{new Date(e.performedAt).toLocaleString()}
|
{formatAuditDateTime(e.performedAt, lang)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-xs">
|
<td className="px-3 py-2 text-xs">
|
||||||
{tWithFallback(
|
{tWithFallback(
|
||||||
@@ -8145,7 +8261,7 @@ function AuditSection({
|
|||||||
{actor}
|
{actor}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-gray-500 tabular-nums whitespace-nowrap shrink-0">
|
<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>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -8366,10 +8482,7 @@ function PdfSection({
|
|||||||
v{a.version} — {a.archiveDate}
|
v{a.version} — {a.archiveDate}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
{new Date(a.generatedAt).toLocaleString(
|
{formatAuditDateTime(a.generatedAt, lang)} · {who}
|
||||||
lang === "ar" ? "ar" : "en",
|
|
||||||
)}{" "}
|
|
||||||
· {who}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user