diff --git a/job-tracker-ui/src/admin-audit-page.test.tsx b/job-tracker-ui/src/admin-audit-page.test.tsx index c1f75d0c..2b221f6f 100644 --- a/job-tracker-ui/src/admin-audit-page.test.tsx +++ b/job-tracker-ui/src/admin-audit-page.test.tsx @@ -56,6 +56,31 @@ test("searches loaded audit metadata without querying the API or matching notes" expect(mockedApi.post).not.toHaveBeenCalled(); }); +test("combines exact event type and inclusive local calendar dates without a new request", async () => { + const endOfDay = new Date(2026, 9, 20, 23, 59, 59, 999).toISOString(); + const nextDay = new Date(2026, 9, 21).toISOString(); + mockedApi.get.mockResolvedValue({ data: [ + { ...row, at: endOfDay, jobTitle: "Teacher" }, + { ...row, id: 8, at: nextDay, jobTitle: "Nurse" }, + { ...row, id: 9, at: endOfDay, jobTitle: "Driver", type: "StatusChanged" }, + ] } as any); + renderPage(); + await screen.findByText("Teacher"); + fireEvent.change(screen.getByRole("combobox", { name: "Event type" }), { target: { value: "Deleted" } }); + const from = screen.getByLabelText("From date"); + const through = screen.getByLabelText("Through date"); + fireEvent.change(from, { target: { value: "2026-10-20" } }); + fireEvent.change(through, { target: { value: "2026-10-20" } }); + expect(screen.getByText("Teacher")).toBeVisible(); + expect(screen.queryByText("Nurse")).not.toBeInTheDocument(); + expect(screen.queryByText("Driver")).not.toBeInTheDocument(); + fireEvent.change(through, { target: { value: "2026-10-19" } }); + expect(through).toBeInvalid(); + expect(through).toHaveAccessibleDescription("The end date must be on or after the start date."); + expect(screen.queryByText("Teacher")).not.toBeInTheDocument(); + expect(mockedApi.get).toHaveBeenCalledTimes(1); +}); + test("[P7-10 audit] retains shared heading hierarchy and a named keyboard scroll region", async () => { mockedApi.get.mockResolvedValue({ data: [row] } as any); renderPage(); @@ -153,5 +178,7 @@ test("localizes audit actions in Bokmål without translating stored event data", expect(await screen.findByRole("button", { name: "Angre" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Gjenopprett" })).toBeInTheDocument(); expect(screen.getByText("Example AS - Jobb #42")).toBeInTheDocument(); - expect(screen.getByText("User-authored note")).toBeInTheDocument(); + expect(screen.getByText("User-authored note")).not.toBeVisible(); + fireEvent.click(screen.getByText("Hendelsesdetaljer")); + expect(screen.getByText("User-authored note")).toBeVisible(); }); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 9c2af0e2..7083e834 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -2625,6 +2625,14 @@ export const translations = { adminAuditSearch: "Search recent audit events", adminAuditSearchScope: "Searches up to 200 loaded events by user, event type, application ID, title or company.", adminAuditNoMatches: "No loaded events match your search.", + adminAuditEventType: "Event type", + adminAuditAllEventTypes: "All event types", + adminAuditFromDate: "From date", + adminAuditThroughDate: "Through date", + adminAuditInvalidRange: "The end date must be on or after the start date.", + adminAuditEventDetails: "Event details", + adminAuditPreviousValue: "Previous value", + adminAuditNewValue: "New value", adminSystemEnvironment: "Environment", adminSystemStatusTab: "Status", adminSystemSettingsTab: "Settings", @@ -5858,6 +5866,14 @@ export const translations = { adminAuditSearch: "Søk i nylige revisjonshendelser", adminAuditSearchScope: "Søker i opptil 200 innlastede hendelser etter bruker, hendelsestype, søknads-ID, stillingstittel eller virksomhet.", adminAuditNoMatches: "Ingen innlastede hendelser samsvarer med søket.", + adminAuditEventType: "Hendelsestype", + adminAuditAllEventTypes: "Alle hendelsestyper", + adminAuditFromDate: "Fra dato", + adminAuditThroughDate: "Til og med dato", + adminAuditInvalidRange: "Sluttdatoen må være på eller etter startdatoen.", + adminAuditEventDetails: "Hendelsesdetaljer", + adminAuditPreviousValue: "Tidligere verdi", + adminAuditNewValue: "Ny verdi", adminSystemEnvironment: "Miljø", adminSystemStatusTab: "Status", adminSystemSettingsTab: "Innstillinger", diff --git a/job-tracker-ui/src/views/AdminAuditPage.tsx b/job-tracker-ui/src/views/AdminAuditPage.tsx index 6898127f..e5d7cd84 100644 --- a/job-tracker-ui/src/views/AdminAuditPage.tsx +++ b/job-tracker-ui/src/views/AdminAuditPage.tsx @@ -46,6 +46,9 @@ export default function AdminAuditPage() { const { language, t } = useI18n(); const [busyId, setBusyId] = useState(null); const [search, setSearch] = useState(""); + const [eventType, setEventType] = useState(""); + const [fromDate, setFromDate] = useState(""); + const [throughDate, setThroughDate] = useState(""); const body=useRef(null); const owner=useRef({ mounted: true, generation: 0, busy: false }); const currentTranslation=useRef(t); @@ -84,17 +87,29 @@ export default function AdminAuditPage() { }; const query = search.trim().toLowerCase(); - const rows = query ? auditResource.data.filter(e => - String(e.jobApplicationId) === query || - [e.type, e.jobTitle, e.companyName, e.ownerUserId, e.ownerEmail, e.ownerUserName] - .some(value => value?.toLowerCase().includes(query)) - ) : auditResource.data; + const eventTypes = [...new Set(auditResource.data.map(event => event.type))].sort(); + if (eventType && !eventTypes.includes(eventType)) eventTypes.push(eventType); + const fromTime = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : -Infinity; + const throughTime = throughDate ? new Date(`${throughDate}T23:59:59.999`).getTime() : Infinity; + const invalidRange = Boolean(fromDate && throughDate && fromDate > throughDate); + const filtering = Boolean(query || eventType || fromDate || throughDate); + const rows = filtering ? auditResource.data.filter(e => { + if (eventType && e.type !== eventType) return false; + if (fromDate || throughDate) { + const time = Date.parse(e.at); + if (!(time >= fromTime && time <= throughTime)) return false; + } + return !query || String(e.jobApplicationId) === query || + [e.type, e.jobTitle, e.companyName, e.ownerUserId, e.ownerEmail, e.ownerUserName] + .some(value => value?.toLowerCase().includes(query)); + }) : auditResource.data; const error=auditResource.error?.message===fallback.current ? { ...auditResource.error, message: t("adminAuditLoadFailed") } : auditResource.error; return ( {!auditResource.error && auditResource.data.length > 0 && ( + setSearch(event.target.value)} - sx={{ width: "100%", maxWidth: 480, mb: 2 }} + sx={{ width: "100%", maxWidth: 480 }} /> + + setEventType(event.target.value)} slotProps={{ select: { native: true }, inputLabel: { shrink: true } }}> + + {eventTypes.map(type => )} + + setFromDate(event.target.value)} slotProps={{ inputLabel: { shrink: true } }} /> + setThroughDate(event.target.value)} slotProps={{ inputLabel: { shrink: true } }} error={invalidRange} helperText={invalidRange ? t("adminAuditInvalidRange") : undefined} /> + + )} @@ -115,8 +139,8 @@ export default function AdminAuditPage() { onRetry={async () => { body.current?.focus(); await auditResource.reload(); }} /> - {!auditResource.error && query && auditResource.data.length > 0 && rows.length === 0 ? ( - {t("adminAuditNoMatches")} + {!auditResource.error && filtering && auditResource.data.length > 0 && rows.length === 0 ? ( + !invalidRange && {t("adminAuditNoMatches")} ) : !auditResource.error ? @@ -146,7 +170,6 @@ export default function AdminAuditPage() { rows.map((e) => { const jobLabel = e.jobTitle || t("adminAuditJobFallback", { id: e.jobApplicationId }); const ownerLabel = e.ownerEmail || e.ownerUserName || e.ownerUserId || "-"; - const details = e.oldValue || e.newValue ? `${e.oldValue ?? ""} -> ${e.newValue ?? ""}` : ""; const disabled = busyId !== null; const showRestore = e.type === "Deleted"; @@ -169,10 +192,14 @@ export default function AdminAuditPage() { {ownerLabel} - - {details} - {e.note ? `${details ? "\n" : ""}${e.note}` : ""} - + {e.oldValue || e.newValue || e.note ? ( + + {t("adminAuditEventDetails")} + {e.oldValue && {t("adminAuditPreviousValue")} {e.oldValue}} + {e.newValue && {t("adminAuditNewValue")} {e.newValue}} + {e.note && {e.note}} + + ) : "—"}