feat(documents): add owner-scoped document workspace

This commit is contained in:
cesnimda
2026-10-16 18:22:19 +02:00
parent 8e4db121ae
commit 5a67ec3d3d
19 changed files with 4932 additions and 9 deletions
+299
View File
@@ -0,0 +1,299 @@
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { Link as RouterLink, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { Alert, Autocomplete, Box, Button, Chip, FormControl, InputLabel, MenuItem, Pagination, Paper, Select, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography, useMediaQuery, useTheme } from "@mui/material";
import RefreshIcon from "@mui/icons-material/Refresh";
import { api } from "../api";
import ViewStateNotice from "../components/ViewStateNotice";
import type { ViewResourceError } from "../hooks/useViewResource";
import { useI18n } from "../i18n/I18nProvider";
import type { TranslationKey } from "../i18n/translations";
import { PageHeader } from "../layout/PageLayout";
type DocumentItem = {
id: string; resourceId: string; type: string; name: string; state: string; visibility: string | null;
jobApplicationId: number | null; jobTitle: string | null; companyName: string | null; jobIsDeleted: boolean | null;
recordedAt: string | null; dateKind: string; version: number | null; historyCount: number | null;
attachmentCount: number | null; cvVariantId: number | null; cvVariantName: string | null; cvVariantVersion: number | null;
fileType: string | null; fileSize: number | null; purpose: string | null; useForAi: boolean | null;
provider: string | null; sourceHref: string; downloadHref: string | null; publicHref: string | null;
hasCurrentContent: boolean | null;
};
type DocumentsResult = { items: DocumentItem[]; page: number; pageSize: number; total: number; totalPages: number };
type JobChoice = { id: number; jobTitle: string; companyName: string };
type DownloadState = { id: string; busy: boolean; status?: number };
const types: Array<[string, TranslationKey]> = [["all", "documentsAllTypes"], ["cv", "documentsTypeCv"], ["cover-letter", "documentsTypeLetter"], ["application-material", "documentsTypeMaterial"], ["tailored-cv", "documentsTypeTailoredCv"], ["attachment", "documentsTypeAttachment"], ["submitted-package", "documentsTypePackage"], ["email-draft", "documentsTypeEmailDraft"]];
const states: Array<[string, TranslationKey]> = [["all", "documentsAllStates"], ["current", "documentsCurrent"], ["draft", "documentsDraft"], ["frozen", "documentsFrozen"]];
const visibilityOptions: Array<[string, TranslationKey]> = [["all", "documentsAllVisibility"], ["private", "documentsPrivate"], ["public", "documentsPublic"]];
const sorts: Array<[string, TranslationKey]> = [["date-desc", "documentsSortDateDesc"], ["date-asc", "documentsSortDateAsc"], ["name-asc", "documentsSortNameAsc"], ["name-desc", "documentsSortNameDesc"]];
const option = (value: string | null, choices: Array<[string, TranslationKey]>, fallback = "all") => choices.some(([key]) => key === value) ? value! : fallback;
const positiveInteger = (value: string | null, maximum = Number.MAX_SAFE_INTEGER) => value && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) && Number(value) > 0 && Number(value) <= maximum ? Number(value) : undefined;
const errorState = (error: any): ViewResourceError => ({ kind: [401, 403].includes(error?.response?.status) ? "unauthorized" : "error", status: error?.response?.status, message: "", retryable: ![401, 403].includes(error?.response?.status) });
function sourcePath(item: DocumentItem) {
if (item.id !== `${item.type}:${item.resourceId}`) return null;
if (item.type === "email-draft") return /^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i.test(item.resourceId) && item.sourceHref === "/correspondence" ? item.sourceHref : null;
if (!/^[1-9]\d*$/.test(item.resourceId)) return null;
const job = item.jobApplicationId;
const jobPath = Number.isSafeInteger(job) && Number(job) > 0 ? `/jobs/${job}` : null;
const expected = item.type === "cv" ? `/career/builder/${item.resourceId}`
: item.type === "attachment" ? jobPath && `${jobPath}?section=documents`
: item.type === "cover-letter" ? jobPath && `${jobPath}?section=application&sub=letter`
: item.type === "application-material" ? jobPath && `${jobPath}?section=application&sub=answers`
: item.type === "tailored-cv" ? jobPath && `${jobPath}?section=application&sub=cv`
: item.type === "submitted-package" ? jobPath && `${jobPath}?section=application&sub=submission` : null;
return expected && item.sourceHref === expected ? expected : null;
}
function publicPath(item: DocumentItem) {
if (!sourcePath(item) || item.type !== "cv" || item.visibility !== "public" || !/^\/cv\/(?:[\w.~\-]|%[\da-f]{2})+$/i.test(item.publicHref ?? "")) return null;
try {
const slug = decodeURIComponent(item.publicHref!.slice(4));
return slug && slug !== "." && slug !== ".." ? item.publicHref : null;
} catch { return null; }
}
export default function DocumentsPage() {
const { language, t } = useI18n();
const location = useLocation();
const navigate = useNavigate();
const [params] = useSearchParams();
const theme = useTheme();
const desktop = useMediaQuery(theme.breakpoints.up("md"));
const labelId = useId();
const q = (params.get("q") ?? "").slice(0, 200);
const search = q.trim();
const type = option(params.get("type"), types);
const state = option(params.get("state"), states);
const visibility = option(params.get("visibility"), visibilityOptions);
const sort = option(params.get("sort"), sorts, "date-desc");
const page = positiveInteger(params.get("page"), 10000) ?? 1;
// React Compiler needs a primitive dependency after the shared ID validator.
const jobApplicationId = Number(positiveInteger(params.get("jobApplicationId"), 2147483647)) || undefined;
const key = JSON.stringify([search, type, state, visibility, sort, page, jobApplicationId]);
const currentKey = useRef(key);
currentKey.current = key;
const [result, setResult] = useState<DocumentsResult | null>(null);
const [loadedKey, setLoadedKey] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<ViewResourceError | null>(null);
const request = useRef(0);
const pendingKey = useRef<string | null>(null);
const listController = useRef<AbortController | null>(null);
const mounted = useRef(true);
const resultsRef = useRef<HTMLDivElement>(null);
const [downloadState, setDownloadState] = useState<DownloadState | null>(null);
const downloadController = useRef<AbortController | null>(null);
const downloadFocus = useRef<{ button: HTMLButtonElement; key: string } | null>(null);
const [jobsOpen, setJobsOpen] = useState(false);
const [jobQuery, setJobQuery] = useState("");
const [jobs, setJobs] = useState<JobChoice[]>([]);
const [jobsKey, setJobsKey] = useState<string | null>(null);
const [jobsLoading, setJobsLoading] = useState(false);
const [jobsError, setJobsError] = useState<ViewResourceError | null>(null);
const jobsRequest = useRef(0);
const jobsPending = useRef<string | null>(null);
const queryEdits = useRef({ location, params });
const updateQuery = (changes: Record<string, string>, replace = false) => {
const next = new URLSearchParams(queryEdits.current.location === location ? queryEdits.current.params : params);
for (const [name, value] of Object.entries(changes)) {
if (!value || (value === "all" && ["type", "state", "visibility"].includes(name)) || (name === "page" && value === "1")) next.delete(name);
else next.set(name, value);
}
queryEdits.current = { location, params: next };
navigate({ pathname: location.pathname, search: next.size ? `?${next}` : "", hash: location.hash }, { state: location.state, replace });
};
const load = useCallback(async () => {
if (!mounted.current || pendingKey.current === key) return;
const sequence = ++request.current;
pendingKey.current = key;
listController.current?.abort();
const controller = new AbortController();
listController.current = controller;
setLoading(true);
setError(null);
try {
const response = await api.get<DocumentsResult>("/documents", { params: { page, pageSize: 25, q: search || undefined, type, state, visibility, sort, ...(jobApplicationId ? { jobApplicationId } : {}) }, signal: controller.signal });
if (!mounted.current || sequence !== request.current || key !== currentKey.current) return;
setResult(response.data);
setLoadedKey(key);
} catch (failure) {
if (mounted.current && sequence === request.current && !controller.signal.aborted) setError(errorState(failure));
} finally {
if (mounted.current && sequence === request.current) { pendingKey.current = null; setLoading(false); }
}
}, [jobApplicationId, key, page, search, sort, state, type, visibility]);
useEffect(() => {
mounted.current = true;
return () => { mounted.current = false; request.current += 1; jobsRequest.current += 1; listController.current?.abort(); downloadController.current?.abort(); };
}, []);
useEffect(() => {
setDownloadState(null);
downloadController.current?.abort();
downloadController.current = null;
void load();
return () => { request.current += 1; pendingKey.current = null; listController.current?.abort(); };
}, [load]);
useEffect(() => {
const target = downloadFocus.current;
if (downloadState?.busy || !target) return;
downloadFocus.current = null;
if (document.activeElement === resultsRef.current && target.key === currentKey.current && target.button.isConnected && !target.button.disabled) target.button.focus();
}, [downloadState]);
const loadJobs = useCallback(async () => {
const search = jobQuery.trim();
if (!mounted.current || jobsPending.current === search) return;
const sequence = ++jobsRequest.current;
jobsPending.current = search;
setJobsLoading(true);
setJobsError(null);
try {
const response = await api.get<JobChoice[]>("/jobapplications/choices", { params: { q: search || undefined, limit: 20 } });
if (mounted.current && sequence === jobsRequest.current) { setJobs(response.data); setJobsKey(search); }
} catch (failure) {
if (mounted.current && sequence === jobsRequest.current) setJobsError(errorState(failure));
} finally {
if (sequence === jobsRequest.current) { jobsPending.current = null; if (mounted.current) setJobsLoading(false); }
}
}, [jobQuery]);
useEffect(() => {
if (!jobsOpen) return;
const timer = window.setTimeout(() => void loadJobs(), jobQuery.trim() ? 250 : 0);
return () => { window.clearTimeout(timer); jobsRequest.current += 1; jobsPending.current = null; };
}, [jobQuery, jobsOpen, loadJobs]);
const refresh = () => { resultsRef.current?.focus(); setDownloadState(null); void load(); };
const download = async (item: DocumentItem, button?: HTMLButtonElement) => {
if (!sourcePath(item) || item.type !== "attachment" || item.downloadHref !== `/api/attachments/download/${item.resourceId}` || downloadController.current) return;
if (button) downloadFocus.current = { button, key };
resultsRef.current?.focus();
const capturedKey = key;
const controller = new AbortController();
downloadController.current = controller;
setDownloadState({ id: item.id, busy: true });
try {
const response = await api.get<Blob>(`/attachments/download/${item.resourceId}`, { responseType: "blob", signal: controller.signal });
if (!mounted.current || controller.signal.aborted || capturedKey !== currentKey.current) return;
const blob = response.data instanceof Blob ? response.data : new Blob([response.data], { type: item.fileType ?? "application/octet-stream" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
try {
link.href = url;
link.download = item.name;
document.body.appendChild(link);
link.click();
} finally { link.remove(); URL.revokeObjectURL(url); }
setDownloadState(null);
} catch (failure: any) {
if (mounted.current && !controller.signal.aborted && capturedKey === currentKey.current) setDownloadState({ id: item.id, busy: false, status: failure?.response?.status ?? 0 });
} finally {
if (downloadController.current === controller) downloadController.current = null;
}
};
const current = loadedKey === key && !loading && !error;
const items = current ? result?.items ?? [] : [];
const filtered = !!q.trim() || type !== "all" || state !== "all" || visibility !== "all" || !!jobApplicationId;
const locale = language === "nb" ? "nb-NO" : "en-GB";
const selectedJob = useMemo(() => {
if (!jobApplicationId) return null;
const choice = jobs.find((job) => job.id === jobApplicationId);
if (choice) return choice;
const item = result?.items.find((document) => document.jobApplicationId === jobApplicationId);
return { id: jobApplicationId, jobTitle: item?.jobTitle ?? t("documentsJobNumber", { id: jobApplicationId }), companyName: item?.companyName ?? "" };
}, [jobApplicationId, jobs, result, t]);
const from = { from: `${location.pathname}${location.search}${location.hash}`, fromState: location.state };
const buttonSx = { whiteSpace: "normal", overflowWrap: "anywhere", maxWidth: "100%", minHeight: 40 } as const;
const metadataSx = { overflowWrap: "anywhere", minWidth: 0 } as const;
const date = (item: DocumentItem) => {
const label: TranslationKey = item.dateKind === "uploaded-local" ? "documentsUploadedLocal" : item.dateKind === "captured-utc" ? "documentsCaptured" : item.dateKind === "history-recorded-utc" ? "documentsHistoryRecorded" : "documentsUpdated";
let value: Date | null = null;
if (item.recordedAt && item.dateKind === "uploaded-local") {
const local = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)/.exec(item.recordedAt);
// UTC is only a formatting carrier for the unzoned wall-clock components.
if (local) value = new Date(Date.UTC(+local[1], +local[2] - 1, +local[3], +local[4], +local[5], +local[6]));
} else if (item.recordedAt && ["updated-utc", "captured-utc", "history-recorded-utc"].includes(item.dateKind)) value = new Date(item.recordedAt);
if (!value || Number.isNaN(value.valueOf())) return <Typography variant="body2" color="text.secondary">{t("documentsDateUnknown")}</Typography>;
return <Box><Typography variant="caption" color="text.secondary">{t(label)}</Typography><Typography variant="body2" component="time" dateTime={item.recordedAt!} sx={{ display: "block" }}>{value.toLocaleString(locale, { dateStyle: "medium", timeStyle: "short", ...(item.dateKind === "uploaded-local" ? { timeZone: "UTC" } : {}) })}</Typography></Box>;
};
const badges = (item: DocumentItem) => <Stack direction="row" useFlexGap flexWrap="wrap" gap={0.75} sx={{ "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 24 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.25 } }}>
<Chip size="small" variant="outlined" label={t(states.find(([value]) => value === item.state)?.[1] ?? "documentsCurrent")} />
{item.type === "cv" && item.visibility ? <Chip size="small" variant="outlined" label={t(item.visibility === "public" ? "documentsPublic" : "documentsPrivate")} /> : null}
{item.jobIsDeleted ? <Chip size="small" variant="outlined" label={t("documentsDeletedJob")} /> : null}
</Stack>;
const details = (item: DocumentItem) => <Stack spacing={0.5} sx={metadataSx}>
<Typography component={desktop ? "span" : "h3"} variant="body1" sx={{ fontWeight: 700, overflowWrap: "anywhere" }}>{item.name || t("documentsUntitled")}</Typography>
<Box><Typography component="span" variant="body2" color="text.secondary">{t(types.find(([value]) => value === item.type)?.[1] ?? "documentsTypeAttachment")}</Typography>{item.version != null ? <Typography component="span" variant="body2" color="text.secondary">{` · ${t("documentsVersion", { version: item.version })}`}</Typography> : null}</Box>
{item.type === "cv" && item.visibility === "public" ? <Typography variant="caption" color="text.secondary">{t("documentsPublicHelp")}</Typography> : null}
{item.type === "cover-letter" && item.hasCurrentContent === false ? <Typography variant="body2">{t("documentsHistoryOnly")}</Typography> : null}
{item.historyCount != null && item.historyCount > 0 ? <Typography variant="body2" color="text.secondary">{t("documentsHistoryVersions", { count: item.historyCount })}</Typography> : null}
{item.type === "submitted-package" ? <>
{item.cvVariantName && item.cvVariantVersion != null ? <Typography variant="body2">{t("documentsCapturedCv", { name: item.cvVariantName, version: item.cvVariantVersion })}</Typography> : null}
{item.attachmentCount != null ? <Typography variant="body2" color="text.secondary">{t("documentsAttachmentCount", { count: item.attachmentCount })}</Typography> : null}
<Typography variant="caption" color="text.secondary">{t("documentsFrozenHelp")}</Typography>
</> : null}
{item.type === "email-draft" ? <Typography variant="caption" color="text.secondary">{t("documentsEmailHelp")}</Typography> : null}
{item.type === "attachment" ? <>
<Typography variant="caption" color="text.secondary">{[item.fileType, item.fileSize != null ? t("documentsFileSize", { size: new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(item.fileSize / 1024) }) : null].filter(Boolean).join(" · ")}</Typography>
{item.purpose ? <Typography variant="caption" color="text.secondary">{t("attachmentsPurpose")}: {t(({ resume: "attachmentsPurposeResume", "cover-letter": "attachmentsPurposeCoverLetter", portfolio: "attachmentsPurposePortfolio", "case-study": "attachmentsPurposeCaseStudy", certificate: "attachmentsPurposeCertificate" } as Record<string, TranslationKey>)[item.purpose.toLowerCase()] ?? "attachmentsPurposeOther")}</Typography> : null}
{item.purpose?.toLowerCase() === "certificate" ? <Typography variant="caption" color="text.secondary">{t("documentsCertificateHelp")}</Typography> : null}
{item.useForAi != null ? <Typography variant="caption" color="text.secondary">{t("attachmentsAiUse")}: {t(item.useForAi ? "attachmentsAiEnabled" : "attachmentsAiDisabled")}</Typography> : null}
</> : null}
</Stack>;
const job = (item: DocumentItem) => <Typography variant="body2" sx={metadataSx}>{item.jobTitle ? [item.jobTitle, item.companyName].filter(Boolean).join(" · ") : t("documentsUnlinked")}</Typography>;
const actions = (item: DocumentItem) => {
const source = sourcePath(item);
const ownDownload = source && item.type === "attachment" && item.downloadHref === `/api/attachments/download/${item.resourceId}`;
const publicLink = publicPath(item);
const failed = downloadState?.id === item.id && !downloadState.busy && downloadState.status !== undefined;
return <Stack spacing={1} alignItems="flex-start" sx={{ minWidth: 0 }}>
<Stack direction="row" useFlexGap flexWrap="wrap" gap={0.75}>
{source ? <Button component={RouterLink} to={source} state={from} variant="outlined" size="small" sx={buttonSx}>{t(item.type === "email-draft" ? "documentsOpenCorrespondence" : item.type === "submitted-package" ? "documentsOpenSubmissionHistory" : "documentsOpenSource")}</Button> : <Typography variant="body2" color="text.secondary">{t("documentsSourceUnavailable")}</Typography>}
{publicLink ? <Button component={RouterLink} to={publicLink} state={from} size="small" sx={buttonSx}>{t("documentsOpenPublicCv")}</Button> : null}
{ownDownload ? <Button size="small" onClick={(event) => void download(item, event.currentTarget)} disabled={!!downloadState?.busy} sx={buttonSx}>{t(downloadState?.id === item.id && downloadState.busy ? "loading" : "documentsDownloadAttachment")}</Button> : null}
</Stack>
{failed ? <Alert severity="error" sx={{ maxWidth: "100%", overflowWrap: "anywhere", display: "grid", gridTemplateColumns: "auto minmax(0, 1fr)", "& .MuiAlert-message": { minWidth: 0 }, "& .MuiAlert-action": { gridColumn: 2, m: 0, p: 0, justifySelf: "start" } }} action={<Button color="inherit" size="small" sx={buttonSx} onClick={(event) => downloadState?.status === 404 ? refresh() : void download(item, event.currentTarget)}>{t(downloadState?.status === 404 ? "documentsRefresh" : "retry")}</Button>}>{t(downloadState?.status === 404 ? "documentsDownloadUnavailable" : "documentsDownloadFailed")}</Alert> : null}
</Stack>;
};
const filter = (name: string, title: TranslationKey, value: string, options: Array<[string, TranslationKey]>) => <FormControl fullWidth size="small">
<InputLabel id={`${labelId}-${name}`}>{t(title)}</InputLabel>
<Select labelId={`${labelId}-${name}`} label={t(title)} value={value} onChange={(event) => updateQuery({ [name]: event.target.value, page: "1" })}>{options.map(([id, label]) => <MenuItem key={id} value={id}>{t(label)}</MenuItem>)}</Select>
</FormControl>;
return <Stack spacing={2} data-content-density="comfortable" sx={{ minWidth: 0 }}>
<PageHeader level="h2" title={t("documentsYourDocuments")} description={t("documentsIndexHelp")} actions={<Button variant="outlined" startIcon={<RefreshIcon />} onClick={refresh} disabled={loading} sx={buttonSx}>{t("documentsRefresh")}</Button>} />
<Stack spacing={2}>
<TextField fullWidth size="small" label={t("documentsSearch")} value={q} onChange={(event) => updateQuery({ q: event.target.value, page: "1" }, true)} slotProps={{ htmlInput: { maxLength: 200 } }} />
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(3, minmax(0, 1fr))" }, gap: 2, minWidth: 0 }}>
{filter("type", "documentsType", type, types)}
{filter("state", "documentsState", state, states)}
{filter("visibility", "documentsVisibility", visibility, visibilityOptions)}
<Autocomplete size="small" options={jobsKey === jobQuery.trim() && !jobsError ? jobs : []} value={selectedJob} loading={jobsLoading} filterOptions={(choices) => choices} isOptionEqualToValue={(a, b) => a.id === b.id} getOptionLabel={(choice) => [choice.jobTitle, choice.companyName].filter(Boolean).join(" · ")} onOpen={() => setJobsOpen(true)} onClose={() => setJobsOpen(false)} onInputChange={(_, value, reason) => { if (reason === "input") setJobQuery(value); else if (reason === "reset") setJobQuery(""); }} onChange={(_, choice) => updateQuery({ jobApplicationId: choice ? String(choice.id) : "", page: "1" })} noOptionsText={t(jobsError ? "documentsJobsLoadFailed" : "documentsNoJobs")} loadingText={t("loading")} openText={t("documentsOpenJobChoices")} closeText={t("documentsCloseJobChoices")} clearText={t("documentsClearJob")} renderInput={(input) => <TextField {...input} label={t("documentsJobFilter")} helperText={t("documentsJobFilterHelp")} />} />
{filter("sort", "documentsSort", sort, sorts)}
</Box>
{jobsError ? <ViewStateNotice compact title={t("documentsJobsLoadFailed")} error={{ ...jobsError, message: t("documentsJobsLoadFailed") }} onRetry={() => { resultsRef.current?.focus(); return loadJobs(); }} /> : null}
<Typography variant="caption" color="text.secondary">{t("documentsDateOrderHelp")}</Typography>
</Stack>
<Box ref={resultsRef} role="region" aria-label={t("documentsYourDocuments")} tabIndex={-1} aria-busy={loading || !current && !error} sx={{ minWidth: 0 }}>
<ViewStateNotice compact loading={loading} title={t("documentsLoadFailed")} error={error ? { ...error, message: t("documentsLoadFailed") } : null} onRetry={refresh} />
{current && result ? <>
<Typography role="status" variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t("documentsResultCount", { shown: items.length, total: result.total })}</Typography>
{items.length === 0 ? <Paper variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1 }}>
<Typography component="h3" variant="sectionTitle">{t(result.total > 0 ? "documentsPageEmpty" : filtered ? "documentsNoMatches" : "documentsEmpty")}</Typography>
<Typography color="text.secondary" sx={{ mt: 1 }}>{t(result.total > 0 ? "documentsPageEmptyHelp" : filtered ? "documentsNoMatchesHelp" : "documentsEmptyHelp")}</Typography>
{result.total > 0 ? <Button variant="outlined" sx={{ ...buttonSx, mt: 2 }} onClick={() => { resultsRef.current?.focus(); updateQuery({ page: "1" }); }}>{t("documentsFirstPage")}</Button> : null}
</Paper> : desktop ?
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 1 }}><Table size="small" aria-label={t("documentsYourDocuments")} sx={{ tableLayout: "fixed", "& th, & td": { verticalAlign: "top", overflowWrap: "anywhere" } }}>
<TableHead><TableRow><TableCell sx={{ width: "30%" }}>{t("documentsName")}</TableCell><TableCell sx={{ width: "17%" }}>{t("documentsJobFilter")}</TableCell><TableCell sx={{ width: "15%" }}>{t("documentsState")}</TableCell><TableCell sx={{ width: "18%" }}>{t("documentsRecordedDate")}</TableCell><TableCell sx={{ width: "20%" }}>{t("documentsActions")}</TableCell></TableRow></TableHead>
<TableBody>{items.map((item) => <TableRow key={item.id}><TableCell component="th" scope="row">{details(item)}</TableCell><TableCell>{job(item)}</TableCell><TableCell>{badges(item)}</TableCell><TableCell>{date(item)}</TableCell><TableCell>{actions(item)}</TableCell></TableRow>)}</TableBody>
</Table></TableContainer> : <Stack spacing={2}>{items.map((item) => <Paper key={item.id} component="article" aria-label={item.name} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0 }}><Stack spacing={1.5}>{details(item)}{job(item)}{badges(item)}{date(item)}{actions(item)}</Stack></Paper>)}</Stack>}
{result.totalPages > 1 ? <Pagination page={page} count={Math.min(result.totalPages, 10000)} onChange={(_, next) => { resultsRef.current?.focus(); updateQuery({ page: String(next) }); }} aria-label={t("documentsPages")} getItemAriaLabel={(kind, target) => t(kind === "page" ? "documentsGoToPage" : kind === "previous" ? "documentsPreviousPage" : kind === "next" ? "documentsNextPage" : kind === "first" ? "documentsFirstPage" : "documentsLastPage", { page: target ?? 1 })} sx={{ mt: 2, "& .MuiPagination-ul": { flexWrap: "wrap", gap: 0.5 } }} /> : null}
</> : null}
</Box>
</Stack>;
}