feat(career): add factual evidence bank
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import CareerEvidenceBank from "./views/career/CareerEvidenceBank";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { api } from "./api";
|
||||
|
||||
jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn(), delete: jest.fn() }, getApiErrorMessage: (_e: unknown, fallback: string) => fallback }));
|
||||
jest.mock("./confirm", () => ({ useConfirm: () => ({ confirm: jest.fn().mockResolvedValue(true) }) }));
|
||||
const mocked = api as jest.Mocked<typeof api>;
|
||||
|
||||
test("saves factual evidence with provenance and tags", async () => {
|
||||
mocked.get.mockResolvedValue({ data: [] } as any);
|
||||
mocked.post.mockResolvedValue({ data: { id: 1, category: "achievement", title: "Reduced incidents", statement: "Added rollback checks", tags: ["reliability"], sourceReference: "retrospective" } } as any);
|
||||
render(<I18nProvider><CareerEvidenceBank /></I18nProvider>);
|
||||
fireEvent.change(await screen.findByLabelText("Short title"), { target: { value: "Reduced incidents" } });
|
||||
fireEvent.change(screen.getByLabelText("Evidence statement"), { target: { value: "Added rollback checks" } });
|
||||
fireEvent.change(screen.getByLabelText("Skills and keywords"), { target: { value: "reliability" } });
|
||||
fireEvent.change(screen.getByLabelText("Source or provenance"), { target: { value: "retrospective" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save evidence" }));
|
||||
await waitFor(() => expect(mocked.post).toHaveBeenCalledWith("/career/evidence", expect.objectContaining({ title: "Reduced incidents", isVerified: true, sourceType: "manual" })));
|
||||
expect(await screen.findByText("Added rollback checks")).toBeInTheDocument();
|
||||
});
|
||||
@@ -736,6 +736,32 @@ export const translations = {
|
||||
careerOverviewTitle: "Career Workspace",
|
||||
careerWorkspaceNavigation: "Career workspace section",
|
||||
careerWorkspaceOverviewTab: "Overview",
|
||||
careerWorkspaceEvidenceTab: "Evidence bank",
|
||||
careerEvidenceTitle: "Career evidence bank",
|
||||
careerEvidenceSubtitle: "Save factual achievements and examples once, then reuse them when tailoring applications.",
|
||||
careerEvidenceAdd: "Add factual evidence",
|
||||
careerEvidenceCategory: "Category",
|
||||
careerEvidenceCategory_achievement: "Achievement",
|
||||
careerEvidenceCategory_metric: "Metric",
|
||||
careerEvidenceCategory_project: "Project",
|
||||
careerEvidenceCategory_technology: "Technology",
|
||||
careerEvidenceCategory_responsibility: "Responsibility",
|
||||
careerEvidenceCategory_example: "Example",
|
||||
careerEvidenceItemTitle: "Short title",
|
||||
careerEvidenceStatement: "Evidence statement",
|
||||
careerEvidenceTruthHelp: "Only save claims you can support. AI suggestions never enter this bank automatically.",
|
||||
careerEvidenceTags: "Skills and keywords",
|
||||
careerEvidenceTagsHelp: "Comma-separated; used to recommend relevant evidence for a job.",
|
||||
careerEvidenceSource: "Source or provenance",
|
||||
careerEvidenceSourceHelp: "For example: Warwickshire role, project retrospective, certificate.",
|
||||
careerEvidenceSave: "Save evidence",
|
||||
careerEvidenceEmpty: "No evidence saved yet. Start with one concrete achievement or project outcome.",
|
||||
careerEvidenceManualSource: "Added by you",
|
||||
careerEvidenceLoadFailed: "Could not load career evidence.",
|
||||
careerEvidenceSaveFailed: "Could not save career evidence.",
|
||||
careerEvidenceDeleteFailed: "Could not delete career evidence.",
|
||||
careerEvidenceDeleteTitle: "Delete evidence?",
|
||||
careerEvidenceDeleteMessage: "Delete “{title}”? This removes it from future tailoring suggestions.",
|
||||
careerWorkspaceProfileTab: "Career profile",
|
||||
careerWorkspaceImportTab: "Import review",
|
||||
careerOverviewSubtitle: "Choose what you want to work on next.",
|
||||
@@ -3068,6 +3094,32 @@ export const translations = {
|
||||
careerOverviewTitle: "Karriereområde",
|
||||
careerWorkspaceNavigation: "Område i karrierearbeidsområdet",
|
||||
careerWorkspaceOverviewTab: "Oversikt",
|
||||
careerWorkspaceEvidenceTab: "Kunnskapsbank",
|
||||
careerEvidenceTitle: "Kunnskapsbank for karrieren",
|
||||
careerEvidenceSubtitle: "Lagre faktiske prestasjoner og eksempler én gang, og gjenbruk dem når du tilpasser søknader.",
|
||||
careerEvidenceAdd: "Legg til faktisk dokumentasjon",
|
||||
careerEvidenceCategory: "Kategori",
|
||||
careerEvidenceCategory_achievement: "Prestasjon",
|
||||
careerEvidenceCategory_metric: "Målt resultat",
|
||||
careerEvidenceCategory_project: "Prosjekt",
|
||||
careerEvidenceCategory_technology: "Teknologi",
|
||||
careerEvidenceCategory_responsibility: "Ansvar",
|
||||
careerEvidenceCategory_example: "Eksempel",
|
||||
careerEvidenceItemTitle: "Kort tittel",
|
||||
careerEvidenceStatement: "Dokumentert påstand",
|
||||
careerEvidenceTruthHelp: "Lagre bare påstander du kan dokumentere. AI-forslag legges aldri til automatisk.",
|
||||
careerEvidenceTags: "Ferdigheter og nøkkelord",
|
||||
careerEvidenceTagsHelp: "Kommaseparert; brukes til å anbefale relevant dokumentasjon for en stilling.",
|
||||
careerEvidenceSource: "Kilde eller opphav",
|
||||
careerEvidenceSourceHelp: "For eksempel: rolle i Warwickshire, prosjektretrospektiv, sertifikat.",
|
||||
careerEvidenceSave: "Lagre dokumentasjon",
|
||||
careerEvidenceEmpty: "Ingen dokumentasjon er lagret ennå. Start med én konkret prestasjon eller et prosjektresultat.",
|
||||
careerEvidenceManualSource: "Lagt til av deg",
|
||||
careerEvidenceLoadFailed: "Kunne ikke laste karrieredokumentasjon.",
|
||||
careerEvidenceSaveFailed: "Kunne ikke lagre karrieredokumentasjon.",
|
||||
careerEvidenceDeleteFailed: "Kunne ikke slette karrieredokumentasjon.",
|
||||
careerEvidenceDeleteTitle: "Slette dokumentasjon?",
|
||||
careerEvidenceDeleteMessage: "Slette «{title}»? Dette fjerner den fra fremtidige tilpasningsforslag.",
|
||||
careerWorkspaceProfileTab: "Karriereprofil",
|
||||
careerWorkspaceImportTab: "Importgjennomgang",
|
||||
careerOverviewSubtitle: "Velg hva du vil arbeide med videre.",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { api, getApiErrorMessage } from "../api";
|
||||
import CropImageDialog from "../components/CropImageDialog";
|
||||
import ProfileCompleteness from "./career/ProfileCompleteness";
|
||||
import CareerWorkspaceOverview from "./career/CareerWorkspaceOverview";
|
||||
import CareerEvidenceBank from "./career/CareerEvidenceBank";
|
||||
import {
|
||||
EducationSection,
|
||||
InterestsSection,
|
||||
@@ -128,12 +129,12 @@ type CareerSectionStatus = { key: string; label: string; complete: boolean; coun
|
||||
type CareerCompleteness = { percent: number; missing: string[]; sections: CareerSectionStatus[] };
|
||||
type CareerProfileResponse = { profile: StructuredCvProfile; completeness: CareerCompleteness; cvText?: string | null };
|
||||
type CareerVersion = { version: number; source: string; createdAtUtc: string; isCurrent: boolean };
|
||||
type CareerWorkspaceSection = "overview" | "profile" | "import";
|
||||
type CareerWorkspaceSection = "overview" | "profile" | "evidence" | "import";
|
||||
|
||||
function initialWorkspaceSection(): CareerWorkspaceSection {
|
||||
if (typeof window === "undefined") return "overview";
|
||||
const section = new URLSearchParams(window.location.search).get("section");
|
||||
return section === "profile" || section === "import" ? section : "overview";
|
||||
return section === "profile" || section === "evidence" || section === "import" ? section : "overview";
|
||||
}
|
||||
|
||||
// CareerProfilePage backs /career: the master career profile — the single editable source of
|
||||
@@ -322,6 +323,7 @@ export default function CareerProfilePage() {
|
||||
>
|
||||
<MenuItem value="overview">{t("careerWorkspaceOverviewTab")}</MenuItem>
|
||||
<MenuItem value="profile">{t("careerWorkspaceProfileTab")}</MenuItem>
|
||||
<MenuItem value="evidence">{t("careerWorkspaceEvidenceTab")}</MenuItem>
|
||||
<MenuItem value="import">{t("careerWorkspaceImportTab")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
@@ -333,6 +335,7 @@ export default function CareerProfilePage() {
|
||||
>
|
||||
<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>
|
||||
@@ -347,7 +350,9 @@ export default function CareerProfilePage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{workspaceSection !== "overview" ? <Paper id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, 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)", scrollMarginTop: 96 }}>
|
||||
{workspaceSection === "evidence" ? <CareerEvidenceBank /> : null}
|
||||
|
||||
{workspaceSection === "profile" || workspaceSection === "import" ? <Paper id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, 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)", scrollMarginTop: 96 }}>
|
||||
{workspaceSection === "profile" ? <ProfileCompleteness
|
||||
completeness={completeness}
|
||||
versions={versions}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Alert, Box, Button, Chip, MenuItem, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import { api, getApiErrorMessage } from "../../api";
|
||||
import { useConfirm } from "../../confirm";
|
||||
import { useI18n } from "../../i18n/I18nProvider";
|
||||
|
||||
type Evidence = { id: number; category: string; title: string; statement: string; tags: string[]; sourceReference: string | null };
|
||||
|
||||
export default function CareerEvidenceBank() {
|
||||
const { t } = useI18n(); const { confirm } = useConfirm();
|
||||
const [items, setItems] = useState<Evidence[]>([]); const [title, setTitle] = useState(""); const [statement, setStatement] = useState("");
|
||||
const [category, setCategory] = useState("achievement"); const [tags, setTags] = useState(""); const [source, setSource] = useState("");
|
||||
const [error, setError] = useState<string | null>(null); const [busy, setBusy] = useState(false);
|
||||
const load = () => api.get<Evidence[]>("/career/evidence").then(r => { setItems(r.data); setError(null); }).catch(e => setError(getApiErrorMessage(e, t("careerEvidenceLoadFailed"))));
|
||||
useEffect(() => { void load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const add = async () => { setBusy(true); try { const r = await api.post<Evidence>("/career/evidence", { category, title, statement, tags: tags.split(","), sourceType: "manual", sourceReference: source, isVerified: true }); setItems(v => [r.data, ...v]); setTitle(""); setStatement(""); setTags(""); setSource(""); setError(null); } catch (e) { setError(getApiErrorMessage(e, t("careerEvidenceSaveFailed"))); } finally { setBusy(false); } };
|
||||
const remove = async (item: Evidence) => { if (!(await confirm({ title: t("careerEvidenceDeleteTitle"), message: t("careerEvidenceDeleteMessage", { title: item.title }), confirmLabel: t("deleteAction"), destructive: true }))) return; try { await api.delete(`/career/evidence/${item.id}`); setItems(v => v.filter(x => x.id !== item.id)); } catch (e) { setError(getApiErrorMessage(e, t("careerEvidenceDeleteFailed"))); } };
|
||||
const categories = ["achievement", "metric", "project", "technology", "responsibility", "example"];
|
||||
const categoryLabel = (value: string) => t(`careerEvidenceCategory_${value}` as "careerEvidenceCategory_achievement");
|
||||
return <Stack spacing={2}>
|
||||
<Box><Typography variant="h5" fontWeight={800}>{t("careerEvidenceTitle")}</Typography><Typography color="text.secondary">{t("careerEvidenceSubtitle")}</Typography></Box>
|
||||
{error && <Alert severity="error" action={<Button color="inherit" onClick={() => void load()}>{t("retry")}</Button>}>{error}</Alert>}
|
||||
<Paper variant="outlined" sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}><Stack spacing={1.5}>
|
||||
<Typography variant="subtitle1" fontWeight={700}>{t("careerEvidenceAdd")}</Typography>
|
||||
<TextField select size="small" label={t("careerEvidenceCategory")} value={category} onChange={e => setCategory(e.target.value)}>{categories.map(v => <MenuItem key={v} value={v}>{categoryLabel(v)}</MenuItem>)}</TextField>
|
||||
<TextField label={t("careerEvidenceItemTitle")} value={title} onChange={e => setTitle(e.target.value)} />
|
||||
<TextField label={t("careerEvidenceStatement")} value={statement} onChange={e => setStatement(e.target.value)} multiline minRows={3} helperText={t("careerEvidenceTruthHelp")} />
|
||||
<TextField label={t("careerEvidenceTags")} value={tags} onChange={e => setTags(e.target.value)} helperText={t("careerEvidenceTagsHelp")} />
|
||||
<TextField label={t("careerEvidenceSource")} value={source} onChange={e => setSource(e.target.value)} helperText={t("careerEvidenceSourceHelp")} />
|
||||
<Button variant="contained" disabled={busy || !title.trim() || !statement.trim()} onClick={() => void add()} sx={{ alignSelf: "flex-start" }}>{t("careerEvidenceSave")}</Button>
|
||||
</Stack></Paper>
|
||||
{items.length === 0 ? <Alert severity="info">{t("careerEvidenceEmpty")}</Alert> : items.map(item => <Paper key={item.id} variant="outlined" sx={{ p: 2, borderRadius: 3 }}><Stack spacing={1}>
|
||||
<Stack direction="row" justifyContent="space-between" gap={1}><Box><Typography fontWeight={700}>{item.title}</Typography><Typography variant="caption" color="text.secondary">{categoryLabel(item.category)} · {item.sourceReference || t("careerEvidenceManualSource")}</Typography></Box><Button color="error" size="small" startIcon={<DeleteOutlineIcon />} onClick={() => void remove(item)}>{t("deleteAction")}</Button></Stack>
|
||||
<Typography variant="body2">{item.statement}</Typography><Stack direction="row" flexWrap="wrap" gap={0.5}>{item.tags.map(tag => <Chip key={tag} size="small" label={tag} />)}</Stack>
|
||||
</Stack></Paper>)}
|
||||
</Stack>;
|
||||
}
|
||||
Reference in New Issue
Block a user