style(ui): open career and document surfaces and show single-month activity
This commit is contained in:
@@ -248,6 +248,10 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
{[0, 0.5, 1].map(fraction => <line key={fraction} x1="20" x2="740" y1={180 - fraction * 160} y2={180 - fraction * 160} stroke={palette.divider} />)}
|
||||
<path d={chart.applied} fill="none" stroke={palette.primary.main} strokeWidth="3" />
|
||||
<path d={chart.responses} fill="none" stroke={palette.success.main} strokeWidth="3" strokeDasharray="7 5" />
|
||||
{analytics.length === 1 ? <>
|
||||
<circle cx="20" cy={180 - analytics[0].applied / chart.max * 160} r="5" fill={palette.primary.main} />
|
||||
<circle cx="20" cy={180 - analytics[0].responses / chart.max * 160} r="8" fill="none" stroke={palette.success.main} strokeWidth="3" />
|
||||
</> : null}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, mb: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">{monthLabel(analytics[0].month)}</Typography>
|
||||
|
||||
@@ -125,6 +125,23 @@ test('[PRES-S01] activity chart uses one shared zero-based scale and a readable
|
||||
expect(firstY(paths[0])).toBeLessThan(firstY(paths[1]));
|
||||
});
|
||||
|
||||
test('[PRES-S01] a single recorded month has visible markers on the shared scale and retains exact values', async () => {
|
||||
const writesBefore = mockedApi.put.mock.calls.length;
|
||||
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url.endsWith('/analytics') ? [monthly[0]] : data(url) }) as any);
|
||||
open();
|
||||
const activity = await screen.findByRole('region', { name: 'Application activity' });
|
||||
const markers = activity.querySelectorAll('svg circle');
|
||||
expect(markers).toHaveLength(2);
|
||||
expect(markers[0]).toHaveAttribute('cy', '20');
|
||||
expect(markers[1]).toHaveAttribute('cy', '140');
|
||||
for (const marker of markers) expect(Number(marker.getAttribute('r'))).toBeGreaterThan(0);
|
||||
fireEvent.click(within(activity).getByText('View monthly values'));
|
||||
const table = within(activity).getByRole('table');
|
||||
expect(within(table).getByRole('cell', { name: '4' })).toBeVisible();
|
||||
expect(within(table).getByRole('cell', { name: '1' })).toBeVisible();
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(writesBefore);
|
||||
});
|
||||
|
||||
test('[P7-02 PRES-S01 S02] setup guidance treats pending and failed summaries as unknown until their owner retries', async () => {
|
||||
let resolveStats!: (response: { data: typeof stats }) => void;
|
||||
let fail = false;
|
||||
|
||||
@@ -100,7 +100,10 @@ test.each(['en', 'nb'] as const)('%s document filters and results support reques
|
||||
localStorage.setItem('uiLanguage', language); renderPage();
|
||||
await screen.findByText('Metadata CV ÆØÅ', { exact: true });
|
||||
const count = mockedApi.get.mock.calls.length;
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('documentsJumpResults', {}, language) }));
|
||||
const jump = screen.getByRole('button', { name: copy('documentsJumpResults', {}, language) });
|
||||
const search = screen.getByRole('textbox', { name: copy('documentsSearch', {}, language) });
|
||||
expect(jump.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
fireEvent.click(jump);
|
||||
expect(screen.getByRole('region', { name: copy('documentsYourDocuments', {}, language) })).toHaveFocus();
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('documentsReturnFilters', {}, language) }));
|
||||
expect(screen.getByRole('textbox', { name: copy('documentsSearch', {}, language) })).toHaveFocus();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Checkbox, Chip, FormControl, FormControlLabel, InputLabel, LinearProgress, MenuItem, Paper, Select, Tab, Tabs, TextField, Typography } from "@mui/material";
|
||||
import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Checkbox, Chip, FormControl, FormControlLabel, InputLabel, LinearProgress, MenuItem, Select, Tab, Tabs, TextField, Typography } from "@mui/material";
|
||||
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
@@ -522,8 +522,8 @@ export default function CareerProfilePage() {
|
||||
: change.id === "Contact" && change.confidence === "Low" && change.label === "Contact details" ? t("careerImportContactDetails") : change.label;
|
||||
return (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 2, minWidth: 0, "& .MuiAlert-root": { flexWrap: "wrap" }, "& .MuiAlert-message": { minWidth: 0, flex: "1 1 160px", overflow: "visible", overflowWrap: "anywhere" } }}>
|
||||
<Paper component="nav" aria-label={t("careerWorkspaceNavigation")} variant="outlined" sx={{ borderRadius: 1, minWidth: 0, overflow: "hidden" }}>
|
||||
<FormControl size="small" fullWidth sx={{ display: { xs: "flex", sm: "none" }, m: 1.25, width: "auto" }}>
|
||||
<Box component="nav" aria-label={t("careerWorkspaceNavigation")} sx={{ borderBottom: 1, borderColor: "divider", minWidth: 0 }}>
|
||||
<FormControl size="small" fullWidth sx={{ display: { xs: "flex", sm: "none" } }}>
|
||||
<InputLabel id="career-workspace-section-label">{t("careerWorkspaceNavigation")}</InputLabel>
|
||||
<Select
|
||||
labelId="career-workspace-section-label"
|
||||
@@ -541,14 +541,14 @@ export default function CareerProfilePage() {
|
||||
value={workspaceSection}
|
||||
onChange={(_, value: CareerWorkspaceSection) => navigateWorkspace(value)}
|
||||
aria-label={t("careerWorkspaceNavigation")}
|
||||
sx={{ display: { xs: "none", sm: "flex" }, px: 1 }}
|
||||
sx={{ display: { xs: "none", sm: "flex" } }}
|
||||
>
|
||||
<Tab value="overview" label={t("careerWorkspaceOverviewTab")} />
|
||||
<Tab value="profile" label={t("careerWorkspaceProfileTab")} />
|
||||
<Tab value="evidence" label={t("careerWorkspaceEvidenceTab")} />
|
||||
<Tab value="import" label={t("careerWorkspaceImportTab")} />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{workspaceSection === "overview" ? (
|
||||
<CareerWorkspaceOverview
|
||||
@@ -568,7 +568,7 @@ export default function CareerProfilePage() {
|
||||
|
||||
{evidenceVisited ? <Box hidden={workspaceSection !== "evidence"}><CareerEvidenceBank /></Box> : null}
|
||||
|
||||
{workspaceSection === "profile" || workspaceSection === "import" ? <Paper variant="outlined" id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, scrollMarginTop: 96, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }}>
|
||||
{workspaceSection === "profile" || workspaceSection === "import" ? <Box id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, minWidth: 0, scrollMarginTop: 96, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }}>
|
||||
<input
|
||||
ref={cvInputRef}
|
||||
type="file"
|
||||
@@ -627,7 +627,7 @@ export default function CareerProfilePage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{workspaceSection === "profile" ? <Paper variant="outlined" sx={{ mb: 2.5, p: { xs: 1.5, sm: 2 }, borderRadius: 3 }}>
|
||||
{workspaceSection === "profile" ? <Box sx={{ mb: 2.5, py: 1.5, borderBottom: 1, borderColor: "divider" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography component="h2" variant="subtitle1" sx={{ fontWeight: 800 }}>{t("careerProfileImportTitle")}</Typography>
|
||||
@@ -659,7 +659,7 @@ export default function CareerProfilePage() {
|
||||
{t("careerProfileImportReady")}
|
||||
</Alert> : null}
|
||||
{!canUseAi ? <Box sx={{ mt: 1.5 }}><ProFeatureNotice featureKey="career-ai" title={t("profileProImportTitle")}>{t("profileProImportBody")}</ProFeatureNotice></Box> : null}
|
||||
</Paper> : null}
|
||||
</Box> : null}
|
||||
|
||||
{workspaceSection === "profile" ? <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
@@ -843,7 +843,7 @@ export default function CareerProfilePage() {
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<PageSection title={t("profileCvExtractionHistory")} description={t("profileCvExtractionHistoryHelp")} surface>
|
||||
<PageSection title={t("profileCvExtractionHistory")} description={t("profileCvExtractionHistoryHelp")}>
|
||||
{runsLoading ? <LinearProgress aria-label={t("loading")} /> : null}
|
||||
{runsLoadError !== null ? <Alert severity="error" action={<Button color="inherit" disabled={runsLoading} onClick={() => void loadExtractionRuns()}>{t("retry")}</Button>}>{t("careerOverviewImportUnavailable")}</Alert> : null}
|
||||
{latestRun ? (
|
||||
@@ -1011,7 +1011,7 @@ export default function CareerProfilePage() {
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: workspaceSection === "profile" ? 2 : 0, display: workspaceSection === "profile" ? "block" : "none" }}>
|
||||
<PageSection title={t("profileCvStructuredEditor")} description={t("profileCvStructuredEditorHelp")} surface>
|
||||
<PageSection title={t("profileCvStructuredEditor")} description={t("profileCvStructuredEditorHelp")}>
|
||||
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||
<Box id="career-section-contact">
|
||||
<PersonalInformationSection
|
||||
@@ -1090,7 +1090,7 @@ export default function CareerProfilePage() {
|
||||
|
||||
</Box>
|
||||
|
||||
</Paper> : null}
|
||||
</Box> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -274,7 +274,10 @@ export default function DocumentsPage() {
|
||||
</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>} />
|
||||
<PageHeader level="h2" title={t("documentsYourDocuments")} description={t("documentsIndexHelp")} actions={<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
<Button onClick={() => resultsRef.current?.focus()}>{t("documentsJumpResults")}</Button>
|
||||
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={refresh} disabled={loading} sx={buttonSx}>{t("documentsRefresh")}</Button>
|
||||
</Box>} />
|
||||
<Stack component="aside" aria-label={t("documentsFilters")} spacing={2}>
|
||||
<TextField inputRef={searchRef} 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 }}>
|
||||
@@ -286,7 +289,6 @@ export default function DocumentsPage() {
|
||||
</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>
|
||||
<Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => resultsRef.current?.focus()}>{t("documentsJumpResults")}</Button>
|
||||
</Stack>
|
||||
<Box ref={resultsRef} role="region" aria-label={t("documentsYourDocuments")} tabIndex={-1} aria-busy={loading || !current && !error} sx={{ minWidth: 0 }}>
|
||||
<Button size="small" sx={{ mb: 1 }} onClick={() => searchRef.current?.focus()}>{t("documentsReturnFilters")}</Button>
|
||||
@@ -301,7 +303,7 @@ export default function DocumentsPage() {
|
||||
<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>}
|
||||
</Table></TableContainer> : <Stack spacing={2}>{items.map((item) => <Box key={item.id} component="article" aria-label={item.name} sx={{ py: 2, borderBottom: 1, borderColor: "divider", minWidth: 0 }}><Stack spacing={1.5}>{details(item)}{job(item)}{badges(item)}{date(item)}{actions(item)}</Stack></Box>)}</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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Alert, Box, Button, Chip, LinearProgress, Paper, Skeleton, Stack, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, Chip, LinearProgress, Skeleton, Stack, Typography } from "@mui/material";
|
||||
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
|
||||
import PersonOutlineIcon from "@mui/icons-material/PersonOutline";
|
||||
import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined";
|
||||
@@ -43,7 +43,7 @@ function ActionCard({ title, body, href, label, icon, onNavigate }: {
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Paper variant="outlined" component="article" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
<Box component="article" sx={{ py: 1.5, borderBottom: 1, borderColor: "divider", minWidth: 0, display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
|
||||
<Typography component="h3" variant="sectionTitle" sx={{ overflowWrap: "anywhere" }}>{title}</Typography>
|
||||
@@ -57,7 +57,7 @@ function ActionCard({ title, body, href, label, icon, onNavigate }: {
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
|
||||
<Box sx={{ display: "grid", gap: "var(--app-section-gap)" }}>
|
||||
{isFirstRun ? <Alert severity="info">{t("careerOverviewFirstRun")}</Alert> : null}
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 2 }}>
|
||||
<PageSection surface title={t("careerOverviewProfileStatus")} level="h3">
|
||||
<PageSection title={t("careerOverviewProfileStatus")} level="h3">
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center" }}>
|
||||
<Typography variant="body2" color="text.secondary">{t("careerOverviewCompleteness")}</Typography>
|
||||
{loading ? <Skeleton width={42} /> : profileKnown ? <Typography sx={{ fontWeight: 900 }}>{completeness.percent}%</Typography> : null}
|
||||
@@ -157,7 +157,7 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
|
||||
) : null}
|
||||
<Button ref={profileLinkRef} href="/career?section=profile" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("profile"); } : undefined} size="small" sx={{ mt: 1, px: 0.5 }}>{t("careerOverviewImproveProfile")}</Button>
|
||||
</PageSection>
|
||||
<PageSection surface title={t("careerOverviewCvImport")} level="h3">
|
||||
<PageSection title={t("careerOverviewCvImport")} level="h3">
|
||||
{importUnknown ? <Typography role="status" color="text.secondary">{t("loading")}</Typography>
|
||||
: runsLoadError ? <Alert severity="error" action={onRetryRuns ? <Button color="inherit" disabled={runsLoading} onClick={() => { importLinkRef.current?.focus(); onRetryRuns(); }}>{t("retry")}</Button> : undefined}>{t("careerOverviewImportUnavailable")}</Alert>
|
||||
: <Alert severity={importState.severity}>{importState.message}</Alert>}
|
||||
@@ -186,7 +186,7 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
|
||||
<ActionCard title={t("careerOverviewJobCv")} body={t("careerOverviewJobCvBody")} href="/jobs" label={t("careerOverviewChooseJob")} icon={<WorkOutlineIcon />} />
|
||||
</Box>
|
||||
|
||||
<PageSection surface title={t("careerOverviewRecentCvs")} level="h3" actions={<Button ref={recentLinkRef} href="/career/builder" size="small">{t("careerOverviewViewAll")}</Button>}>
|
||||
<PageSection title={t("careerOverviewRecentCvs")} level="h3" actions={<Button ref={recentLinkRef} href="/career/builder" size="small">{t("careerOverviewViewAll")}</Button>}>
|
||||
{recentLoading ? (
|
||||
<Stack spacing={1}><Typography role="status" color="text.secondary">{t("loading")}</Typography><Skeleton height={32} /><Skeleton height={32} /></Stack>
|
||||
) : recentError ? (
|
||||
|
||||
Reference in New Issue
Block a user