feat: Phase 0 foundation — Job entity, expanded pipeline, AI service lockdown, DateApplied history
Unblocks the documented core workflow and closes the AI-service exposure, without changing existing behaviour. Job/JobApplication split (additive; see ADR-002): - New Job entity (the opportunity) with owner-scoped query filter; nullable JobApplication.JobId FK. Nothing reads Job yet. - Migration AddJobEntityAndProspectStages, hand-edited to drop reconciler-owned tables the scaffolder re-emitted; verified against the real dev DB. Pipeline: 10 internal stages across three concerns kept separate — PipelineStage (workflow) / PipelineGroup (UI: NotApplied/Active/Closed) / PipelineCategory (analytics). Adds Saved/Interested/Preparing/Withdrawn; keeps Waiting and Ghosted. Kanban shows 3 grouped columns; cards keep a stage chip and full transitions; drag applies only safe transitions (never infers Ghosted/Withdrawn). DateApplied nullable + SavedAt. Cleared when leaving Applied so analytics stay accurate; the discarded date is preserved as an AppliedDateCleared JobEvent. AI service lockdown: no host port; private ai_internal network (backend is the only other member); X-Ai-Service-Token required on all non-/health endpoints; AI_SERVICE_TOKEN mandatory via compose. Verified backend-only against the live stack. Also carries two pre-existing working-tree files (views/ProfilePage.tsx, views/CareerWorkspacePage.tsx) so the tree is clean for the branch integration. Tests: +40 backend (247 total), +5 sidecar (16), +15 frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -111,7 +111,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
setStatus(j.status ?? "Applied");
|
||||
setInitialStatus(j.status ?? "Applied");
|
||||
setStatusChangedAt(new Date().toISOString().slice(0, 10));
|
||||
setDateApplied(toDateInputValue(j.dateApplied));
|
||||
setDateApplied(toDateInputValue(j.dateApplied ?? undefined));
|
||||
setLocation(j.location ?? "");
|
||||
setSalary(j.salary ?? "");
|
||||
setSalaryMin(j.salaryMin != null ? String(j.salaryMin) : "");
|
||||
|
||||
@@ -7,7 +7,9 @@ import { Company, JobApplication } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
type ImportJob = Omit<JobApplication, "id" | "company"> & {
|
||||
// savedAt is omitted alongside id: both are assigned by the server on create, so an imported
|
||||
// row has no business supplying one.
|
||||
type ImportJob = Omit<JobApplication, "id" | "company" | "savedAt"> & {
|
||||
company: Pick<Company, "name" | "location" | "source">;
|
||||
};
|
||||
|
||||
|
||||
@@ -723,8 +723,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("jobDetailsStrategySnapshotEmpty")}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box><Typography variant="overline">{t("jobDetailsDateApplied")}</Typography><Typography>{job ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("jobDetailsDaysSince")}</Typography><Typography>{job?.daysSince ?? ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("jobDetailsDateApplied")}</Typography><Typography>{job?.dateApplied ? new Date(job.dateApplied).toLocaleDateString() : "—"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("jobDetailsDaysSince")}</Typography><Typography>{job?.daysSince ?? "—"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job?.location ?? ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("jobDetailsSalary")}</Typography><Typography>{job?.salary ?? ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("jobDetailsNextAction")}</Typography><Typography>{job?.nextAction ?? ""}</Typography></Box>
|
||||
|
||||
@@ -72,7 +72,9 @@ export default function JobFlowBar({ job, history = [] }: { job: JobApplication
|
||||
const items = useMemo(() => {
|
||||
if (!job) return [] as FlowItem[];
|
||||
|
||||
const appliedAt = new Date(job.dateApplied);
|
||||
// Null for a job that has not been applied to yet: there is no Applied milestone to anchor
|
||||
// the flow on, so the bar simply starts later.
|
||||
const appliedAt = job.dateApplied ? new Date(job.dateApplied) : null;
|
||||
const replyAt = firstResponse(history, job);
|
||||
const interviewAt = firstStatusChange(history, "Interview") || (normalizeStatus(job.status) === "Interview" ? replyAt ?? appliedAt : null);
|
||||
const offerAt = firstStatusChange(history, "Offer") || (normalizeStatus(job.status) === "Offer" ? replyAt ?? interviewAt ?? appliedAt : null);
|
||||
@@ -81,15 +83,17 @@ export default function JobFlowBar({ job, history = [] }: { job: JobApplication
|
||||
|| firstStatusChange(history, "Ghosted")
|
||||
|| ((normalizeStatus(job.status) === "Rejected" || normalizeStatus(job.status) === "Ghosted") ? replyAt ?? appliedAt : null);
|
||||
|
||||
const next: FlowItem[] = [
|
||||
{
|
||||
const next: FlowItem[] = [];
|
||||
|
||||
if (appliedAt) {
|
||||
next.push({
|
||||
key: "applied",
|
||||
label: "Applied",
|
||||
at: appliedAt,
|
||||
color: theme.palette.info.main,
|
||||
icon: <WorkOutlineIcon fontSize="small" />,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
if (replyAt) {
|
||||
next.push({
|
||||
|
||||
@@ -352,7 +352,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
tags: parseTags(job.tags),
|
||||
actionSignals: actionSignal ? [actionSignal] : [],
|
||||
primaryAction: actionSignal,
|
||||
appliedDateLabel: new Date(job.dateApplied).toLocaleDateString(),
|
||||
appliedDateLabel: job.dateApplied ? new Date(job.dateApplied).toLocaleDateString() : "—",
|
||||
isSelected: selectedIdSet.has(job.id),
|
||||
isExpanded: expanded.includes(job.id),
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CardContent,
|
||||
Chip,
|
||||
IconButton,
|
||||
ListSubheader,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Paper,
|
||||
@@ -19,11 +20,25 @@ import ViewStateNotice from "./ViewStateNotice";
|
||||
import { JobApplication } from "../types";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
import { PIPELINE_STATUSES, PipelineStatus, normalizeStatus, statusLabel, statusTone } from "../pipeline";
|
||||
import {
|
||||
GROUP_ENTRY_STATUS,
|
||||
PIPELINE_GROUPS,
|
||||
PIPELINE_STATUSES,
|
||||
PipelineGroup,
|
||||
PipelineStatus,
|
||||
groupOf,
|
||||
normalizeStatus,
|
||||
statusLabel,
|
||||
statusTone,
|
||||
} from "../pipeline";
|
||||
|
||||
const STATUSES = PIPELINE_STATUSES;
|
||||
type Status = PipelineStatus;
|
||||
|
||||
// The board shows one column per group rather than one per stage: ten columns is unreadable, and
|
||||
// the stage detail is preserved on each card's chip. "Other" collects custom statuses, which
|
||||
// belong to no group, and only appears when something is actually in it.
|
||||
type ColumnKey = PipelineGroup | "Other";
|
||||
|
||||
const TONE_PALETTE: Record<string, (theme: any) => string> = {
|
||||
error: (theme) => theme.palette.error.main,
|
||||
warning: (theme) => theme.palette.warning.main,
|
||||
@@ -89,22 +104,32 @@ export default function KanbanBoard() {
|
||||
const jobs = jobsResource.data;
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, JobApplication[]>();
|
||||
STATUSES.forEach((s) => map.set(s, []));
|
||||
const map = new Map<ColumnKey, JobApplication[]>();
|
||||
PIPELINE_GROUPS.forEach((g) => map.set(g.key, []));
|
||||
map.set("Other", []);
|
||||
for (const j of jobs) {
|
||||
const key = normalizeStatus(j.status);
|
||||
map.get(key)!.push(j);
|
||||
map.get(groupOf(j.status) ?? "Other")!.push(j);
|
||||
}
|
||||
map.forEach((arr, k) => {
|
||||
arr.sort((a, b) => +new Date(b.dateApplied) - +new Date(a.dateApplied));
|
||||
// Sort by stage first so a column reads in pipeline order (Saved before Preparing), then by
|
||||
// recency. Fall back to savedAt: unapplied jobs have no dateApplied, and treating that as
|
||||
// the epoch would sink them to the bottom.
|
||||
arr.sort((a, b) => {
|
||||
const stageDelta = PIPELINE_STATUSES.indexOf(normalizeStatus(a.status) as Status)
|
||||
- PIPELINE_STATUSES.indexOf(normalizeStatus(b.status) as Status);
|
||||
if (stageDelta !== 0) return stageDelta;
|
||||
return +new Date(b.dateApplied ?? b.savedAt) - +new Date(a.dateApplied ?? a.savedAt);
|
||||
});
|
||||
map.set(k, arr);
|
||||
});
|
||||
return map;
|
||||
}, [jobs]);
|
||||
|
||||
const onDropTo = async (status: Status) => {
|
||||
// A group is not a status, so dropping onto a column applies that group's entry stage. Precise
|
||||
// stages stay on the card menu.
|
||||
const onDropTo = async (group: PipelineGroup) => {
|
||||
if (!dragJobId) return;
|
||||
const status = GROUP_ENTRY_STATUS[group];
|
||||
setDragJobId(null);
|
||||
await api.patch(`/jobapplications/${dragJobId}/status`, { status });
|
||||
jobsResource.setData((prev) => prev.map((j) => (j.id === dragJobId ? { ...j, status } : j)));
|
||||
@@ -117,6 +142,20 @@ export default function KanbanBoard() {
|
||||
|
||||
const currentMenuStatus = menuJobId == null ? null : normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? "");
|
||||
|
||||
// "Other" (custom statuses) only earns a column when something is in it, and is not a drop
|
||||
// target — there is no single custom status to assign.
|
||||
const columns = useMemo(() => {
|
||||
const base: { key: ColumnKey; label: string; droppable: boolean }[] = PIPELINE_GROUPS.map((g) => ({
|
||||
key: g.key,
|
||||
label: t(g.labelKey as any),
|
||||
droppable: true,
|
||||
}));
|
||||
if ((groups.get("Other") ?? []).length > 0) {
|
||||
base.push({ key: "Other", label: t("kanbanGroupOther"), droppable: false });
|
||||
}
|
||||
return base;
|
||||
}, [groups, t]);
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>
|
||||
@@ -135,7 +174,7 @@ export default function KanbanBoard() {
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: "flex", md: "grid" },
|
||||
gridTemplateColumns: { md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" },
|
||||
gridTemplateColumns: { md: "repeat(3, 1fr)" },
|
||||
gap: 2,
|
||||
alignItems: "start",
|
||||
overflowX: { xs: "auto", md: "visible" },
|
||||
@@ -144,14 +183,15 @@ export default function KanbanBoard() {
|
||||
"-webkit-overflow-scrolling": "touch",
|
||||
}}
|
||||
>
|
||||
{STATUSES.map((status) => {
|
||||
const c = toneColor(theme, status);
|
||||
const list = groups.get(status) ?? [];
|
||||
{columns.map(({ key, label, droppable }) => {
|
||||
const list = groups.get(key) ?? [];
|
||||
// Colour the column by its entry stage so the header dot still carries meaning.
|
||||
const c = droppable ? toneColor(theme, GROUP_ENTRY_STATUS[key as PipelineGroup]) : theme.palette.grey[500];
|
||||
return (
|
||||
<Paper
|
||||
key={status}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => void onDropTo(status)}
|
||||
key={key}
|
||||
onDragOver={(e) => { if (droppable) e.preventDefault(); }}
|
||||
onDrop={() => { if (droppable) void onDropTo(key as PipelineGroup); }}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 3,
|
||||
@@ -166,7 +206,7 @@ export default function KanbanBoard() {
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Box sx={{ width: 9, height: 9, borderRadius: "50%", backgroundColor: c, flexShrink: 0 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
|
||||
{statusLabel(t, status)}
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
@@ -189,6 +229,10 @@ export default function KanbanBoard() {
|
||||
{list.map((j) => {
|
||||
const pill = cardPill(j, t);
|
||||
const tags = parseTags(j.tags);
|
||||
// The column is a group, so each card names its own stage — grouping collapses
|
||||
// the columns, not the information.
|
||||
const cardStatus = normalizeStatus(j.status);
|
||||
const cardColor = toneColor(theme, cardStatus);
|
||||
return (
|
||||
<Card
|
||||
key={j.id}
|
||||
@@ -198,7 +242,7 @@ export default function KanbanBoard() {
|
||||
sx={{
|
||||
cursor: "grab",
|
||||
borderRadius: 2.5,
|
||||
borderLeft: `4px solid ${c}`,
|
||||
borderLeft: `4px solid ${cardColor}`,
|
||||
transition: "box-shadow .15s, transform .15s",
|
||||
"&:hover": { boxShadow: 4, transform: "translateY(-1px)" },
|
||||
"&:active": { cursor: "grabbing" },
|
||||
@@ -224,6 +268,19 @@ export default function KanbanBoard() {
|
||||
{[j.company?.name, j.location].filter(Boolean).join(" · ")}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 0.75 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={statusLabel(t, j.status)}
|
||||
sx={{
|
||||
height: 22,
|
||||
fontWeight: 700,
|
||||
backgroundColor: alpha(cardColor, 0.14),
|
||||
color: cardColor,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, mt: 0.75 }}>
|
||||
{tags.map((tag) => (
|
||||
@@ -252,9 +309,11 @@ export default function KanbanBoard() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.75 }}>
|
||||
{t("kanbanAppliedAgo", { days: j.daysSince })}
|
||||
</Typography>
|
||||
{j.daysSince != null ? (
|
||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.75 }}>
|
||||
{t("kanbanAppliedAgo", { days: j.daysSince })}
|
||||
</Typography>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -271,14 +330,22 @@ export default function KanbanBoard() {
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* Dragging is coarse (group entry stage only), so the menu carries every precise stage. */}
|
||||
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={() => { setMenuAnchor(null); setMenuJobId(null); }}>
|
||||
{(["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const)
|
||||
.filter((s) => s !== currentMenuStatus)
|
||||
.map((s) => (
|
||||
<MenuItem key={s} onClick={() => { if (menuJobId) void setStatus(menuJobId, s); setMenuAnchor(null); setMenuJobId(null); }}>
|
||||
{t("jobTableSetStatus", { status: statusLabel(t, s) })}
|
||||
</MenuItem>
|
||||
))}
|
||||
{PIPELINE_GROUPS.flatMap((g) => {
|
||||
const options = g.statuses.filter((s) => s !== currentMenuStatus);
|
||||
if (options.length === 0) return [];
|
||||
return [
|
||||
<ListSubheader key={`${g.key}-header`} sx={{ lineHeight: "32px", backgroundColor: "transparent" }}>
|
||||
{t(g.labelKey as any)}
|
||||
</ListSubheader>,
|
||||
...options.map((s) => (
|
||||
<MenuItem key={s} onClick={() => { if (menuJobId) void setStatus(menuJobId, s); setMenuAnchor(null); setMenuJobId(null); }}>
|
||||
{t("jobTableSetStatus", { status: statusLabel(t, s) })}
|
||||
</MenuItem>
|
||||
)),
|
||||
];
|
||||
})}
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user