507 lines
19 KiB
TypeScript
507 lines
19 KiB
TypeScript
import React, { useCallback, useEffect, useState } from "react";
|
|
|
|
import {
|
|
Alert, Box, Button, Checkbox, Chip, IconButton, LinearProgress, MenuItem, Paper,
|
|
Stack, TextField, Tooltip, Typography,
|
|
} from "@mui/material";
|
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
|
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
|
|
|
import { getApiErrorMessage } from "../api";
|
|
import {
|
|
FollowUp, INTERVIEW_PREP_CATEGORIES, InterviewPrepBoard, InterviewPrepItem, interviewPrepApi,
|
|
ApplicationCv, applicationAssetsApi,
|
|
} from "../applicationWorkspace";
|
|
import { aiWorkspaceApi } from "../aiWorkspace";
|
|
import { useAccountPlan } from "../accountPlan";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
import { useConfirm } from "../confirm";
|
|
import { PageSection } from "../layout/PageLayout";
|
|
import ViewStateNotice from "./ViewStateNotice";
|
|
|
|
// Phase 5.5 — Interview preparation and follow-up.
|
|
//
|
|
// The prep content is the user's. AI suggestions are generated in this dedicated workspace and only
|
|
// become prep items after an explicit accept action.
|
|
// docs/architecture/overview.md.
|
|
|
|
function Shell({ title, subtitle, loading, error, children }: {
|
|
title: string;
|
|
subtitle?: string;
|
|
loading: boolean;
|
|
error: string | null;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<PageSection surface title={title} description={subtitle} level="h3">
|
|
<ViewStateNotice title={title} loading={loading} error={error ? { kind: "error", message: error, retryable: false } : null} compact />
|
|
{!loading && !error && children}
|
|
</PageSection>
|
|
);
|
|
}
|
|
|
|
export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
|
|
const { t } = useI18n();
|
|
const [board, setBoard] = useState<InterviewPrepBoard | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
const [title, setTitle] = useState("");
|
|
const [category, setCategory] = useState(INTERVIEW_PREP_CATEGORIES[0].key);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
setBoard(await interviewPrepApi.get(jobId));
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(getApiErrorMessage(err, t("interviewPrepLoadFailed")));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [jobId, t]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
const mutate = async (run: () => Promise<unknown>) => {
|
|
setBusy(true);
|
|
try {
|
|
await run();
|
|
await load();
|
|
} catch (err) {
|
|
setError(getApiErrorMessage(err, t("interviewPrepUpdateFailed")));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const add = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const value = title.trim();
|
|
if (!value) return;
|
|
setTitle("");
|
|
return mutate(() => interviewPrepApi.add(jobId, { category, title: value }));
|
|
};
|
|
|
|
const startDebrief = () => mutate(() => interviewPrepApi.add(jobId, {
|
|
category: "debrief",
|
|
title: t("interviewDebriefDefaultTitle"),
|
|
content: t("interviewDebriefTemplate"),
|
|
}));
|
|
|
|
const hasDebrief = board?.groups.some((group) => group.category === "debrief") ?? false;
|
|
|
|
return (
|
|
<Stack spacing={2}>
|
|
<Shell
|
|
title={t("interviewPrepTitle")}
|
|
subtitle={t("interviewPrepSubtitle")}
|
|
loading={loading}
|
|
error={error}
|
|
>
|
|
<Stack spacing={2}>
|
|
{board && !board.isInterviewStage && (
|
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
|
{t("interviewPrepEarly")}
|
|
</Alert>
|
|
)}
|
|
|
|
{board && board.total > 0 && (
|
|
<Box>
|
|
<Stack direction="row" justifyContent="space-between" alignItems="baseline" sx={{ mb: 0.75 }}>
|
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 700 }}>
|
|
{t("interviewPrepProgress")}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{t("interviewPrepReadyCount", { prepared: board.prepared, total: board.total })}
|
|
</Typography>
|
|
</Stack>
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={board.percent}
|
|
aria-label={t("interviewPrepProgress")}
|
|
sx={{ height: 8, borderRadius: 4 }}
|
|
/>
|
|
</Box>
|
|
)}
|
|
|
|
{board && board.total === 0 ? (
|
|
<Typography variant="body2" color="text.secondary">
|
|
{t("interviewPrepEmpty")}
|
|
</Typography>
|
|
) : (
|
|
(board?.groups ?? []).map((group) => (
|
|
<Box key={group.category}>
|
|
<Typography variant="caption" sx={{ fontWeight: 800, textTransform: "uppercase", letterSpacing: ".06em", color: "text.secondary" }}>
|
|
{interviewCategoryLabel(t, group.category)}
|
|
</Typography>
|
|
<Stack sx={{ mt: 0.5 }}>
|
|
{group.items.map((item) => (
|
|
<PrepRow
|
|
key={item.id}
|
|
jobId={jobId}
|
|
item={item}
|
|
busy={busy}
|
|
onChanged={load}
|
|
onError={setError}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
))
|
|
)}
|
|
|
|
{board?.isInterviewStage && !hasDebrief ? (
|
|
<Paper variant="outlined" sx={{ p: 2, bgcolor: "action.hover" }}>
|
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5} alignItems={{ sm: "center" }} justifyContent="space-between">
|
|
<Box>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("interviewDebriefTitle")}</Typography>
|
|
<Typography variant="body2" color="text.secondary">{t("interviewDebriefHelp")}</Typography>
|
|
</Box>
|
|
<Button variant="outlined" disabled={busy} onClick={() => void startDebrief()}>{t("interviewDebriefStart")}</Button>
|
|
</Stack>
|
|
</Paper>
|
|
) : null}
|
|
|
|
<Box component="form" onSubmit={add}>
|
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
|
|
<TextField
|
|
select
|
|
size="small"
|
|
label={t("interviewPrepCategory")}
|
|
value={category}
|
|
disabled={busy}
|
|
onChange={(e) => setCategory(e.target.value)}
|
|
sx={{ minWidth: { sm: 200 } }}
|
|
>
|
|
{INTERVIEW_PREP_CATEGORIES.map((c) => (
|
|
<MenuItem key={c.key} value={c.key}>{interviewCategoryLabel(t, c.key)}</MenuItem>
|
|
))}
|
|
</TextField>
|
|
<TextField
|
|
fullWidth
|
|
size="small"
|
|
label={t("interviewPrepAddLabel")}
|
|
value={title}
|
|
disabled={busy}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
/>
|
|
<Button type="submit" variant="contained" disabled={busy || !title.trim()}>{t("interviewPrepAdd")}</Button>
|
|
</Stack>
|
|
</Box>
|
|
</Stack>
|
|
</Shell>
|
|
|
|
<InterviewAiAssistant jobId={jobId} onAccepted={load} />
|
|
|
|
<ApplicationFollowUp jobId={jobId} />
|
|
</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">{t("languageEnglish")}</MenuItem>
|
|
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</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.
|
|
function PrepRow({ jobId, item, busy, onChanged, onError }: {
|
|
jobId: number;
|
|
item: InterviewPrepItem;
|
|
busy: boolean;
|
|
onChanged: () => void;
|
|
onError: (message: string) => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const { confirm } = useConfirm();
|
|
const [draft, setDraft] = useState<string | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const content = draft ?? item.content ?? "";
|
|
const dirty = draft !== null && draft !== (item.content ?? "");
|
|
|
|
const run = async (fn: () => Promise<unknown>) => {
|
|
setSaving(true);
|
|
try {
|
|
await fn();
|
|
setDraft(null);
|
|
onChanged();
|
|
} catch (err) {
|
|
onError(getApiErrorMessage(err, t("interviewPrepAnswerSaveFailed")));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Box sx={{ py: 1, borderBottom: "1px solid", borderColor: "divider" }}>
|
|
<Stack direction="row" alignItems="flex-start" spacing={1}>
|
|
<Checkbox
|
|
size="small"
|
|
checked={item.isPrepared}
|
|
disabled={busy || saving}
|
|
inputProps={{ "aria-label": t("interviewPrepReadyLabel", { title: item.title }) }}
|
|
onChange={() => run(() => interviewPrepApi.update(jobId, item.id, { isPrepared: !item.isPrepared }))}
|
|
sx={{ mt: -0.5 }}
|
|
/>
|
|
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
|
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
|
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{item.title}</Typography>
|
|
{item.source === "ai" && (
|
|
<Chip size="small" label={t("interviewPrepFromAi")} variant="outlined" />
|
|
)}
|
|
</Stack>
|
|
<TextField
|
|
multiline
|
|
minRows={2}
|
|
fullWidth
|
|
size="small"
|
|
placeholder={t("interviewPrepAnswerPlaceholder")}
|
|
inputProps={{ "aria-label": t("interviewPrepAnswerFor", { title: item.title }) }}
|
|
value={content}
|
|
disabled={busy || saving}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
sx={{ mt: 0.75 }}
|
|
/>
|
|
{dirty && (
|
|
<Stack direction="row" spacing={1} sx={{ mt: 0.75 }}>
|
|
<Button
|
|
size="small"
|
|
variant="contained"
|
|
disabled={saving}
|
|
onClick={() => run(() => interviewPrepApi.update(jobId, item.id, { content }))}
|
|
>
|
|
{t("interviewPrepSaveAnswer")}
|
|
</Button>
|
|
<Button size="small" disabled={saving} onClick={() => setDraft(null)}>{t("interviewPrepDiscard")}</Button>
|
|
</Stack>
|
|
)}
|
|
</Box>
|
|
<Tooltip title={t("interviewPrepDelete")}>
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
disabled={busy || saving}
|
|
aria-label={t("interviewPrepDeleteLabel", { title: item.title })}
|
|
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" />
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
</Stack>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export function ApplicationFollowUp({ jobId }: { jobId: number }) {
|
|
const { t } = useI18n();
|
|
const [data, setData] = useState<FollowUp | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
const [date, setDate] = useState<string>("");
|
|
const [action, setAction] = useState<string>("");
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const result = await interviewPrepApi.followUp(jobId);
|
|
setData(result);
|
|
setDate(result.followUpAt ? result.followUpAt.slice(0, 10) : "");
|
|
setAction(result.nextAction ?? "");
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(getApiErrorMessage(err, t("interviewFollowUpLoadFailed")));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [jobId, t]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
const save = async () => {
|
|
setBusy(true);
|
|
try {
|
|
const result = await interviewPrepApi.setFollowUp(jobId, date || null, action || null);
|
|
setData(result);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(getApiErrorMessage(err, t("interviewFollowUpSaveFailed")));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Shell
|
|
title={t("interviewFollowUpTitle")}
|
|
subtitle={t("interviewFollowUpSubtitle")}
|
|
loading={loading}
|
|
error={error}
|
|
>
|
|
<Stack spacing={2}>
|
|
{data && data.openFollowUpTasks > 0 && (
|
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
|
{t(data.openFollowUpTasks === 1 ? "interviewFollowUpOpenTask" : "interviewFollowUpOpenTasks", { count: data.openFollowUpTasks })}
|
|
</Alert>
|
|
)}
|
|
|
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
|
|
<TextField
|
|
type="date"
|
|
size="small"
|
|
label={t("interviewFollowUpDate")}
|
|
value={date}
|
|
disabled={busy}
|
|
onChange={(e) => setDate(e.target.value)}
|
|
InputLabelProps={{ shrink: true }}
|
|
/>
|
|
<TextField
|
|
fullWidth
|
|
size="small"
|
|
label={t("workspaceNextAction")}
|
|
value={action}
|
|
disabled={busy}
|
|
onChange={(e) => setAction(e.target.value)}
|
|
/>
|
|
<Button variant="contained" disabled={busy} onClick={save}>{t("save")}</Button>
|
|
</Stack>
|
|
|
|
{data && !data.followUpAt && (
|
|
<Typography variant="body2" color="text.secondary">
|
|
{t("interviewFollowUpEmpty")}
|
|
</Typography>
|
|
)}
|
|
</Stack>
|
|
</Shell>
|
|
);
|
|
}
|
|
|
|
function interviewCategoryLabel(t: (key: any) => string, category: string) {
|
|
const keys: Record<string, string> = {
|
|
"company-research": "interviewCategoryCompanyResearch",
|
|
technical: "interviewCategoryTechnical",
|
|
behavioural: "interviewCategoryBehavioural",
|
|
star: "interviewCategoryStar",
|
|
question: "interviewCategoryQuestions",
|
|
note: "interviewCategoryNotes",
|
|
debrief: "interviewCategoryDebrief",
|
|
};
|
|
return keys[category] ? t(keys[category]) : category;
|
|
}
|