import React, { useCallback, useEffect, useState } from "react"; import { Alert, Box, Button, Checkbox, Chip, Divider, IconButton, LinearProgress, MenuItem, Paper, Skeleton, 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"; // 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 ( {title} {subtitle && {subtitle}} {loading ? ( {[0, 1, 2].map((i) => )} ) : error ? ( {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) => ( {interviewCategoryLabel(t, c.key)} ))} 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) => {t(item.labelKey)})} setLanguage(event.target.value as "en" | "nb-NO")} sx={{ minWidth: 190 }}> {t("languageEnglish")} {t("languageNorwegianBokmal")} setInstructions(event.target.value)} /> {error && {error}} {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; }