Files
jobtrackingapp/job-tracker-ui/src/views/AdminAuditPage.tsx
T
cesnimda acf60c2a07
CI and Deploy / test (pull_request) Failing after 50s
CI and Deploy / deploy (pull_request) Has been skipped
build(frontend): migrate CRA to Next.js (CSR lift-and-shift)
Wave 6. Swaps react-scripts' build/dev tooling for Next.js while
keeping the app's actual routing/rendering model unchanged -- the app
is almost entirely behind auth with no proven SSR/SEO need, so a real
App Router rewrite would touch ~90 files for zero user-visible benefit.

- next.config.js: output:'export' (static HTML+JS, same "single
  index.html served by nginx with try_files fallback" deploy as CRA).
- app/layout.tsx + app/page.tsx: root shell ports public/index.html's
  <head>, mounts the whole existing App tree client-only (ssr:false)
  since it reads window/localStorage during initial render and Next's
  static prerender would otherwise execute that on the server.
- Renamed src/pages/ -> src/views/ (Next's Pages Router auto-detects
  any `pages/` dir under the app root and tried to build our React
  Router page components as its own routes).
- REACT_APP_* -> NEXT_PUBLIC_* across code, .env.development,
  Dockerfile, docker-compose.yml build args.
- Replaced the CRA SVGR import (`ReactComponent` from .svg, unsupported
  under Turbopack) with a small inline JobbjaktMark component.
- TypeScript 4.9 -> 5.9 (MUI v8's type-checked build needs syntax
  4.9's parser rejects; CRA never hit this because babel doesn't
  type-check).
- Dropped CRA-only files (index.tsx, reportWebVitals, react-app-env.d.ts,
  public/index.html); kept react-scripts as the Jest test runner only
  (next/jest migration not needed -- the existing config already works).

Verified: `next build` static export succeeds, `next dev` serves the
landing page and client-side routes (login etc.) correctly, all 57
frontend tests + 172 backend tests still green.

Known caveat: deep-linking straight to a sub-route (e.g. /login) 404s
in `next dev` since there's no server route for it -- the app only
ever mounts at "/". Production is unaffected: nginx's existing
try_files fallback still serves index.html for any path.
2026-07-12 00:50:45 +02:00

184 lines
6.2 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
Button,
Chip,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { api } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
type AuditItem = {
id: number;
type: string;
oldValue?: string | null;
newValue?: string | null;
note?: string | null;
at: string;
jobApplicationId: number;
jobTitle?: string | null;
companyName?: string | null;
ownerUserId?: string | null;
ownerEmail?: string | null;
ownerUserName?: string | null;
};
function canUndo(type: string) {
return ["StatusChanged", "FollowUpSet", "ResponseUpdated", "Deleted", "Restored"].includes(type);
}
export default function AdminAuditPage() {
const { toast } = useToast();
const { t } = useI18n();
const [items, setItems] = useState<AuditItem[]>([]);
const [loading, setLoading] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const r = await api.get<AuditItem[]>("/admin/audit?take=200");
setItems(r.data ?? []);
} catch {
setItems([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const undo = async (e: AuditItem) => {
if (!canUndo(e.type)) return;
setBusyId(e.id);
try {
const res = await api.post<{ ok: boolean; message: string }>(`/admin/audit/${e.id}/undo`, {});
toast(res.data?.message || "Undone.", "success");
await load();
} catch (err: any) {
const msg = err?.response?.data?.message || err?.response?.data || err?.message || "Undo failed.";
toast(String(msg), "error");
} finally {
setBusyId(null);
}
};
const restore = async (e: AuditItem) => {
setBusyId(e.id);
try {
await api.post(`/jobapplications/${e.jobApplicationId}/restore`, {});
toast(t("adminAuditRestored"), "success");
await load();
} catch (err: any) {
const msg = err?.response?.data || err?.message || "Restore failed.";
toast(String(msg), "error");
} finally {
setBusyId(null);
}
};
const rows = useMemo(() => items, [items]);
return (
<Paper sx={{ mt: 0, p: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 1 }}>
{t("adminAuditTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
{t("adminAuditSubtitle")}
</Typography>
<TableContainer sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ width: 165 }}>{t("adminAuditAt")}</TableCell>
<TableCell sx={{ width: 160 }}>{t("adminAuditType")}</TableCell>
<TableCell>{t("adminAuditJob")}</TableCell>
<TableCell sx={{ width: 220 }}>{t("adminAuditUser")}</TableCell>
<TableCell>{t("adminAuditDetails")}</TableCell>
<TableCell sx={{ width: 170 }}>{t("adminAuditActions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6}>
<Typography sx={{ color: "text.secondary" }}>{t("loading")}</Typography>
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={6}>
<Typography sx={{ color: "text.secondary" }}>{t("adminAuditNoEvents")}</Typography>
</TableCell>
</TableRow>
) : (
rows.map((e) => {
const jobLabel = e.jobTitle || `Job #${e.jobApplicationId}`;
const ownerLabel = e.ownerEmail || e.ownerUserName || e.ownerUserId || "-";
const details = e.oldValue || e.newValue ? `${e.oldValue ?? ""} -> ${e.newValue ?? ""}` : "";
const disabled = busyId === e.id;
const showRestore = e.type === "Deleted";
return (
<TableRow key={e.id} hover>
<TableCell>{e.at ? new Date(e.at).toLocaleString() : ""}</TableCell>
<TableCell>
<Chip label={e.type} size="small" variant="outlined" />
</TableCell>
<TableCell>
<Typography sx={{ fontWeight: 800, lineHeight: 1.25 }}>
{e.companyName ? `${e.companyName} - ` : ""}
{jobLabel}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
jobId={e.jobApplicationId}
</Typography>
</TableCell>
<TableCell>
<Typography sx={{ fontWeight: 700 }}>{ownerLabel}</Typography>
</TableCell>
<TableCell>
<Typography sx={{ whiteSpace: "pre-wrap", color: "text.secondary" }}>
{details}
{e.note ? `${details ? "\n" : ""}${e.note}` : ""}
</Typography>
</TableCell>
<TableCell>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{canUndo(e.type) ? (
<Button size="small" variant="outlined" disabled={disabled} onClick={() => void undo(e)}>
Undo
</Button>
) : null}
{showRestore ? (
<Button size="small" variant="contained" disabled={disabled} onClick={() => void restore(e)}>
Restore
</Button>
) : null}
</Box>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
);
}