feat(interview): integrate contextual AI prep
This commit is contained in:
@@ -5,16 +5,22 @@ import {
|
|||||||
Skeleton, Stack, TextField, Tooltip, Typography,
|
Skeleton, Stack, TextField, Tooltip, Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
|
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
||||||
|
|
||||||
import { getApiErrorMessage } from "../api";
|
import { getApiErrorMessage } from "../api";
|
||||||
import {
|
import {
|
||||||
FollowUp, INTERVIEW_PREP_CATEGORIES, InterviewPrepBoard, InterviewPrepItem, interviewPrepApi,
|
FollowUp, INTERVIEW_PREP_CATEGORIES, InterviewPrepBoard, InterviewPrepItem, interviewPrepApi,
|
||||||
|
ApplicationCv, applicationAssetsApi,
|
||||||
} from "../applicationWorkspace";
|
} from "../applicationWorkspace";
|
||||||
|
import { aiWorkspaceApi } from "../aiWorkspace";
|
||||||
|
import { useAccountPlan } from "../accountPlan";
|
||||||
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
import { useConfirm } from "../confirm";
|
||||||
|
|
||||||
// Phase 5.5 — Interview preparation and follow-up.
|
// Phase 5.5 — Interview preparation and follow-up.
|
||||||
//
|
//
|
||||||
// The prep content is the user's: this component never generates anything. AI suggestions live in the
|
// The prep content is the user's. AI suggestions are generated in this dedicated workspace and only
|
||||||
// AI panel below and only become prep items when the user adds them.
|
// become prep items after an explicit accept action.
|
||||||
// docs/architecture/application-workspace.md.
|
// docs/architecture/application-workspace.md.
|
||||||
|
|
||||||
function Shell({ title, subtitle, loading, error, children }: {
|
function Shell({ title, subtitle, loading, error, children }: {
|
||||||
@@ -41,6 +47,7 @@ function Shell({ title, subtitle, loading, error, children }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
|
export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [board, setBoard] = useState<InterviewPrepBoard | null>(null);
|
const [board, setBoard] = useState<InterviewPrepBoard | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -87,15 +94,15 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
|
|||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<Shell
|
<Shell
|
||||||
title="Interview preparation"
|
title={t("interviewPrepTitle")}
|
||||||
subtitle="Your own research, answers and questions. Nothing here is generated or overwritten."
|
subtitle={t("interviewPrepSubtitle")}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
>
|
>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
{board && !board.isInterviewStage && (
|
{board && !board.isInterviewStage && (
|
||||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||||
This application has not reached an interview stage yet. Preparing early is fine.
|
{t("interviewPrepEarly")}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -103,16 +110,16 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
|
|||||||
<Box>
|
<Box>
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="baseline" sx={{ mb: 0.75 }}>
|
<Stack direction="row" justifyContent="space-between" alignItems="baseline" sx={{ mb: 0.75 }}>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
|
||||||
Preparation progress
|
{t("interviewPrepProgress")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
{board.prepared} of {board.total} ready
|
{t("interviewPrepReadyCount", { prepared: board.prepared, total: board.total })}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
variant="determinate"
|
variant="determinate"
|
||||||
value={board.percent}
|
value={board.percent}
|
||||||
aria-label="Interview preparation progress"
|
aria-label={t("interviewPrepProgress")}
|
||||||
sx={{ height: 8, borderRadius: 4 }}
|
sx={{ height: 8, borderRadius: 4 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -120,8 +127,7 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
|
|||||||
|
|
||||||
{board && board.total === 0 ? (
|
{board && board.total === 0 ? (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Nothing prepared yet. Add a question you expect, a company fact worth knowing, or a STAR
|
{t("interviewPrepEmpty")}
|
||||||
example you want ready.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
(board?.groups ?? []).map((group) => (
|
(board?.groups ?? []).map((group) => (
|
||||||
@@ -150,7 +156,7 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
|
|||||||
<TextField
|
<TextField
|
||||||
select
|
select
|
||||||
size="small"
|
size="small"
|
||||||
label="Category"
|
label={t("interviewPrepCategory")}
|
||||||
value={category}
|
value={category}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
@@ -163,22 +169,132 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
|
|||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
label="Add a question, topic or note"
|
label={t("interviewPrepAddLabel")}
|
||||||
value={title}
|
value={title}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" variant="contained" disabled={busy || !title.trim()}>Add</Button>
|
<Button type="submit" variant="contained" disabled={busy || !title.trim()}>{t("interviewPrepAdd")}</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Shell>
|
</Shell>
|
||||||
|
|
||||||
|
<InterviewAiAssistant jobId={jobId} onAccepted={load} />
|
||||||
|
|
||||||
<ApplicationFollowUp jobId={jobId} />
|
<ApplicationFollowUp jobId={jobId} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INTERVIEW_AI_FOCUS = [
|
||||||
|
{ key: "full", labelKey: "interviewAiFull", category: "note" },
|
||||||
|
{ key: "technical", labelKey: "interviewAiTechnical", category: "technical" },
|
||||||
|
{ key: "behavioural", labelKey: "interviewAiBehavioural", category: "behavioural" },
|
||||||
|
{ key: "star", labelKey: "interviewAiStar", category: "star" },
|
||||||
|
{ key: "gaps", labelKey: "interviewAiGaps", category: "note" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function InterviewAiAssistant({ jobId, onAccepted }: { jobId: number; onAccepted: () => void }) {
|
||||||
|
const { canUseAi } = useAccountPlan();
|
||||||
|
const { language: uiLanguage, t } = useI18n();
|
||||||
|
const [cv, setCv] = useState<ApplicationCv | null>(null);
|
||||||
|
const [focus, setFocus] = useState<(typeof INTERVIEW_AI_FOCUS)[number]["key"]>("full");
|
||||||
|
const [language, setLanguage] = useState<"en" | "nb-NO">(() => uiLanguage === "nb" ? "nb-NO" : "en");
|
||||||
|
const [instructions, setInstructions] = useState("");
|
||||||
|
const [suggestion, setSuggestion] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
applicationAssetsApi.cv(jobId)
|
||||||
|
.then((result) => { if (active) setCv(result); })
|
||||||
|
.catch(() => { if (active) setCv(null); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [jobId]);
|
||||||
|
|
||||||
|
const selected = INTERVIEW_AI_FOCUS.find((item) => item.key === focus) ?? INTERVIEW_AI_FOCUS[0];
|
||||||
|
const generate = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const focusLabel = t(selected.labelKey);
|
||||||
|
const extraContext = [
|
||||||
|
`Focus this preparation on: ${focusLabel}.`,
|
||||||
|
instructions.trim(),
|
||||||
|
].filter(Boolean).join("\n");
|
||||||
|
const result = await aiWorkspaceApi.generate(jobId, {
|
||||||
|
module: "interview",
|
||||||
|
targetLanguage: language,
|
||||||
|
extraContext,
|
||||||
|
});
|
||||||
|
setSuggestion(result.result.text?.trim() ?? "");
|
||||||
|
} catch (err) {
|
||||||
|
setError(getApiErrorMessage(err, t("interviewAiFailed")));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const accept = async () => {
|
||||||
|
if (!suggestion) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await interviewPrepApi.add(jobId, {
|
||||||
|
category: selected.category,
|
||||||
|
title: t(selected.labelKey),
|
||||||
|
content: suggestion,
|
||||||
|
source: "ai",
|
||||||
|
});
|
||||||
|
setSuggestion("");
|
||||||
|
onAccepted();
|
||||||
|
} catch (err) {
|
||||||
|
setError(getApiErrorMessage(err, t("interviewAiAcceptFailed")));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasCv = !!cv?.attachedVariantId;
|
||||||
|
return (
|
||||||
|
<Shell title={t("interviewAiTitle")} subtitle={t("interviewAiSubtitle")} loading={false} error={null}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{hasCv ? (
|
||||||
|
<Alert severity="success" variant="outlined">{t("interviewAiUsing")} <strong>{cv?.attachedVariantName}</strong></Alert>
|
||||||
|
) : (
|
||||||
|
<Alert severity="info">{t("interviewAiSelectCv")}</Alert>
|
||||||
|
)}
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
|
||||||
|
<TextField select size="small" label={t("interviewAiFocus")} value={focus} onChange={(event) => setFocus(event.target.value as typeof focus)} sx={{ minWidth: 220 }}>
|
||||||
|
{INTERVIEW_AI_FOCUS.map((item) => <MenuItem key={item.key} value={item.key}>{t(item.labelKey)}</MenuItem>)}
|
||||||
|
</TextField>
|
||||||
|
<TextField select size="small" label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")} sx={{ minWidth: 190 }}>
|
||||||
|
<MenuItem value="en">English</MenuItem>
|
||||||
|
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Stack>
|
||||||
|
<TextField multiline minRows={2} fullWidth label={t("coverAiAdditionalInstructions")} value={instructions} onChange={(event) => setInstructions(event.target.value)} />
|
||||||
|
{error && <Alert severity="error">{error}</Alert>}
|
||||||
|
<Button variant="contained" startIcon={<AutoFixHighIcon />} disabled={!canUseAi || !hasCv || busy} onClick={() => void generate()} sx={{ alignSelf: "flex-start" }}>
|
||||||
|
{busy ? t("interviewAiGenerating") : canUseAi ? t("interviewAiGenerate") : t("interviewAiProRequired")}
|
||||||
|
</Button>
|
||||||
|
{suggestion && (
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, borderColor: "primary.main" }}>
|
||||||
|
<Typography variant="overline" color="primary">{t("coverAiSuggestion")}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{suggestion}</Typography>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
|
||||||
|
<Button size="small" variant="contained" disabled={busy} onClick={() => void accept()}>{t("interviewAiAddToPrep")}</Button>
|
||||||
|
<Button size="small" disabled={busy} onClick={() => setSuggestion("")}>{t("coverAiReject")}</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// One prep entry. The answer is a local draft until saved, so a background reload never eats typing.
|
// One prep entry. The answer is a local draft until saved, so a background reload never eats typing.
|
||||||
function PrepRow({ jobId, item, busy, onChanged, onError }: {
|
function PrepRow({ jobId, item, busy, onChanged, onError }: {
|
||||||
jobId: number;
|
jobId: number;
|
||||||
@@ -187,6 +303,8 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: {
|
|||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { confirm } = useConfirm();
|
||||||
const [draft, setDraft] = useState<string | null>(null);
|
const [draft, setDraft] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const content = draft ?? item.content ?? "";
|
const content = draft ?? item.content ?? "";
|
||||||
@@ -212,7 +330,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: {
|
|||||||
size="small"
|
size="small"
|
||||||
checked={item.isPrepared}
|
checked={item.isPrepared}
|
||||||
disabled={busy || saving}
|
disabled={busy || saving}
|
||||||
inputProps={{ "aria-label": `Ready: ${item.title}` }}
|
inputProps={{ "aria-label": t("interviewPrepReadyLabel", { title: item.title }) }}
|
||||||
onChange={() => run(() => interviewPrepApi.update(jobId, item.id, { isPrepared: !item.isPrepared }))}
|
onChange={() => run(() => interviewPrepApi.update(jobId, item.id, { isPrepared: !item.isPrepared }))}
|
||||||
sx={{ mt: -0.5 }}
|
sx={{ mt: -0.5 }}
|
||||||
/>
|
/>
|
||||||
@@ -220,7 +338,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: {
|
|||||||
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
|
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{item.title}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{item.title}</Typography>
|
||||||
{item.source === "ai" && (
|
{item.source === "ai" && (
|
||||||
<Chip size="small" label="From AI" variant="outlined" />
|
<Chip size="small" label={t("interviewPrepFromAi")} variant="outlined" />
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -228,7 +346,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: {
|
|||||||
minRows={2}
|
minRows={2}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="Your answer, in your own words."
|
placeholder={t("interviewPrepAnswerPlaceholder")}
|
||||||
value={content}
|
value={content}
|
||||||
disabled={busy || saving}
|
disabled={busy || saving}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
@@ -242,19 +360,27 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: {
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={() => run(() => interviewPrepApi.update(jobId, item.id, { content }))}
|
onClick={() => run(() => interviewPrepApi.update(jobId, item.id, { content }))}
|
||||||
>
|
>
|
||||||
Save answer
|
{t("interviewPrepSaveAnswer")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="small" disabled={saving} onClick={() => setDraft(null)}>Discard</Button>
|
<Button size="small" disabled={saving} onClick={() => setDraft(null)}>{t("interviewPrepDiscard")}</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Tooltip title="Delete">
|
<Tooltip title={t("interviewPrepDelete")}>
|
||||||
<span>
|
<span>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
disabled={busy || saving}
|
disabled={busy || saving}
|
||||||
aria-label={`Delete: ${item.title}`}
|
aria-label={t("interviewPrepDeleteLabel", { title: item.title })}
|
||||||
onClick={() => run(() => interviewPrepApi.remove(jobId, item.id))}
|
onClick={async () => {
|
||||||
|
const accepted = await confirm({
|
||||||
|
title: t("interviewPrepDeleteTitle"),
|
||||||
|
message: t("interviewPrepDeleteMessage", { title: item.title }),
|
||||||
|
confirmLabel: t("interviewPrepDelete"),
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
if (accepted) await run(() => interviewPrepApi.remove(jobId, item.id));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DeleteOutlineIcon fontSize="inherit" />
|
<DeleteOutlineIcon fontSize="inherit" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -266,6 +392,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ApplicationFollowUp({ jobId }: { jobId: number }) {
|
export function ApplicationFollowUp({ jobId }: { jobId: number }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [data, setData] = useState<FollowUp | null>(null);
|
const [data, setData] = useState<FollowUp | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -307,16 +434,15 @@ export function ApplicationFollowUp({ jobId }: { jobId: number }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell
|
<Shell
|
||||||
title="Follow-up"
|
title={t("interviewFollowUpTitle")}
|
||||||
subtitle="The same date the reminder service already uses. Follow-up tasks live in the checklist."
|
subtitle={t("interviewFollowUpSubtitle")}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
>
|
>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
{data && data.openFollowUpTasks > 0 && (
|
{data && data.openFollowUpTasks > 0 && (
|
||||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||||
{data.openFollowUpTasks} open follow-up {data.openFollowUpTasks === 1 ? "task" : "tasks"} on
|
{t(data.openFollowUpTasks === 1 ? "interviewFollowUpOpenTask" : "interviewFollowUpOpenTasks", { count: data.openFollowUpTasks })}
|
||||||
the checklist.
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -324,7 +450,7 @@ export function ApplicationFollowUp({ jobId }: { jobId: number }) {
|
|||||||
<TextField
|
<TextField
|
||||||
type="date"
|
type="date"
|
||||||
size="small"
|
size="small"
|
||||||
label="Follow up on"
|
label={t("interviewFollowUpDate")}
|
||||||
value={date}
|
value={date}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => setDate(e.target.value)}
|
onChange={(e) => setDate(e.target.value)}
|
||||||
@@ -333,17 +459,17 @@ export function ApplicationFollowUp({ jobId }: { jobId: number }) {
|
|||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
label="Next action"
|
label={t("workspaceNextAction")}
|
||||||
value={action}
|
value={action}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => setAction(e.target.value)}
|
onChange={(e) => setAction(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button variant="contained" disabled={busy} onClick={save}>Save</Button>
|
<Button variant="contained" disabled={busy} onClick={save}>{t("save")}</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{data && !data.followUpAt && (
|
{data && !data.followUpAt && (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
No follow-up scheduled. Applications without one go quiet.
|
{t("interviewFollowUpEmpty")}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -91,6 +91,46 @@ export const translations = {
|
|||||||
confirmDestructiveHint: "This action may be hard to undo.",
|
confirmDestructiveHint: "This action may be hard to undo.",
|
||||||
confirmStandardHint: "Please confirm this action.",
|
confirmStandardHint: "Please confirm this action.",
|
||||||
enterValue: "Enter value",
|
enterValue: "Enter value",
|
||||||
|
interviewPrepTitle: "Interview preparation",
|
||||||
|
interviewPrepSubtitle: "Your research, answers and questions. AI suggestions are only saved when you accept them.",
|
||||||
|
interviewPrepEarly: "This application has not reached an interview stage yet. Preparing early is fine.",
|
||||||
|
interviewPrepProgress: "Interview preparation progress",
|
||||||
|
interviewPrepReadyCount: "{prepared} of {total} ready",
|
||||||
|
interviewPrepEmpty: "Nothing prepared yet. Add an expected question, a company fact worth knowing, or a STAR example you want ready.",
|
||||||
|
interviewPrepCategory: "Category",
|
||||||
|
interviewPrepAddLabel: "Add a question, topic or note",
|
||||||
|
interviewPrepAdd: "Add",
|
||||||
|
interviewAiTitle: "AI interview coach",
|
||||||
|
interviewAiSubtitle: "Uses the linked CV, job advert and application analysis. Suggestions never overwrite your notes.",
|
||||||
|
interviewAiUsing: "Using",
|
||||||
|
interviewAiSelectCv: "Select a CV on the CV tab before generating tailored interview preparation.",
|
||||||
|
interviewAiFocus: "Preparation focus",
|
||||||
|
interviewAiFull: "Complete interview brief",
|
||||||
|
interviewAiTechnical: "Technical questions",
|
||||||
|
interviewAiBehavioural: "Behavioural questions",
|
||||||
|
interviewAiStar: "STAR preparation",
|
||||||
|
interviewAiGaps: "Weak areas and likely concerns",
|
||||||
|
interviewAiGenerate: "Generate preparation",
|
||||||
|
interviewAiGenerating: "Generating…",
|
||||||
|
interviewAiProRequired: "Pro required",
|
||||||
|
interviewAiAddToPrep: "Add to preparation",
|
||||||
|
interviewAiFailed: "Could not generate interview preparation.",
|
||||||
|
interviewAiAcceptFailed: "Could not add the suggestion to your preparation.",
|
||||||
|
interviewPrepReadyLabel: "Ready: {title}",
|
||||||
|
interviewPrepFromAi: "From AI",
|
||||||
|
interviewPrepAnswerPlaceholder: "Your answer, in your own words.",
|
||||||
|
interviewPrepSaveAnswer: "Save answer",
|
||||||
|
interviewPrepDiscard: "Discard",
|
||||||
|
interviewPrepDelete: "Delete",
|
||||||
|
interviewPrepDeleteLabel: "Delete: {title}",
|
||||||
|
interviewPrepDeleteTitle: "Delete preparation item?",
|
||||||
|
interviewPrepDeleteMessage: "Delete “{title}”? This cannot be undone.",
|
||||||
|
interviewFollowUpTitle: "Follow-up",
|
||||||
|
interviewFollowUpSubtitle: "Uses the same date as reminders. Follow-up tasks remain in the application checklist.",
|
||||||
|
interviewFollowUpOpenTask: "{count} open follow-up task on the checklist.",
|
||||||
|
interviewFollowUpOpenTasks: "{count} open follow-up tasks on the checklist.",
|
||||||
|
interviewFollowUpDate: "Follow up on",
|
||||||
|
interviewFollowUpEmpty: "No follow-up scheduled. Applications without one can go quiet.",
|
||||||
notFoundTitle: "Page not found",
|
notFoundTitle: "Page not found",
|
||||||
notFoundBody: "The page you were looking for does not exist or may have moved.",
|
notFoundBody: "The page you were looking for does not exist or may have moved.",
|
||||||
appErrorTitle: "Something went wrong",
|
appErrorTitle: "Something went wrong",
|
||||||
@@ -1318,6 +1358,46 @@ export const translations = {
|
|||||||
confirmDestructiveHint: "Denne handlingen kan være vanskelig å angre.",
|
confirmDestructiveHint: "Denne handlingen kan være vanskelig å angre.",
|
||||||
confirmStandardHint: "Bekreft denne handlingen.",
|
confirmStandardHint: "Bekreft denne handlingen.",
|
||||||
enterValue: "Skriv inn verdi",
|
enterValue: "Skriv inn verdi",
|
||||||
|
interviewPrepTitle: "Intervjuforberedelse",
|
||||||
|
interviewPrepSubtitle: "Din research, dine svar og spørsmål. AI-forslag lagres bare når du godtar dem.",
|
||||||
|
interviewPrepEarly: "Søknaden har ikke nådd intervjustadiet ennå. Det er helt greit å forberede seg tidlig.",
|
||||||
|
interviewPrepProgress: "Fremdrift for intervjuforberedelse",
|
||||||
|
interviewPrepReadyCount: "{prepared} av {total} klare",
|
||||||
|
interviewPrepEmpty: "Ingenting er forberedt ennå. Legg til et forventet spørsmål, et relevant selskapsfakta eller et STAR-eksempel.",
|
||||||
|
interviewPrepCategory: "Kategori",
|
||||||
|
interviewPrepAddLabel: "Legg til spørsmål, tema eller notat",
|
||||||
|
interviewPrepAdd: "Legg til",
|
||||||
|
interviewAiTitle: "AI-intervjutrening",
|
||||||
|
interviewAiSubtitle: "Bruker tilknyttet CV, stillingsannonse og søknadsanalyse. Forslag overskriver aldri notatene dine.",
|
||||||
|
interviewAiUsing: "Bruker",
|
||||||
|
interviewAiSelectCv: "Velg en CV i CV-fanen før du genererer tilpasset intervjuforberedelse.",
|
||||||
|
interviewAiFocus: "Fokus for forberedelsen",
|
||||||
|
interviewAiFull: "Komplett intervjuforberedelse",
|
||||||
|
interviewAiTechnical: "Tekniske spørsmål",
|
||||||
|
interviewAiBehavioural: "Atferdsbaserte spørsmål",
|
||||||
|
interviewAiStar: "STAR-forberedelse",
|
||||||
|
interviewAiGaps: "Svake områder og sannsynlige bekymringer",
|
||||||
|
interviewAiGenerate: "Generer forberedelse",
|
||||||
|
interviewAiGenerating: "Genererer…",
|
||||||
|
interviewAiProRequired: "Krever Pro",
|
||||||
|
interviewAiAddToPrep: "Legg til i forberedelsen",
|
||||||
|
interviewAiFailed: "Kunne ikke generere intervjuforberedelse.",
|
||||||
|
interviewAiAcceptFailed: "Kunne ikke legge forslaget til i forberedelsen.",
|
||||||
|
interviewPrepReadyLabel: "Klar: {title}",
|
||||||
|
interviewPrepFromAi: "Fra AI",
|
||||||
|
interviewPrepAnswerPlaceholder: "Svaret ditt, med egne ord.",
|
||||||
|
interviewPrepSaveAnswer: "Lagre svar",
|
||||||
|
interviewPrepDiscard: "Forkast",
|
||||||
|
interviewPrepDelete: "Slett",
|
||||||
|
interviewPrepDeleteLabel: "Slett: {title}",
|
||||||
|
interviewPrepDeleteTitle: "Slette forberedelseselement?",
|
||||||
|
interviewPrepDeleteMessage: "Slette «{title}»? Dette kan ikke angres.",
|
||||||
|
interviewFollowUpTitle: "Oppfølging",
|
||||||
|
interviewFollowUpSubtitle: "Bruker samme dato som påminnelser. Oppfølgingsoppgaver forblir i søknadssjekklisten.",
|
||||||
|
interviewFollowUpOpenTask: "{count} åpen oppfølgingsoppgave i sjekklisten.",
|
||||||
|
interviewFollowUpOpenTasks: "{count} åpne oppfølgingsoppgaver i sjekklisten.",
|
||||||
|
interviewFollowUpDate: "Følg opp den",
|
||||||
|
interviewFollowUpEmpty: "Ingen oppfølging er planlagt. Søknader uten oppfølging kan bli stille.",
|
||||||
notFoundTitle: "Siden ble ikke funnet",
|
notFoundTitle: "Siden ble ikke funnet",
|
||||||
notFoundBody: "Siden du lette etter finnes ikke eller kan ha blitt flyttet.",
|
notFoundBody: "Siden du lette etter finnes ikke eller kan ha blitt flyttet.",
|
||||||
appErrorTitle: "Noe gikk galt",
|
appErrorTitle: "Noe gikk galt",
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
import { ApplicationFollowUp, ApplicationInterviewPrep } from "./components/InterviewPrep";
|
import { ApplicationFollowUp, ApplicationInterviewPrep } from "./components/InterviewPrep";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
|
import { I18nProvider } from "./i18n/I18nProvider";
|
||||||
|
import { ConfirmProvider } from "./confirm";
|
||||||
|
|
||||||
|
const render = (ui: React.ReactElement) => rtlRender(<I18nProvider><ConfirmProvider>{ui}</ConfirmProvider></I18nProvider>);
|
||||||
|
|
||||||
jest.mock("./api", () => ({
|
jest.mock("./api", () => ({
|
||||||
api: {
|
api: {
|
||||||
@@ -48,6 +52,7 @@ const followUp = { followUpAt: "2026-07-26T00:00:00", nextAction: "Chase recruit
|
|||||||
function routeGet(overrides: Record<string, any> = {}) {
|
function routeGet(overrides: Record<string, any> = {}) {
|
||||||
mockedApi.get.mockImplementation((url: string) => {
|
mockedApi.get.mockImplementation((url: string) => {
|
||||||
if (url.endsWith("/follow-up")) return Promise.resolve({ data: overrides.followUp ?? followUp } as any);
|
if (url.endsWith("/follow-up")) return Promise.resolve({ data: overrides.followUp ?? followUp } as any);
|
||||||
|
if (url.endsWith("/cv")) return Promise.resolve({ data: overrides.cv ?? { attachedVariantId: 3, attachedVariantName: "Backend CV" } } as any);
|
||||||
return Promise.resolve({ data: overrides.board ?? board } as any);
|
return Promise.resolve({ data: overrides.board ?? board } as any);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -116,6 +121,7 @@ test("deleting a prep item calls delete", async () => {
|
|||||||
|
|
||||||
render(<ApplicationInterviewPrep jobId={7} />);
|
render(<ApplicationInterviewPrep jobId={7} />);
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Delete: Funding history" }));
|
fireEvent.click(await screen.findByRole("button", { name: "Delete: Funding history" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
|
||||||
|
|
||||||
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/jobapplications/7/interview-prep/1"));
|
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/jobapplications/7/interview-prep/1"));
|
||||||
});
|
});
|
||||||
@@ -129,6 +135,36 @@ test("empty prep shows a useful empty state", async () => {
|
|||||||
expect(screen.getByText(/has not reached an interview stage/i)).toBeInTheDocument();
|
expect(screen.getByText(/has not reached an interview stage/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("AI preparation uses the linked CV and only saves after explicit acceptance", async () => {
|
||||||
|
routeGet();
|
||||||
|
mockedApi.post.mockImplementation((url: string) => {
|
||||||
|
if (url.endsWith("/ai/generate")) {
|
||||||
|
return Promise.resolve({ data: { result: { text: "## Technical\nExplain dependency injection." } } } as any);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: board.groups[0].items[0] } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ApplicationInterviewPrep jobId={7} />);
|
||||||
|
expect(await screen.findByText("Backend CV")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Generate preparation" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Explain dependency injection/i)).toBeInTheDocument();
|
||||||
|
expect(mockedApi.post).toHaveBeenCalledWith(
|
||||||
|
"/jobapplications/7/ai/generate",
|
||||||
|
expect.objectContaining({ module: "interview", targetLanguage: "en" }),
|
||||||
|
);
|
||||||
|
expect(mockedApi.post).not.toHaveBeenCalledWith(
|
||||||
|
"/jobapplications/7/interview-prep",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add to preparation" }));
|
||||||
|
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(
|
||||||
|
"/jobapplications/7/interview-prep",
|
||||||
|
expect.objectContaining({ source: "ai", content: expect.stringContaining("dependency injection") }),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
test("prep surfaces a load error", async () => {
|
test("prep surfaces a load error", async () => {
|
||||||
mockedApi.get.mockRejectedValue(new Error("boom"));
|
mockedApi.get.mockRejectedValue(new Error("boom"));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user