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 (
{!loading && !error && children}
);
}
export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
const { t } = useI18n();
const [board, setBoard] = useState(null);
const [error, setError] = useState(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) => {
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 (
{board && !board.isInterviewStage && (
{t("interviewPrepEarly")}
)}
{board && board.total > 0 && (
{t("interviewPrepProgress")}
{t("interviewPrepReadyCount", { prepared: board.prepared, total: board.total })}
)}
{board && board.total === 0 ? (
{t("interviewPrepEmpty")}
) : (
(board?.groups ?? []).map((group) => (
{interviewCategoryLabel(t, group.category)}
{group.items.map((item) => (
))}
))
)}
{board?.isInterviewStage && !hasDebrief ? (
{t("interviewDebriefTitle")}
{t("interviewDebriefHelp")}
) : null}
setCategory(e.target.value)}
sx={{ minWidth: { sm: 200 } }}
>
{INTERVIEW_PREP_CATEGORIES.map((c) => (
))}
setTitle(e.target.value)}
/>
);
}
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(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(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 (
{hasCv ? (
{t("interviewAiUsing")} {cv?.attachedVariantName}
) : (
{t("interviewAiSelectCv")}
)}
setFocus(event.target.value as typeof focus)} sx={{ minWidth: 220 }}>
{INTERVIEW_AI_FOCUS.map((item) => )}
setLanguage(event.target.value as "en" | "nb-NO")} sx={{ minWidth: 190 }}>
setInstructions(event.target.value)} />
{error && {error}}
} disabled={!canUseAi || !hasCv || busy} onClick={() => void generate()} sx={{ alignSelf: "flex-start" }}>
{busy ? t("interviewAiGenerating") : canUseAi ? t("interviewAiGenerate") : t("interviewAiProRequired")}
{suggestion && (
{t("coverAiSuggestion")}
{suggestion}
)}
);
}
// 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(null);
const [saving, setSaving] = useState(false);
const content = draft ?? item.content ?? "";
const dirty = draft !== null && draft !== (item.content ?? "");
const run = async (fn: () => Promise) => {
setSaving(true);
try {
await fn();
setDraft(null);
onChanged();
} catch (err) {
onError(getApiErrorMessage(err, t("interviewPrepAnswerSaveFailed")));
} finally {
setSaving(false);
}
};
return (
run(() => interviewPrepApi.update(jobId, item.id, { isPrepared: !item.isPrepared }))}
sx={{ mt: -0.5 }}
/>
{item.title}
{item.source === "ai" && (
)}
setDraft(e.target.value)}
sx={{ mt: 0.75 }}
/>
{dirty && (
)}
{
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));
}}
>
);
}
export function ApplicationFollowUp({ jobId }: { jobId: number }) {
const { t } = useI18n();
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [date, setDate] = useState("");
const [action, setAction] = useState("");
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 (
{data && data.openFollowUpTasks > 0 && (
{t(data.openFollowUpTasks === 1 ? "interviewFollowUpOpenTask" : "interviewFollowUpOpenTasks", { count: data.openFollowUpTasks })}
)}
setDate(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
setAction(e.target.value)}
/>
{data && !data.followUpAt && (
{t("interviewFollowUpEmpty")}
)}
);
}
function interviewCategoryLabel(t: (key: any) => string, category: string) {
const keys: Record = {
"company-research": "interviewCategoryCompanyResearch",
technical: "interviewCategoryTechnical",
behavioural: "interviewCategoryBehavioural",
star: "interviewCategoryStar",
question: "interviewCategoryQuestions",
note: "interviewCategoryNotes",
debrief: "interviewCategoryDebrief",
};
return keys[category] ? t(keys[category]) : category;
}