eac34705e3
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>
151 lines
5.3 KiB
TypeScript
151 lines
5.3 KiB
TypeScript
// Single frontend source of truth for the canonical job pipeline.
|
|
// Mirrors the backend JobPipeline (JobTrackerApi/Services/JobPipeline.cs); keep the two in sync.
|
|
// The backend serves the same shape from GET /jobapplications/pipeline (key/order/category/group).
|
|
|
|
/**
|
|
* Detailed internal stages, in pipeline order. The board does not show ten columns — it groups
|
|
* these via PIPELINE_GROUPS below.
|
|
*
|
|
* Waiting and Ghosted are retained deliberately: Ghosted is where the rules engine parks a job
|
|
* that was never answered (neither Rejected nor Withdrawn), and Waiting carries its own follow-up
|
|
* rule and reminder wording.
|
|
*/
|
|
export const PIPELINE_STATUSES = [
|
|
"Saved",
|
|
"Interested",
|
|
"Preparing",
|
|
"Applied",
|
|
"Waiting",
|
|
"Interview",
|
|
"Offer",
|
|
"Rejected",
|
|
"Ghosted",
|
|
"Withdrawn",
|
|
] as const;
|
|
|
|
export type PipelineStatus = (typeof PIPELINE_STATUSES)[number];
|
|
|
|
export type PipelineGroup = "NotApplied" | "Active" | "Closed";
|
|
|
|
/**
|
|
* How the board collapses stages. A different axis from the backend's analytics "category":
|
|
* Offer is category Success there, but groups under Active here because it is still being worked.
|
|
*/
|
|
export const PIPELINE_GROUPS: { key: PipelineGroup; labelKey: string; statuses: PipelineStatus[] }[] = [
|
|
{ key: "NotApplied", labelKey: "pipelineGroupNotApplied", statuses: ["Saved", "Interested", "Preparing"] },
|
|
{ key: "Active", labelKey: "pipelineGroupActive", statuses: ["Applied", "Waiting", "Interview", "Offer"] },
|
|
{ key: "Closed", labelKey: "pipelineGroupClosed", statuses: ["Rejected", "Ghosted", "Withdrawn"] },
|
|
];
|
|
|
|
const GROUP_BY_STATUS = new Map<PipelineStatus, PipelineGroup>(
|
|
PIPELINE_GROUPS.flatMap((g) => g.statuses.map((s) => [s, g.key] as const)),
|
|
);
|
|
|
|
/**
|
|
* Stage a job lands on when dragged onto a grouped column. A group is not itself a status, so a
|
|
* coarse drag has to pick one: the group's entry stage. Precise stages stay available on the
|
|
* card menu.
|
|
*
|
|
* Closed deliberately enters on Rejected rather than Ghosted or Withdrawn: Ghosted is something the
|
|
* rules engine concludes, and Withdrawn is a specific claim about the user's own action. Neither
|
|
* should be inferred from a drag.
|
|
*/
|
|
export const GROUP_ENTRY_STATUS: Record<PipelineGroup, PipelineStatus> = {
|
|
NotApplied: "Saved",
|
|
Active: "Applied",
|
|
Closed: "Rejected",
|
|
};
|
|
|
|
/** The board group a status belongs to. Unknown/custom statuses have no group. */
|
|
export function groupOf(status?: string | null): PipelineGroup | null {
|
|
const normalized = normalizeStatus(status);
|
|
return normalized === "Other" ? null : GROUP_BY_STATUS.get(normalized) ?? null;
|
|
}
|
|
|
|
/** True for pre-application stages — nothing has been submitted yet. */
|
|
export function isProspect(status?: string | null): boolean {
|
|
return groupOf(status) === "NotApplied";
|
|
}
|
|
|
|
export type StatusTone = "primary" | "info" | "success" | "warning" | "error" | "default";
|
|
|
|
// Legacy/synonym spellings collapse onto a canonical stage (matches the backend alias map).
|
|
const ALIASES: Record<string, PipelineStatus> = {
|
|
bookmarked: "Saved",
|
|
wishlist: "Saved",
|
|
"to apply": "Saved",
|
|
shortlisted: "Interested",
|
|
considering: "Interested",
|
|
"in preparation": "Preparing",
|
|
"preparing application": "Preparing",
|
|
drafting: "Preparing",
|
|
interviewing: "Interview",
|
|
interviews: "Interview",
|
|
interviewed: "Interview",
|
|
"in interview": "Interview",
|
|
declined: "Rejected",
|
|
"no response": "Ghosted",
|
|
"no reply": "Ghosted",
|
|
pending: "Waiting",
|
|
awaiting: "Waiting",
|
|
"in progress": "Waiting",
|
|
"awaiting response": "Waiting",
|
|
withdrew: "Withdrawn",
|
|
cancelled: "Withdrawn",
|
|
canceled: "Withdrawn",
|
|
};
|
|
|
|
/** Canonical status for a raw value, or "Other" for unknown/custom statuses. */
|
|
export function normalizeStatus(status?: string | null): PipelineStatus | "Other" {
|
|
const trimmed = (status ?? "").trim();
|
|
if (!trimmed) return "Applied";
|
|
const exact = PIPELINE_STATUSES.find((s) => s.toLowerCase() === trimmed.toLowerCase());
|
|
if (exact) return exact;
|
|
const alias = ALIASES[trimmed.toLowerCase()];
|
|
return alias ?? "Other";
|
|
}
|
|
|
|
/** MUI palette key for a status; both chip color and board accent derive from this. */
|
|
export function statusTone(status?: string | null): StatusTone {
|
|
switch (normalizeStatus(status)) {
|
|
case "Offer":
|
|
return "success";
|
|
case "Rejected":
|
|
case "Withdrawn":
|
|
return "error";
|
|
case "Waiting":
|
|
case "Ghosted":
|
|
return "warning";
|
|
case "Interview":
|
|
return "info";
|
|
case "Applied":
|
|
return "primary";
|
|
// Pre-application stages read as neutral: nothing is in flight, so nothing needs attention.
|
|
case "Saved":
|
|
case "Interested":
|
|
case "Preparing":
|
|
return "default";
|
|
default:
|
|
return "default";
|
|
}
|
|
}
|
|
|
|
const LABEL_KEYS: Record<PipelineStatus, string> = {
|
|
Saved: "statusSaved",
|
|
Interested: "statusInterested",
|
|
Preparing: "statusPreparing",
|
|
Applied: "statusApplied",
|
|
Waiting: "statusWaiting",
|
|
Interview: "statusInterview",
|
|
Offer: "statusOffer",
|
|
Rejected: "statusRejected",
|
|
Ghosted: "statusGhosted",
|
|
Withdrawn: "statusWithdrawn",
|
|
};
|
|
|
|
/** Localized label for a status, falling back to the raw value for custom statuses. */
|
|
export function statusLabel(t: (key: any, params?: any) => string, status: string): string {
|
|
const normalized = normalizeStatus(status);
|
|
return normalized === "Other" ? status : t(LABEL_KEYS[normalized]);
|
|
}
|