feat(workspace): interview and follow-up workflow
Phase 5.5. Completes the lifecycle after submission: prepare, communicate, chase. Interview preparation gets a durable, user-owned store. There were already two per-application AI stores, InterviewPrepNote and AiWorkspaceNote, but both are caches that regenerate when their context signature changes — anything a user typed into them would eventually be overwritten. InterviewPrepItem is the side nothing regenerates, covering company research, technical notes, behavioural answers, STAR examples and the user's own questions in one table, because those categories differ only by label and adding one must not need a migration. Each item records whether the user wrote it or accepted a suggestion, and an IsPrepared flag makes the section double as the preparation checklist. Generation stays in the existing AiWorkspaceService "interview" module, appended to AiInteraction as before. A suggestion is history until the user adds it as a prep item; opening the section generates nothing. Follow-up reuses what exists rather than adding a tracker. The date is JobApplication.FollowUpAt, the same field RulesEngine and the reminder hosted service already act on, so reminders keep working with no new wiring. The task stays an ApplicationChecklistItem in the follow-up category — the section counts open tasks without owning them. The record is a FollowUpSet JobEvent, the same type the rest of the app emits. Communication is untouched: Correspondence already owns recruiter contacts, history and notes, and the workspace already mounted it. The timeline interpreter learned five more types — InterviewScheduled, InterviewCompleted and OfferReceived as milestones, FollowUpCreated and FollowUpCompleted as routine, deliberately outside the milestone spine so it stays a summary of what actually happened. JobEvent remains the history source. InterviewPrepItems is reconciler-owned with a no-op migration, guarded on JobApplications, and verified on a fresh MariaDB 11: int AUTO_INCREMENT primary key, varchar owner and title, tinyint flag, datetime(6), composite index inside the key limit. 371 backend tests, 128 frontend tests, Release build and the production build all pass locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
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 { getApiErrorMessage } from "../api";
|
||||
import {
|
||||
FollowUp, INTERVIEW_PREP_CATEGORIES, InterviewPrepBoard, InterviewPrepItem, interviewPrepApi,
|
||||
} from "../applicationWorkspace";
|
||||
|
||||
// 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
|
||||
// AI panel below and only become prep items when the user adds them.
|
||||
// docs/architecture/application-workspace.md.
|
||||
|
||||
function Shell({ title, subtitle, loading, error, children }: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{title}</Typography>
|
||||
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
{loading ? (
|
||||
<Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={44} />)}</Stack>
|
||||
) : error ? (
|
||||
<Alert severity="error">{error}</Alert>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
|
||||
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, "Could not load interview preparation."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const mutate = async (run: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await run();
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not update interview preparation."));
|
||||
} 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 }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Shell
|
||||
title="Interview preparation"
|
||||
subtitle="Your own research, answers and questions. Nothing here is generated or overwritten."
|
||||
loading={loading}
|
||||
error={error}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
{board && !board.isInterviewStage && (
|
||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||
This application has not reached an interview stage yet. Preparing early is fine.
|
||||
</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 }}>
|
||||
Preparation progress
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{board.prepared} of {board.total} ready
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={board.percent}
|
||||
aria-label="Interview preparation progress"
|
||||
sx={{ height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{board && board.total === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Nothing prepared yet. Add a question you expect, a company fact worth knowing, or a STAR
|
||||
example you want ready.
|
||||
</Typography>
|
||||
) : (
|
||||
(board?.groups ?? []).map((group) => (
|
||||
<Box key={group.category}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 800, textTransform: "uppercase", letterSpacing: ".06em", color: "text.secondary" }}>
|
||||
{group.label}
|
||||
</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>
|
||||
))
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={add}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label="Category"
|
||||
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}>{c.label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Add a question, topic or note"
|
||||
value={title}
|
||||
disabled={busy}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" variant="contained" disabled={busy || !title.trim()}>Add</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Shell>
|
||||
|
||||
<ApplicationFollowUp jobId={jobId} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 [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, "Could not save this answer."));
|
||||
} 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": `Ready: ${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="From AI" variant="outlined" />
|
||||
)}
|
||||
</Stack>
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Your answer, in your own words."
|
||||
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 }))}
|
||||
>
|
||||
Save answer
|
||||
</Button>
|
||||
<Button size="small" disabled={saving} onClick={() => setDraft(null)}>Discard</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
<Tooltip title="Delete">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={busy || saving}
|
||||
aria-label={`Delete: ${item.title}`}
|
||||
onClick={() => run(() => interviewPrepApi.remove(jobId, item.id))}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApplicationFollowUp({ jobId }: { jobId: number }) {
|
||||
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, "Could not load follow-up."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [jobId]);
|
||||
|
||||
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, "Could not save the follow-up."));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell
|
||||
title="Follow-up"
|
||||
subtitle="The same date the reminder service already uses. Follow-up tasks live in the checklist."
|
||||
loading={loading}
|
||||
error={error}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
{data && data.openFollowUpTasks > 0 && (
|
||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||
{data.openFollowUpTasks} open follow-up {data.openFollowUpTasks === 1 ? "task" : "tasks"} on
|
||||
the checklist.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
label="Follow up on"
|
||||
value={date}
|
||||
disabled={busy}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Next action"
|
||||
value={action}
|
||||
disabled={busy}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
/>
|
||||
<Button variant="contained" disabled={busy} onClick={save}>Save</Button>
|
||||
</Stack>
|
||||
|
||||
{data && !data.followUpAt && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No follow-up scheduled. Applications without one go quiet.
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user