fix(ui): clarify document errors and retain readable actions

This commit is contained in:
cesnimda
2026-10-20 20:29:22 +02:00
parent ab89b6984c
commit 584530b694
16 changed files with 777 additions and 30 deletions
+53 -1
View File
@@ -99,10 +99,43 @@ test('[P7-07 Documents] initial503 Retry and incompatible held query expose no s
test('[P7-07 Documents] stale CV opens the existing missing-source error and Back plus explicit Refresh removes its metadata', async ({ page, session }, info) => {
test.setTimeout(120_000); const variant = await cv(page.request, session.owner, 'Removed current CV — æøå'); await enter(page, session.owner); const item = (await readIndex(page.request)).items[0]; await expect(row(page, item)).toBeVisible(); const from = indexPath(page); noImplicitSourceWork(session); expect((await page.request.delete(`${apiUrl}/cv/variants/${variant.id}`, { headers: session.owner.headers })).status()).toBe(204);
const missing = waitResponse(page, 'GET', `/api/cv/variants/${variant.id}`); await keyboard(page, row(page, item).getByRole('link', { name: copy('documentsOpenSource'), exact: true })); expect((await missing).status()).toBe(404); await expect(page).toHaveURL(new RegExp(`/career/builder/${variant.id}$`)); const sourceError = page.getByRole('main').getByRole('alert'); await expect(sourceError).toBeVisible(); await expect(sourceError).toHaveText('Not Found'); await expect(page.getByRole('heading', { level: 2, name: copy('documentsYourDocuments'), exact: true })).toHaveCount(0); await capture(page, info, desktop, 'existing-missing-source-editor'); await page.goBack(); await expect(results(page)).toBeVisible(); expect(indexPath(page)).toBe(from);
const missing = waitResponse(page, 'GET', `/api/cv/variants/${variant.id}`); await keyboard(page, row(page, item).getByRole('link', { name: copy('documentsOpenSource'), exact: true })); expect((await missing).status()).toBe(404); await expect(page).toHaveURL(new RegExp(`/career/builder/${variant.id}$`)); const sourceError = page.getByRole('main').getByRole('alert'); await expect(sourceError).toBeVisible(); await expect(sourceError).toContainText(copy('cvEditorSourceUnavailable')); await expect(sourceError.getByRole('button', { name: copy('retry'), exact: true })).toHaveCount(0); await expect(page.getByRole('heading', { level: 2, name: copy('cvBuilder'), exact: true })).toBeVisible(); await expect(page.getByRole('heading', { level: 2, name: copy('documentsYourDocuments'), exact: true })).toHaveCount(0); await capture(page, info, desktop, 'existing-missing-source-editor'); await page.goBack(); await expect(results(page)).toBeVisible(); expect(indexPath(page)).toBe(from);
const refreshed = waitResponse(page, 'GET', '/api/documents'); await keyboard(page, page.getByRole('button', { name: copy('documentsRefresh'), exact: true })); expect(metadata(await json<Documents>(await refreshed)).total).toBe(0); await expect(row(page, item)).toHaveCount(0); await expect(page.getByText(copy('documentsEmpty'), { exact: true })).toBeVisible(); expect(writes(session)).toEqual([]);
});
test('[P7-11 E08 C05] transient private CV entry recovers GET reads and keyboard focus without saving', async ({ page, session }, info) => {
const variant = await cv(page.request, session.owner, 'Read-only recovery CV');
await enter(page, session.owner);
let failed = true;
await page.route(`**/api/cv/variants/${variant.id}`, route => failed ? route.fulfill({ status: 503, json: { title: 'Controlled unavailable read' } }) : route.continue());
await keyboard(page, row(page, { name: variant.name, type: 'cv' }).getByRole('link', { name: copy('documentsOpenSource'), exact: true }));
const alert = page.getByRole('alert').filter({ hasText: copy('cvEditorOpenFailed') });
await expect(alert).toBeVisible();
failed = false;
await keyboard(page, alert.getByRole('button', { name: copy('retry'), exact: true }));
const editor = page.getByRole('region', { name: copy('cvEditorEditingRegion'), exact: true });
await expect(editor).toBeFocused();
await expect(page.getByLabel(copy('cvEditorName'), { exact: true })).toHaveValue(variant.name);
expect((await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${variant.id}`))).version).toBe(variant.version);
expect(writes(session).every(value => value.method === 'POST' && value.path === `/api/cv/variants/${variant.id}/preview`)).toBe(true);
await capture(page, info, desktop, 'recovered-private-cv-entry');
});
test('[P7-11 E08] unknown routes have bilingual contextual headings and preserve browser history', async ({ page, session }, info) => {
for (const sample of [desktop, { language: 'nb', theme: 'dark', width: 320, height: 844 }] as Sample[]) {
await enter(page, session.owner, sample);
const from = indexPath(page);
await page.goto('/phase7-unknown-route?keep=context');
await expect(page.getByRole('heading', { level: 1, name: copy('notFoundTitle', {}, sample.language), exact: true })).toBeVisible();
await expect(page.getByRole('heading', { level: 2, name: copy('notFoundTitle', {}, sample.language), exact: true })).toBeVisible();
await capture(page, info, sample, 'unknown-route-context');
await keyboard(page, page.getByRole('button', { name: copy('goBack', {}, sample.language), exact: true }));
await expect(results(page, sample.language)).toBeVisible();
expect(indexPath(page)).toBe(from);
}
expect(writes(session)).toEqual([]);
});
test('[P7-07 Documents] explicit authenticated attachment GET retries exact bytes and a real404 offers only list Refresh', async ({ page, session }, info) => {
test.setTimeout(150_000); const parent = await job(page.request, session.owner); const file = await upload(page.request, session.owner, parent); await enter(page, session.owner); const item = (await readIndex(page.request)).items[0]; await expect(row(page, item)).toBeVisible(); noImplicitSourceWork(session); const path = `/api/attachments/download/${file.attachment.id}`; let attempts = 0; await page.route(`${apiUrl}/attachments/download/${file.attachment.id}`, async route => { attempts += 1; if (attempts === 1) return route.fulfill({ status: 503, json: {} }); return route.fulfill({ response: await route.fetch() }); });
await keyboard(page, row(page, item).getByRole('button', { name: copy('documentsDownloadAttachment'), exact: true })); const failed = page.getByRole('alert').filter({ hasText: copy('documentsDownloadFailed') }); await expect(failed).toBeVisible(); expect(count(session, 'GET', path)).toHaveLength(1); const listReads = count(session, 'GET', '/api/documents').length; const downloaded = page.waitForEvent('download'); const actual = waitResponse(page, 'GET', path); await keyboard(page, failed.getByRole('button', { name: copy('retry'), exact: true })); const response = await actual; expect(response.status()).toBe(200); expect(response.headers()['content-type']).toContain('text/plain'); const download = await downloaded; expect(download.suggestedFilename()).toBe(file.name); const saved = await download.path(); expect(saved).not.toBeNull(); expect(await readFile(saved!)).toEqual(file.bytes); expect(attempts).toBe(2); expect(count(session, 'GET', '/api/documents')).toHaveLength(listReads); await expect(row(page, item)).toBeVisible();
@@ -123,6 +156,25 @@ test('[P7-07 Documents] existing letter source keeps dirty edits on navigation C
test('[P7-07 Documents] eight finite bilingual theme widths retain long metadata keyboard actions and complete axe vectors', async ({ page, session }, info) => {
test.setTimeout(300_000); const seeded = await seven(page, session.owner, true); const samples: Sample[] = [{ language: 'en', theme: 'light', width: 320, height: 740 }, { language: 'nb', theme: 'dark', width: 360, height: 844 }, { language: 'en', theme: 'dark', width: 390, height: 844 }, { language: 'nb', theme: 'light', width: 768, height: 1024 }, { language: 'en', theme: 'dark', width: 1024, height: 768 }, { language: 'nb', theme: 'light', width: 1280, height: 320 }, { language: 'en', theme: 'light', width: 1440, height: 900 }, { language: 'nb', theme: 'dark', width: 1920, height: 1080 }];
for (const sample of samples) {
if (sample.width === 1024) {
await enter(page, session.owner, sample);
const open = page.getByRole('link', { name: copy('documentsOpenCorrespondence', {}, sample.language), exact: true }).first();
await expect(open).toBeVisible();
const wordLines = await open.evaluate(button => {
const walker = document.createTreeWalker(button, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
const text = node.textContent?.trim();
if (!text) continue;
const range = document.createRange();
range.setStart(node, (node.textContent ?? '').lastIndexOf(' ') + 1);
range.setEnd(node, node.textContent!.length);
return range.getClientRects().length;
}
return 0;
});
expect(wordLines).toBe(1);
}
await enter(page, session.owner, sample); await expect(rows(page, sample.language)).toHaveCount(7); const control = search(page, sample.language); await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Tab'); expect(await page.evaluate(() => document.activeElement !== document.body && document.activeElement?.isConnected)).toBe(true); const frozen = seeded.items.find(value => value.type === 'submitted-package')!; const action = row(page, frozen, sample.language).getByRole('link', { name: copy('documentsOpenSubmissionHistory', {}, sample.language), exact: true }); await action.focus(); await action.scrollIntoViewIfNeeded(); await expect(action).toBeFocused(); await expect(action).toHaveAttribute('href', frozen.sourceHref);
const beforeCapture = await action.boundingBox(); expect(beforeCapture).not.toBeNull(); expect(beforeCapture!.y).toBeGreaterThanOrEqual(0); expect(beforeCapture!.y + beforeCapture!.height).toBeLessThanOrEqual(sample.height); await capture(page, info, sample, 'long-metadata-keyboard');
await action.scrollIntoViewIfNeeded(); await expect(action).toBeFocused(); const afterCapture = await action.boundingBox(); expect(afterCapture).not.toBeNull(); expect(afterCapture!.y).toBeGreaterThanOrEqual(0); expect(afterCapture!.y + afterCapture!.height).toBeLessThanOrEqual(sample.height); await info.attach(`p707-documents-${sample.language}-${sample.width}-focus-bounds`, { body: JSON.stringify({ beforeFullPageCapture: beforeCapture, afterScrollRestoration: afterCapture, viewport: sample, qualification: 'Full-page screenshot can change scroll. Both actual viewport bounds are asserted after explicit focus/scroll, without relaxing the 0..height requirement.' }), contentType: 'application/json' });
+1 -1
View File
@@ -152,7 +152,7 @@ function titleFor(path: string, t: (k: any) => string): string {
if (path.startsWith("/admin/audit")) return t("auditLog");
if (path.startsWith("/admin/users")) return t("users");
if (path.startsWith("/admin/system")) return t("systemStatus");
return t("appTitle");
return t("notFoundTitle");
}
function subtitleFor(path: string, t: (k: any) => string): string | undefined {
@@ -203,6 +203,43 @@ test("another user's variant is refused by the server and never rendered", async
renderAt(3);
expect(await screen.findByText(/Could not open this CV/i)).toBeInTheDocument();
expect(screen.getByText('This CV is unavailable or you no longer have access to it.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
});
test('a denied CV read has localized permission context without a write or retry', async () => {
routeGet(() => Promise.reject({ response: { status: 403 } }));
renderAt(3);
expect(await screen.findByText('You do not have permission to open this CV.')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'CV Builder', level: 2 })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
});
test('a temporary CV opening failure retries reads and restores focus to the editor without saving', async () => {
let attempt = 0;
routeGet(() => ++attempt === 1 ? Promise.reject({ response: { status: 503 } }) : Promise.resolve({ data: variant }));
renderAt(3);
const retry = await screen.findByRole('button', { name: 'Retry' });
retry.focus(); fireEvent.click(retry);
await screen.findByDisplayValue('Backend CV');
await waitFor(() => expect(screen.getByRole('region', { name: 'CV editing controls' })).toHaveFocus());
expect(attempt).toBe(2);
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.post.mock.calls.every(call => String(call[0]).endsWith('/preview'))).toBe(true);
});
test('Bokmål history localizes known sources while preserving unknown metadata and versions', async () => {
localStorage.setItem('uiLanguage', 'nb');
const sources = ['autosave', 'manual', 'restore', 'create', 'custom-import', 'constructor'];
routeGet(() => Promise.resolve({ data: variant }), { sections: [] }, sources.map((source, index) => ({ version: index + 1, isCurrent: index === 3, source, createdAtUtc: variant.updatedAtUtc })));
renderAt(3);
await screen.findByDisplayValue('Backend CV');
fireEvent.click(screen.getByRole('tab', { name: 'Historikk' }));
for (const label of ['Automatisk lagring', 'Manuell', 'Gjenopprettet versjon', 'Opprettet', 'custom-import', 'constructor']) expect(await screen.findByText(new RegExp(` · ${label}$`))).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Gjenopprett versjon 5' })).toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
});
test('invalid names are explained and are not silently autosaved', async () => {
@@ -241,6 +278,8 @@ test('session undo and redo restore content edits before autosave', async () =>
renderAt(3);
await openContentTab();
expect(screen.getByRole('button', { name: 'Undo' }).parentElement).not.toHaveAttribute('aria-label');
expect(screen.getByRole('button', { name: 'Redo' }).parentElement).not.toHaveAttribute('aria-label');
const headline = await screen.findByLabelText('Headline override');
fireEvent.change(headline, { target: { value: 'Platform Engineer' } });
expect(screen.getByRole('button', { name: 'Undo' })).toBeEnabled();
@@ -0,0 +1,20 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import NotFoundPage from './views/NotFoundPage';
import { I18nProvider } from './i18n/I18nProvider';
afterEach(() => localStorage.removeItem('uiLanguage'));
function Previous() { const location = useLocation(); return <div>{location.pathname + location.search}</div>; }
test.each([['en', 'Page not found', 'Go back'], ['nb', 'Siden ble ikke funnet', 'Gå tilbake']])('unknown %s page has contextual headings and preserves Back', (language, title, back) => {
localStorage.setItem('uiLanguage', language);
render(<I18nProvider><MemoryRouter initialEntries={['/previous?keep=context', '/unknown']} initialIndex={1}>
<Routes><Route path="/previous" element={<Previous />} /><Route path="*" element={<NotFoundPage />} /></Routes>
</MemoryRouter></I18nProvider>);
expect(screen.getByRole('heading', { name: title, level: 2 })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: '404' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: back }));
expect(screen.getByText('/previous?keep=context')).toBeInTheDocument();
});
+35 -12
View File
@@ -41,6 +41,8 @@ import {
import { previewDocumentHtml } from "../cvPreview";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
import { PageHeader } from "../layout/PageLayout";
import ViewStateNotice from "../components/ViewStateNotice";
const MIN_PREVIEW_ZOOM = 0.2;
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error" | "conflict";
@@ -75,7 +77,9 @@ export default function CvBuilderEditor() {
const [exporting, setExporting] = useState(false);
const [publishing, setPublishing] = useState(false);
const [versions, setVersions] = useState<CvVariantVersionInfo[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [loadError, setLoadError] = useState<{ status?: number } | null>(null);
const [loadAttempt, setLoadAttempt] = useState(0);
const [opening, setOpening] = useState(true);
const [mobilePane, setMobilePane] = useState<"edit" | "preview">("edit");
const [historyRevision, setHistoryRevision] = useState(0);
@@ -93,6 +97,8 @@ export default function CvBuilderEditor() {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const editorRef = useRef<HTMLDivElement | null>(null);
const loadRegion = useRef<HTMLDivElement | null>(null);
const retryFocus = useRef(false);
const blockerPromptOpen = useRef(false);
const undoStack = useRef<CvVariantSettings[]>([]);
const redoStack = useRef<CvVariantSettings[]>([]);
@@ -103,6 +109,7 @@ export default function CvBuilderEditor() {
useEffect(() => {
let alive = true;
setOpening(true);
(async () => {
try {
const [variant, themeList, outlineData] = await Promise.all([
@@ -114,16 +121,24 @@ export default function CvBuilderEditor() {
applyVariant(variant);
setThemes(themeList);
setOutline(outlineData);
setLoadError(null);
} catch (err) {
if (alive) setLoadError(getApiErrorMessage(err, t("cvEditorOpenFailed")));
if (alive) setLoadError({ status: (err as { response?: { status?: number } })?.response?.status });
} finally {
if (alive) setOpening(false);
}
})();
return () => {
alive = false;
editorEpoch.current += 1;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variantId]);
}, [variantId, loadAttempt]);
useEffect(() => {
if (!retryFocus.current || opening) return;
retryFocus.current = false;
(loadError ? loadRegion.current : editorRef.current)?.focus();
}, [opening, loadError]);
const applyVariant = (variant: CvVariant) => {
if (saveTimer.current) clearTimeout(saveTimer.current);
@@ -514,10 +529,15 @@ export default function CvBuilderEditor() {
}, [compactEditor, editorReady, fitPreview, mobilePane]);
if (loadError) {
const denied = loadError.status === 401 || loadError.status === 403;
return (
<Box sx={{ p: 3 }}>
<Box ref={loadRegion} role="region" aria-label={t("cvEditorOpenFailed")} tabIndex={-1} aria-busy={opening} sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
<PageHeader level="h2" title={t("cvBuilder")} />
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/career/builder")}>{t("cvEditorBack")}</Button>
<Alert severity="error" sx={{ mt: 2 }}>{loadError}</Alert>
<ViewStateNotice loading={opening} title={t("cvEditorOpenFailed")}
description={loadError.status === 404 ? t("cvEditorSourceUnavailable") : denied ? t("cvEditorPermissionDenied") : undefined}
error={{ kind: denied ? "unauthorized" : "unavailable", message: t("cvEditorOpenFailed"), retryable: !loadError.status || loadError.status >= 500 || loadError.status === 429 }}
onRetry={() => { retryFocus.current = true; loadRegion.current?.focus(); setLoadAttempt(value => value + 1); }} />
</Box>
);
}
@@ -578,8 +598,8 @@ export default function CvBuilderEditor() {
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 1.5, py: 0.5, borderBottom: "1px solid", borderColor: "divider" }}>
<Typography component="h2" variant="subtitle2">{t("cvEditorEditingRegion")}</Typography>
<Stack direction="row">
<Tooltip title={t("cvEditorUndo")}><span><IconButton size="small" aria-label={t("cvEditorUndo")} disabled={!canUndo} onClick={undo}><UndoIcon fontSize="small" /></IconButton></span></Tooltip>
<Tooltip title={t("cvEditorRedo")}><span><IconButton size="small" aria-label={t("cvEditorRedo")} disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
<Tooltip describeChild title={t("cvEditorUndo")}><span><IconButton size="small" aria-label={t("cvEditorUndo")} disabled={!canUndo} onClick={undo}><UndoIcon fontSize="small" /></IconButton></span></Tooltip>
<Tooltip describeChild title={t("cvEditorRedo")}><span><IconButton size="small" aria-label={t("cvEditorRedo")} disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
</Stack>
</Stack>
<Tabs aria-label={t("cvEditorStudioNavigation")} value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ borderBottom: "1px solid", borderColor: "divider" }}>
@@ -1360,17 +1380,20 @@ function HistoryTab({ versions, onRestore }: { versions: CvVariantVersionInfo[];
return (
<Stack spacing={0.5}>
<Typography variant="caption" color="text.secondary">{t("cvEditorVersionCount", { count: versions.length })}</Typography>
{versions.map((v) => (
{versions.map((v) => {
const sources = { autosave: "historySourceAutosave", manual: "historySourceManual", restore: "historySourceRestore", create: "historySourceCreate" } as const;
const source = Object.hasOwn(sources, v.source) ? sources[v.source as keyof typeof sources] : undefined;
return (
<Paper key={v.version} variant="outlined" sx={{ p: 1, display: "flex", alignItems: "center", gap: 1 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
<Typography component="div" variant="body2" sx={{ fontWeight: 700 }}>
{t("cvEditorVersion", { version: v.version })} {v.isCurrent && <Chip size="small" label={t("cvEditorCurrent")} sx={{ ml: 0.5, height: 18 }} />}
</Typography>
<Typography variant="caption" color="text.secondary">{relTime(v.createdAtUtc, language)} · {v.source}</Typography>
<Typography variant="caption" color="text.secondary">{relTime(v.createdAtUtc, language)} · {source ? t(source) : v.source}</Typography>
</Box>
{!v.isCurrent && <Button size="small" aria-label={t("cvEditorRestoreTitle", { version: v.version })} onClick={() => onRestore(v.version)}>{t("cvEditorRestore")}</Button>}
</Paper>
))}
); })}
</Stack>
);
}
+2 -2
View File
@@ -207,7 +207,7 @@ export default function DocumentsPage() {
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 buttonSx = { whiteSpace: "normal", overflowWrap: "normal", maxWidth: "100%", minHeight: 40 } as const;
const metadataSx = { overflowWrap: "anywhere", minWidth: 0 } as const;
const date = (item: DocumentItem) => {
@@ -288,7 +288,7 @@ export default function DocumentsPage() {
<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" } }}>
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 1 }}><Table size="small" aria-label={t("documentsYourDocuments")} sx={{ "& 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>}
+5 -10
View File
@@ -2,6 +2,7 @@ import React from "react";
import { Box, Button, Paper, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { useI18n } from "../i18n/I18nProvider";
import { PageHeader } from "../layout/PageLayout";
export default function NotFoundPage() {
const navigate = useNavigate();
@@ -10,22 +11,16 @@ export default function NotFoundPage() {
return (
<Paper
sx={{
p: { xs: 4, md: 6 },
borderRadius: 4,
border: "none",
boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)",
p: "var(--app-card-padding)",
borderRadius: 1,
minWidth: 0,
}}
>
<Box sx={{ display: "grid", gap: 1.5, maxWidth: 560 }}>
<Typography sx={{ fontWeight: 900, fontSize: { xs: 48, md: 64 }, lineHeight: 1, letterSpacing: "-0.02em", color: "primary.main" }}>
404
</Typography>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
{t("notFoundTitle")}
</Typography>
<Typography sx={{ color: "text.secondary" }}>
{t("notFoundBody")}
</Typography>
<PageHeader level="h2" title={t("notFoundTitle")} description={t("notFoundBody")} />
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", mt: 1 }}>
<Button variant="contained" onClick={() => navigate("/jobs")}>
{t("goHome")}