feat(ui): redesign job header and overview
This commit is contained in:
@@ -91,6 +91,8 @@ test('[PRES-S01 S02 E07 E08 E09] Dashboard native data, visibility and responsiv
|
||||
await customize.focus(); await page.keyboard.press('Enter');
|
||||
await expect(page.getByRole('menuitemcheckbox').first()).toBeFocused();
|
||||
if (language === 'en') {
|
||||
// Measure settled colors, not the menu's entry-opacity transition.
|
||||
await expect.poll(() => page.getByRole('menu').evaluate(menu => getComputedStyle(menu.parentElement!).opacity)).toBe('1');
|
||||
const menuScan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
await info.attach(`dashboard-${mode}-menu-axe`, { body: JSON.stringify({ violations: menuScan.violations, incomplete: menuScan.incomplete }), contentType: 'application/json' });
|
||||
expect(menuScan.violations).toEqual([]);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { expect, test, type APIRequestContext, type Page, type TestInfo } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
async function login(api: APIRequestContext, email: string) {
|
||||
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
|
||||
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value;
|
||||
return { me, headers: { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string } };
|
||||
}
|
||||
async function capture(page: Page, info: TestInfo, name: string) {
|
||||
const path = info.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
await info.attach(name, { path, contentType: 'image/png' });
|
||||
}
|
||||
|
||||
test('[PRES-W01 W02 W03 J05 E08 E09] native Job header/Overview retains actions, custom status, aliases and mobile progress keyboard access', async ({ page, playwright }, info) => {
|
||||
test.setTimeout(240000);
|
||||
const admin = await playwright.request.newContext();
|
||||
const { headers: adminHeaders } = await login(admin, 'e2e@example.test');
|
||||
const email = `job-overview-${Date.now()}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
const { headers } = await login(page.request, email);
|
||||
await page.route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
|
||||
const companyResponse = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys karrieremuligheter og langsiktig plattformutvikling æøå', source: 'direct' } });
|
||||
expect(companyResponse.status()).toBe(201);
|
||||
const companyId = (await companyResponse.json()).id;
|
||||
const title = 'Senior utvikler og faglig rådgiver for samfunnskritiske plattformer og inkluderende digitale tjenester';
|
||||
const jobData = { companyId, jobTitle: title, status: 'Interview', location: 'Tromsø · Oslo og fjernarbeid', salary: '700 000–900 000 NOK', deadline: '2026-12-14T12:00:00Z', dateApplied: '2026-10-02T12:00:00Z', followUpAt: '2026-10-12T12:00:00Z', jobUrl: 'https://example.test/jobs/platform', notes: 'Ask about ownership and sustainable delivery.', description: 'Build reliable services with .NET, SQL and Azure.', tags: 'Azure, .NET', source: 'direct', countryCode: 'NO' };
|
||||
const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: jobData });
|
||||
expect(created.status()).toBe(201); const id = (await created.json()).id;
|
||||
const sparseResponse = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: 'Sparse application', status: 'Saved' } });
|
||||
expect(sparseResponse.status()).toBe(201); const sparseId = (await sparseResponse.json()).id;
|
||||
|
||||
const writes: string[] = [];
|
||||
page.on('request', request => {
|
||||
if (request.url().startsWith(apiUrl) && !['GET', 'OPTIONS'].includes(request.method())) writes.push(`${request.method()} ${new URL(request.url()).pathname}`);
|
||||
});
|
||||
await page.goto(`/jobs/${id}`);
|
||||
await expect(page.getByRole('heading', { name: title, level: 2 })).toBeVisible();
|
||||
await expect(page.getByRole('tablist', { name: translations.en.workspaceSections }).getByRole('tab')).toHaveCount(5);
|
||||
await expect(page.getByRole('link', { name: translations.en.workspaceOpenAdvert })).toHaveAttribute('href', jobData.jobUrl);
|
||||
await expect(page.getByRole('link', { name: translations.en.workspaceOpenAdvert })).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(writes).toEqual([]);
|
||||
const edit = page.getByRole('button', { name: translations.en.workspaceEditApplication });
|
||||
await edit.focus(); await page.keyboard.press('Enter');
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.keyboard.press('Escape'); await expect(edit).toBeFocused();
|
||||
|
||||
for (const language of ['en', 'nb'] as const) for (const mode of ['light', 'dark'] as const) {
|
||||
const t = translations[language];
|
||||
await page.evaluate(({ mode, language }) => { localStorage.setItem('jobtracker.themeMode', mode); localStorage.setItem('uiLanguage', language); }, { mode, language });
|
||||
// Persist language while no app reads are in flight; theme uses its existing local owner.
|
||||
await page.goto('about:blank');
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
await page.goto(`/jobs/${id}?section=overview`);
|
||||
await expect(page.getByRole('heading', { name: title, level: 2 })).toBeVisible();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', mode);
|
||||
const summary = page.getByRole('region', { name: t.workspaceApplicationInfo, exact: true });
|
||||
await expect(summary).toContainText(new Date(jobData.dateApplied).toLocaleDateString(language === 'nb' ? 'nb-NO' : 'en'));
|
||||
const progress = page.getByRole('region', { name: `${t.workspaceProgress}: ${t.statusInterview}` });
|
||||
await expect(progress.locator('[aria-current=step]')).toHaveText(t.statusInterview);
|
||||
for (const [width, height, name] of [[1440, 900, 'desktop'], [768, 1024, 'tablet'], [390, 844, 'phone']] as const) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await capture(page, info, `job-overview-${name}-${mode}-${language}`);
|
||||
}
|
||||
await progress.focus();
|
||||
expect(await progress.evaluate(element => parseFloat(getComputedStyle(element).outlineWidth))).toBeGreaterThanOrEqual(3);
|
||||
await progress.evaluate(element => { element.scrollLeft = 0; });
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect.poll(() => progress.evaluate(element => element.scrollLeft)).toBeGreaterThan(0);
|
||||
await capture(page, info, `job-progress-focus-${mode}-${language}`);
|
||||
if (language === 'en') {
|
||||
const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
await info.attach(`job-overview-${mode}-axe`, { body: JSON.stringify({ violations: scan.violations, incomplete: scan.incomplete }), contentType: 'application/json' });
|
||||
expect(scan.violations).toEqual([]);
|
||||
}
|
||||
}
|
||||
for (const width of [320, 1920]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await capture(page, info, `job-overview-${width}-dark-nb`);
|
||||
}
|
||||
// Existing native status write, not a new header status API or inferred progression.
|
||||
await page.goto('about:blank');
|
||||
expect((await page.request.put(`${apiUrl}/jobapplications/${id}`, { headers, data: { ...jobData, status: 'Faglig vurdering' } })).status()).toBe(204);
|
||||
await page.goto(`/applications/${id}?section=overview`);
|
||||
const custom = page.getByRole('region', { name: `${translations.nb.workspaceProgress}: Faglig vurdering` });
|
||||
await expect(custom).toContainText(translations.nb.workspaceCustomProgress);
|
||||
await expect(custom.locator('[aria-current]')).toHaveCount(0);
|
||||
await capture(page, info, 'job-custom-status-dark-nb');
|
||||
await page.goto(`/jobs/${sparseId}`);
|
||||
await expect(page.getByRole('heading', { name: 'Sparse application' })).toBeVisible();
|
||||
await expect(page.getByRole('region', { name: translations.nb.workspaceApplicationInfo }).getByText('—', { exact: true })).toHaveCount(3);
|
||||
await expect(page.getByRole('link', { name: translations.nb.workspaceOpenAdvert })).toHaveCount(0);
|
||||
await capture(page, info, 'job-sparse-dark-nb');
|
||||
const foreignEmail = `job-overview-foreign-${Date.now()}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email: foreignEmail, password, roles: [] } })).status()).toBe(200);
|
||||
const foreign = await playwright.request.newContext();
|
||||
await login(foreign, foreignEmail);
|
||||
expect((await foreign.get(`${apiUrl}/jobapplications/${id}/workspace`)).status()).toBe(404);
|
||||
await foreign.dispose(); await admin.dispose();
|
||||
});
|
||||
|
||||
test('[PRES-W01 W02 E08] Job Overview loading, failed read, retry and unavailable resource remain explicit', async ({ page }, info) => {
|
||||
await login(page.request, 'e2e@example.test');
|
||||
let release: (() => void) | undefined; let failed = true;
|
||||
const hold = new Promise<void>(resolve => { release = resolve; });
|
||||
await page.route(`${apiUrl}/jobapplications/2147483646/workspace`, async route => {
|
||||
await hold;
|
||||
return route.fulfill({ status: failed ? 503 : 404, json: { message: failed ? 'Temporary workspace failure' : 'Not Found' } });
|
||||
});
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/jobs/2147483646');
|
||||
await expect(page.getByRole('progressbar').first()).toBeVisible();
|
||||
await capture(page, info, 'job-loading'); release!();
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'Could not open this application.' })).toBeVisible();
|
||||
await capture(page, info, 'job-load-error'); failed = false;
|
||||
await page.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'Not Found' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Retry', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Back to applications' })).toBeVisible();
|
||||
await capture(page, info, 'job-unavailable');
|
||||
});
|
||||
@@ -274,3 +274,48 @@ test("hydrates list filters, sort and page from a shareable URL", async () => {
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
test("names the progress scroll region and identifies the current stage without making stages actions", async () => {
|
||||
renderTable("/jobs/42");
|
||||
const progress = await screen.findByRole("region", { name: "Application progress: Waiting" });
|
||||
expect(progress).toHaveAttribute("tabindex", "0");
|
||||
expect(within(progress).getByText("Waiting").closest("[aria-current]")).toHaveAttribute("aria-current", "step");
|
||||
expect(within(progress).queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps custom status literal without claiming a completed standard pipeline", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: { ...overview, status: "Faglig vurdering" } } as any);
|
||||
renderTable("/jobs/42");
|
||||
const progress = await screen.findByRole("region", { name: "Application progress: Faglig vurdering" });
|
||||
expect(within(progress).queryByText("Offer")).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "Backend Developer" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("retries a failed workspace read without creating or changing application content", async () => {
|
||||
mockedApi.get.mockRejectedValueOnce({ response: { status: 503 } });
|
||||
renderTable("/jobs/42");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Retry" }));
|
||||
expect(await screen.findByRole("heading", { name: "Backend Developer" })).toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("keeps the server-selected next action and preparation entry on the existing CV section", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: { ...overview, nextStep: { key: "prepare-cv", label: "Prepare a CV for this role", reason: "Attach the chosen variant.", section: "cv" } } } as any);
|
||||
renderTable("/jobs/42");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Prepare a CV for this role" }));
|
||||
expect(await screen.findByText("CV section")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=cv");
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Overview" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "CV Not prepared" }));
|
||||
expect(await screen.findByText("CV section")).toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("uses recorded dates in the summary and keeps absent dates unknown", async () => {
|
||||
renderTable("/jobs/42");
|
||||
const summary = await screen.findByRole("region", { name: "Application information" });
|
||||
expect(summary).toHaveTextContent(new Date(overview.dateApplied).toLocaleDateString("en"));
|
||||
expect(within(summary).getAllByText("—")).toHaveLength(2);
|
||||
expect(screen.queryByRole("link", { name: "Open original advert" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -2422,6 +2422,8 @@ export const translations = {
|
||||
workspaceEditApplication: "Edit application",
|
||||
workspaceOpenAdvert: "Open original advert",
|
||||
workspaceProgress: "Application progress",
|
||||
workspaceCustomProgress: "This custom status has no standard pipeline position.",
|
||||
workspacePreparation: "Application preparation",
|
||||
workspaceNextAction: "Next recommended action",
|
||||
workspaceNothingOutstanding: "Nothing outstanding — this application is fully prepared.",
|
||||
workspaceRecentActivity: "Recent activity",
|
||||
@@ -4936,6 +4938,8 @@ export const translations = {
|
||||
workspaceEditApplication: "Rediger søknad",
|
||||
workspaceOpenAdvert: "Åpne den opprinnelige annonsen",
|
||||
workspaceProgress: "Søknadsprogresjon",
|
||||
workspaceCustomProgress: "Denne egendefinerte statusen har ingen plass i standardløpet.",
|
||||
workspacePreparation: "Forberedelse av søknaden",
|
||||
workspaceNextAction: "Neste anbefalte handling",
|
||||
workspaceNothingOutstanding: "Ingenting gjenstår — denne søknaden er ferdig forberedt.",
|
||||
workspaceRecentActivity: "Nylig aktivitet",
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from "react-router-dom";
|
||||
|
||||
import {
|
||||
Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Chip, Divider, IconButton, Paper,
|
||||
Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Chip, IconButton, Paper,
|
||||
Skeleton, Stack, Tab, Tabs, Tooltip, Typography,
|
||||
} from "@mui/material";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
@@ -31,6 +31,9 @@ import {
|
||||
} from "../components/ApplicationAssets";
|
||||
import { ApplicationInterviewPrep } from "../components/InterviewPrep";
|
||||
import EditJobDialog from "../components/EditJobDialog";
|
||||
import ViewStateNotice from "../components/ViewStateNotice";
|
||||
import { PageHeader, PageSection } from "../layout/PageLayout";
|
||||
import type { ViewResourceError } from "../hooks/useViewResource";
|
||||
import { ApplicationStatusSuggestion, ApplicationStrategySnapshot } from "../components/ApplicationWorkflowAssist";
|
||||
import { PIPELINE_STATUSES, normalizeStatus, statusLabel, statusTone } from "../pipeline";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -74,7 +77,8 @@ export function ApplicationWorkspace({
|
||||
const section = sectionOverride ?? workspaceSection(params.get("section"));
|
||||
|
||||
const [overview, setOverview] = useState<WorkspaceOverview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<ViewResourceError | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [coverLetterDirty, setCoverLetterDirty] = useState(false);
|
||||
const [packageDraftsDirty, setPackageDraftsDirty] = useState(false);
|
||||
@@ -115,14 +119,19 @@ export function ApplicationWorkspace({
|
||||
const load = useCallback(async () => {
|
||||
if (!Number.isInteger(jobId) || jobId <= 0) {
|
||||
setOverview(null);
|
||||
setError(t("workspaceInvalidLink"));
|
||||
setError({ kind: "error", message: t("workspaceInvalidLink"), retryable: false });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
setOverview(await applicationWorkspaceApi.overview(jobId));
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, t("workspaceLoadFailed")));
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
setError({ kind: "error", message: getApiErrorMessage(err, t("workspaceLoadFailed")), retryable: !status || status >= 500, status });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobId, t]);
|
||||
|
||||
@@ -146,16 +155,17 @@ export function ApplicationWorkspace({
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: "grid", gap: 2, minWidth: 0 }}>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={close}>{t("workspaceBack")}</Button>
|
||||
<Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>
|
||||
<ViewStateNotice title={t("workspaceLoadFailed")} error={error} onRetry={load} compact />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "grid", gap: 2 }}>
|
||||
<Paper sx={{ borderRadius: 3, overflow: "hidden" }}>
|
||||
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
|
||||
<ViewStateNotice title={t("workspaceLoadFailed")} loading={loading && !overview} compact />
|
||||
<Paper variant="outlined" sx={{ minWidth: 0, borderRadius: 1 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: { xs: 1, sm: 2 }, pt: 1.5 }}>
|
||||
<Tooltip title={t("workspaceBack")}>
|
||||
<IconButton size="small" aria-label={t("workspaceBack")} onClick={close}>
|
||||
@@ -229,34 +239,19 @@ export function ApplicationWorkspace({
|
||||
);
|
||||
}
|
||||
function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | null; onEdit: () => void }) {
|
||||
const { t } = useI18n();
|
||||
if (!overview) return <Paper sx={{ p: 2.5, borderRadius: 3 }}><Skeleton width="45%" height={34} /><Skeleton width="30%" /></Paper>;
|
||||
const { language, t } = useI18n();
|
||||
if (!overview) return <Box sx={{ p: 2 }} aria-hidden><Skeleton width="65%" height={34} /><Skeleton width="40%" /></Box>;
|
||||
return (
|
||||
<Box sx={{ px: { xs: 2, sm: 3 }, pt: 1, pb: 2.5 }}>
|
||||
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" flexWrap="wrap" gap={1}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 900 }}>{overview.jobTitle}</Typography>
|
||||
<Typography color="text.secondary">
|
||||
{[overview.company, overview.location, overview.salary].filter(Boolean).join(" · ") || "—"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Tooltip title={t("workspaceEditApplication")}>
|
||||
<IconButton size="small" aria-label={t("workspaceEditApplication")} onClick={onEdit}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Chip size="small" label={statusLabel(t, overview.status)} color={statusTone(overview.status)} variant="outlined" />
|
||||
{overview.source ? <Chip size="small" label={overview.source.toUpperCase()} variant="outlined" /> : null}
|
||||
{overview.jobUrl && (
|
||||
<Tooltip title={t("workspaceOpenAdvert")}>
|
||||
<IconButton size="small" aria-label={t("workspaceOpenAdvert")} href={overview.jobUrl} target="_blank" rel="noopener noreferrer">
|
||||
<OpenInNewIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box sx={{ px: { xs: 2, sm: 3 }, pt: 1, pb: 2 }}>
|
||||
<PageHeader level="h2" title={overview.jobTitle} description={overview.company ?? undefined} actions={<>
|
||||
<Button variant="outlined" startIcon={<EditOutlinedIcon />} onClick={onEdit}>{t("workspaceEditApplication")}</Button>
|
||||
{overview.jobUrl ? <Button component="a" href={overview.jobUrl} target="_blank" rel="noopener noreferrer" endIcon={<OpenInNewIcon />}>{t("workspaceOpenAdvert")}</Button> : null}
|
||||
</>} />
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1, mt: 1.5, minWidth: 0 }}>
|
||||
<Chip size="small" label={statusLabel(t, overview.status)} color={statusTone(overview.status)} variant="outlined" sx={{ maxWidth: "100%", height: "auto", minHeight: 24, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }} />
|
||||
{overview.location ? <Typography variant="body2" color="text.secondary" sx={{ overflowWrap: "anywhere" }}>{overview.location}</Typography> : null}
|
||||
{overview.deadline ? <Typography variant="body2" color="text.secondary">{t("jobDetailsDeadline")}: {new Date(overview.deadline).toLocaleDateString(language === "nb" ? "nb-NO" : "en")}</Typography> : null}
|
||||
</Box>
|
||||
<ApplicationProgress status={overview.status} />
|
||||
</Box>
|
||||
);
|
||||
@@ -265,18 +260,19 @@ function WorkspaceHeader({ overview, onEdit }: { overview: WorkspaceOverview | n
|
||||
function ApplicationProgress({ status }: { status: string }) {
|
||||
const { t } = useI18n();
|
||||
const normalized = normalizeStatus(status);
|
||||
const currentOrder = normalized === "Other" ? Number.MAX_SAFE_INTEGER : PIPELINE_STATUSES.indexOf(normalized) + 1;
|
||||
const currentOrder = normalized === "Other" ? 0 : PIPELINE_STATUSES.indexOf(normalized) + 1;
|
||||
const isTerminal = normalized === "Rejected" || normalized === "Ghosted" || normalized === "Withdrawn";
|
||||
const stages = isTerminal
|
||||
const stages = normalized === "Other" ? [] : isTerminal
|
||||
? [...PIPELINE_STATUSES.filter((stage) => !["Offer", "Rejected", "Ghosted", "Withdrawn"].includes(stage)), normalized]
|
||||
: PIPELINE_STATUSES.filter((stage) => !["Rejected", "Ghosted", "Withdrawn"].includes(stage));
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2.5 }} aria-label={`${t("workspaceProgress")}: ${statusLabel(t, status)}`}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 800, letterSpacing: ".06em", textTransform: "uppercase" }}>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
|
||||
{t("workspaceProgress")}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", overflowX: "auto", pt: 1, pb: 0.5 }}>
|
||||
<Box role="region" tabIndex={0} aria-label={`${t("workspaceProgress")}: ${statusLabel(t, status)}`} sx={{ display: "flex", overflowX: "auto", p: 1, mt: 0.5, borderRadius: 1 }}>
|
||||
{normalized === "Other" ? <Typography variant="body2" color="text.secondary">{t("workspaceCustomProgress")}</Typography> : null}
|
||||
{stages.map((stage, index) => {
|
||||
const order = PIPELINE_STATUSES.indexOf(stage) + 1;
|
||||
const current = stage === normalized;
|
||||
@@ -284,7 +280,7 @@ function ApplicationProgress({ status }: { status: string }) {
|
||||
return (
|
||||
<Box key={stage} sx={{ display: "grid", gridTemplateColumns: index ? "minmax(30px, 1fr) auto" : "auto", alignItems: "center", minWidth: index ? 110 : 72, flex: 1 }}>
|
||||
{index > 0 && <Box aria-hidden sx={{ height: 2, bgcolor: complete || current ? "primary.main" : "divider" }} />}
|
||||
<Stack alignItems="center" spacing={0.5} sx={{ minWidth: 72 }}>
|
||||
<Stack aria-current={current ? "step" : undefined} alignItems="center" spacing={0.5} sx={{ minWidth: 72 }}>
|
||||
<Box aria-hidden sx={{ width: 12, height: 12, borderRadius: "50%", border: 2, borderColor: current || complete ? "primary.main" : "divider", bgcolor: complete ? "primary.main" : "background.paper", boxShadow: current ? (theme) => `0 0 0 4px ${theme.palette.primary.main}26` : "none" }} />
|
||||
<Typography variant="caption" sx={{ whiteSpace: "nowrap", fontWeight: current ? 800 : 600, color: current ? "text.primary" : "text.secondary" }}>
|
||||
{statusLabel(t, stage)}
|
||||
@@ -326,57 +322,65 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Stack spacing={2} sx={{ minWidth: 0 }}>
|
||||
<PageSection surface title={t("workspaceApplicationInfo")}>
|
||||
<Box component="dl" sx={{ m: 0, display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(4, minmax(0, 1fr))" }, gap: 2 }}>
|
||||
{[
|
||||
[t("workspaceDiscovered"), overview.savedAt],
|
||||
[t("workspaceApplied"), overview.dateApplied],
|
||||
[t("jobDetailsDeadline"), overview.deadline],
|
||||
[t("intelligenceCategoryFollowUp"), overview.followUpAt],
|
||||
].map(([label, date]) => <Box key={label}>
|
||||
<Typography component="dt" variant="caption" color="text.secondary">{label}</Typography>
|
||||
<Typography component="dd" variant="body2" sx={{ m: 0, mt: 0.5, fontWeight: 600 }}>{date ? new Date(date).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : "—"}</Typography>
|
||||
</Box>)}
|
||||
</Box>
|
||||
</PageSection>
|
||||
{overview.nextStep ? (
|
||||
<Paper sx={{ p: 2.5, borderRadius: 3, borderLeft: "4px solid", borderLeftColor: "primary.main" }}>
|
||||
<Typography variant="overline" color="text.secondary">{t("workspaceNextAction")}</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800 }}>{nextStepLabel}</Typography>
|
||||
<PageSection surface title={t("workspaceNextAction")}>
|
||||
<Typography component="h3" variant="subtitle1" sx={{ fontWeight: 700 }}>{nextStepLabel}</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 1.5 }}>{nextStepReason}</Typography>
|
||||
<Button variant="contained" endIcon={<ArrowForwardIcon />}
|
||||
onClick={() => overview.nextStep?.section && onGo(overview.nextStep.section as WorkspaceSectionKey)}>
|
||||
{nextStepLabel}
|
||||
</Button>
|
||||
</Paper>
|
||||
</PageSection>
|
||||
) : (
|
||||
<Alert severity="success" sx={{ borderRadius: 3 }}>
|
||||
{t("workspaceNothingOutstanding")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(5, minmax(0, 1fr))" }, gap: 1.5 }}>
|
||||
<PageSection title={t("workspacePreparation")}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(5, minmax(0, 1fr))" }, gap: 1 }}>
|
||||
{stats.map((s) => (
|
||||
<Paper key={s.label} variant="outlined" role="button" tabIndex={0}
|
||||
<Button key={s.label} variant="outlined"
|
||||
onClick={() => onGo(s.go)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onGo(s.go); } }}
|
||||
sx={{ p: 1.5, borderRadius: 3, cursor: "pointer", "&:focus-visible": { boxShadow: 3 } }}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ color: s.ok ? "success.main" : "text.disabled" }}>
|
||||
sx={{ p: 1.5, display: "block", textAlign: "left", color: "text.primary", borderColor: "divider", minWidth: 0, whiteSpace: "normal", overflowWrap: "anywhere" }}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ color: s.ok ? "success.main" : "text.secondary" }}>
|
||||
{s.icon}
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>{s.label}</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ mt: 0.5, fontWeight: 600 }}>{s.value}</Typography>
|
||||
</Paper>
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</PageSection>
|
||||
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("workspaceRecentActivity")}</Typography>
|
||||
<Button size="small" onClick={onReload}>{t("workspaceRefresh")}</Button>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<PageSection surface title={t("workspaceRecentActivity")} actions={<Button size="small" onClick={onReload}>{t("workspaceRefresh")}</Button>}>
|
||||
{overview.recentActivity.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">{t("workspaceNoActivity")}</Typography>
|
||||
) : (
|
||||
<Stack spacing={0.75}>
|
||||
{overview.recentActivity.map((a, i) => (
|
||||
<Stack key={i} direction="row" spacing={1} justifyContent="space-between">
|
||||
<Typography variant="body2"><strong>{a.type}</strong>{a.detail ? ` — ${a.detail}` : ""}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{new Date(a.at).toLocaleDateString(language === "nb" ? "nb-NO" : "en")}</Typography>
|
||||
<Stack key={i} direction={{ xs: "column", sm: "row" }} spacing={0.5} justifyContent="space-between">
|
||||
<Typography variant="body2" sx={{ overflowWrap: "anywhere" }}><strong>{a.type}</strong>{a.detail ? ` — ${a.detail}` : ""}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>{new Date(a.at).toLocaleDateString(language === "nb" ? "nb-NO" : "en")}</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</PageSection>
|
||||
|
||||
<OverviewDetails jobId={overview.id} overview={overview} onReload={onReload} onEdit={onEdit} />
|
||||
</Stack>
|
||||
|
||||
Reference in New Issue
Block a user