Merge Request — Wave 0 quick wins + Tier-1/Tier-2 features #1
@@ -191,7 +191,9 @@ Authentication:
|
|||||||
- Updates an application; records a `StatusChanged` event if the status changed.
|
- Updates an application; records a `StatusChanged` event if the status changed.
|
||||||
- `PATCH /api/jobapplications/{id}/status`
|
- `PATCH /api/jobapplications/{id}/status`
|
||||||
- Body: `{ "status": "..." }`
|
- Body: `{ "status": "..." }`
|
||||||
- Updates only status; records `StatusChanged` if it changed.
|
- Updates only status; records `StatusChanged` if it changed. The status is normalized against the canonical pipeline (casing + known synonyms like `Interviewing`→`Interview`); unrecognized values are preserved as custom statuses.
|
||||||
|
- `GET /api/jobapplications/pipeline`
|
||||||
|
- Returns the canonical ordered pipeline stages (`Applied, Waiting, Interview, Offer, Rejected, Ghosted`) with display order and category (`Active`/`Success`/`Closed`). The UI renders board columns and status dropdowns from this single source of truth.
|
||||||
- `PATCH /api/jobapplications/{id}/followup`
|
- `PATCH /api/jobapplications/{id}/followup`
|
||||||
- Body: `{ "followUpAt": "2026-03-13T12:00:00Z" }` (or `null`)
|
- Body: `{ "followUpAt": "2026-03-13T12:00:00Z" }` (or `null`)
|
||||||
- Sets/clears follow-up date; records a `FollowUpSet` event.
|
- Sets/clears follow-up date; records a `FollowUpSet` event.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { Company, JobImportResult } from "../types";
|
|||||||
import { invalidateCompaniesCache, useCompanies } from "../hooks/useCompanies";
|
import { invalidateCompaniesCache, useCompanies } from "../hooks/useCompanies";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
import { PIPELINE_STATUSES, statusLabel as pipelineStatusLabel } from "../pipeline";
|
||||||
import TagsInput from "./TagsInput";
|
import TagsInput from "./TagsInput";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -60,7 +61,6 @@ type CreatedJobResponse = {
|
|||||||
type AttachmentBucketKey = "resume" | "coverLetter" | "portfolio" | "other";
|
type AttachmentBucketKey = "resume" | "coverLetter" | "portfolio" | "other";
|
||||||
type AttachmentBuckets = Record<AttachmentBucketKey, File[]>;
|
type AttachmentBuckets = Record<AttachmentBucketKey, File[]>;
|
||||||
|
|
||||||
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
|
||||||
const ACCEPTED_DOCUMENT_TYPES = ".pdf,.doc,.docx,.txt,.md,image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
|
const ACCEPTED_DOCUMENT_TYPES = ".pdf,.doc,.docx,.txt,.md,image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
|
||||||
const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
|
const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
|
||||||
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
|
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
|
||||||
@@ -115,7 +115,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
|||||||
|
|
||||||
const [dateApplied, setDateApplied] = useState(() => getTodayIso());
|
const [dateApplied, setDateApplied] = useState(() => getTodayIso());
|
||||||
const [jobTitle, setJobTitle] = useState("");
|
const [jobTitle, setJobTitle] = useState("");
|
||||||
const [status, setStatus] = useState<(typeof STATUS_OPTIONS)[number]>("Applied");
|
const [status, setStatus] = useState<(typeof PIPELINE_STATUSES)[number]>("Applied");
|
||||||
const [location, setLocation] = useState("");
|
const [location, setLocation] = useState("");
|
||||||
const [salary, setSalary] = useState("");
|
const [salary, setSalary] = useState("");
|
||||||
const [salaryMin, setSalaryMin] = useState("");
|
const [salaryMin, setSalaryMin] = useState("");
|
||||||
@@ -350,18 +350,6 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabel = (value: typeof STATUS_OPTIONS[number]) => {
|
|
||||||
const map = {
|
|
||||||
Applied: t("statusApplied"),
|
|
||||||
Waiting: t("statusWaiting"),
|
|
||||||
Interview: t("statusInterview"),
|
|
||||||
Offer: t("statusOffer"),
|
|
||||||
Rejected: t("statusRejected"),
|
|
||||||
Ghosted: t("statusGhosted"),
|
|
||||||
} as const;
|
|
||||||
return map[value];
|
|
||||||
};
|
|
||||||
|
|
||||||
const filesLabel = (files: File[]) => {
|
const filesLabel = (files: File[]) => {
|
||||||
if (files.length === 0) return t("addJobModalNoFilesSelected");
|
if (files.length === 0) return t("addJobModalNoFilesSelected");
|
||||||
if (files.length === 1) return files[0].name;
|
if (files.length === 1) return files[0].name;
|
||||||
@@ -479,9 +467,9 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField select label={t("addJobModalStatus")} value={status} onChange={(e) => setStatus(e.target.value as any)} sx={FIELD_SX}>
|
<TextField select label={t("addJobModalStatus")} value={status} onChange={(e) => setStatus(e.target.value as any)} sx={FIELD_SX}>
|
||||||
{STATUS_OPTIONS.map((s) => (
|
{PIPELINE_STATUSES.map((s) => (
|
||||||
<MenuItem key={s} value={s}>
|
<MenuItem key={s} value={s}>
|
||||||
{statusLabel(s)}
|
{pipelineStatusLabel(t, s)}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useToast } from "../toast";
|
|||||||
import { useCompanies } from "../hooks/useCompanies";
|
import { useCompanies } from "../hooks/useCompanies";
|
||||||
import TagsInput from "./TagsInput";
|
import TagsInput from "./TagsInput";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
import { PIPELINE_STATUSES, statusLabel } from "../pipeline";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -32,7 +33,6 @@ interface Props {
|
|||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
|
||||||
const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
|
const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
|
||||||
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
|
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
|||||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobStatusUpdate")}</Typography>
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobStatusUpdate")}</Typography>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2, mt: 1 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2, mt: 1 }}>
|
||||||
<TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)} sx={FIELD_SX}>
|
<TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)} sx={FIELD_SX}>
|
||||||
{STATUS_OPTIONS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
{PIPELINE_STATUSES.map((s) => <MenuItem key={s} value={s}>{statusLabel(t, s)}</MenuItem>)}
|
||||||
</TextField>
|
</TextField>
|
||||||
<DatePicker label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { ...PICKER_TEXT_FIELD_PROPS, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
|
<DatePicker label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { ...PICKER_TEXT_FIELD_PROPS, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
|
||||||
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
|
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import ViewStateNotice from "./ViewStateNotice";
|
|||||||
import { useCompanies } from "../hooks/useCompanies";
|
import { useCompanies } from "../hooks/useCompanies";
|
||||||
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
||||||
import { formatSalary } from "../salary";
|
import { formatSalary } from "../salary";
|
||||||
|
import { statusLabel, statusTone } from "../pipeline";
|
||||||
import JobDetailsDialog from "./JobDetailsDialog";
|
import JobDetailsDialog from "./JobDetailsDialog";
|
||||||
import EditJobDialog from "./EditJobDialog";
|
import EditJobDialog from "./EditJobDialog";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
@@ -98,10 +99,6 @@ interface Props {
|
|||||||
mode?: "jobs" | "trash";
|
mode?: "jobs" | "trash";
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStatus(status: string): string {
|
|
||||||
return status === "Interviewing" ? "Interview" : status;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTags(raw?: string | null): string[] {
|
function parseTags(raw?: string | null): string[] {
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
try {
|
try {
|
||||||
@@ -112,21 +109,6 @@ function parseTags(raw?: string | null): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusTone(status: string): string {
|
|
||||||
switch (normalizeStatus(status)) {
|
|
||||||
case "Offer":
|
|
||||||
return "success";
|
|
||||||
case "Rejected":
|
|
||||||
return "error";
|
|
||||||
case "Waiting":
|
|
||||||
case "Ghosted":
|
|
||||||
return "warning";
|
|
||||||
case "Interview":
|
|
||||||
return "info";
|
|
||||||
default:
|
|
||||||
return "primary";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateOverview(job: JobApplication): string {
|
function generateOverview(job: JobApplication): string {
|
||||||
if (job.fullSummary) return job.fullSummary;
|
if (job.fullSummary) return job.fullSummary;
|
||||||
@@ -547,7 +529,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{columns.status ? <Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800 }} /> : null}
|
{columns.status ? <Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800 }} /> : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
|
||||||
@@ -695,7 +677,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null}
|
{columns.status ? <TableCell><Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} /></TableCell> : null}
|
||||||
{columns.dateApplied ? <TableCell>{appliedDateLabel}</TableCell> : null}
|
{columns.dateApplied ? <TableCell>{appliedDateLabel}</TableCell> : null}
|
||||||
{columns.daysSince ? <TableCell>{job.daysSince}</TableCell> : null}
|
{columns.daysSince ? <TableCell>{job.daysSince}</TableCell> : null}
|
||||||
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null}
|
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null}
|
||||||
|
|||||||
@@ -19,41 +19,22 @@ import ViewStateNotice from "./ViewStateNotice";
|
|||||||
import { JobApplication } from "../types";
|
import { JobApplication } from "../types";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
import { useViewResource } from "../hooks/useViewResource";
|
import { useViewResource } from "../hooks/useViewResource";
|
||||||
|
import { PIPELINE_STATUSES, PipelineStatus, normalizeStatus, statusLabel, statusTone } from "../pipeline";
|
||||||
|
|
||||||
const STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
const STATUSES = PIPELINE_STATUSES;
|
||||||
type Status = (typeof STATUSES)[number];
|
type Status = PipelineStatus;
|
||||||
|
|
||||||
function normalizeStatus(status: string): Status | "Other" {
|
const TONE_PALETTE: Record<string, (theme: any) => string> = {
|
||||||
if (status === "Interviewing") return "Interview";
|
error: (theme) => theme.palette.error.main,
|
||||||
if ((STATUSES as readonly string[]).includes(status)) return status as Status;
|
warning: (theme) => theme.palette.warning.main,
|
||||||
return "Other";
|
success: (theme) => theme.palette.success.main,
|
||||||
}
|
info: (theme) => alpha(theme.palette.primary.main, 0.95),
|
||||||
|
primary: (theme) => theme.palette.primary.main,
|
||||||
|
default: (theme) => theme.palette.primary.main,
|
||||||
|
};
|
||||||
|
|
||||||
function toneColor(theme: any, status: Status | "Other"): string {
|
function toneColor(theme: any, status: Status | "Other"): string {
|
||||||
if (status === "Rejected") return theme.palette.error.main;
|
return TONE_PALETTE[statusTone(status)](theme);
|
||||||
if (status === "Waiting" || status === "Ghosted") return theme.palette.warning.main;
|
|
||||||
if (status === "Offer") return theme.palette.success.main;
|
|
||||||
if (status === "Interview") return alpha(theme.palette.primary.main, 0.95);
|
|
||||||
return theme.palette.primary.main;
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(t: (key: any, params?: any) => string, status: Status): string {
|
|
||||||
switch (status) {
|
|
||||||
case "Applied":
|
|
||||||
return t("statusApplied");
|
|
||||||
case "Waiting":
|
|
||||||
return t("statusWaiting");
|
|
||||||
case "Interview":
|
|
||||||
return t("statusInterview");
|
|
||||||
case "Offer":
|
|
||||||
return t("statusOffer");
|
|
||||||
case "Rejected":
|
|
||||||
return t("statusRejected");
|
|
||||||
case "Ghosted":
|
|
||||||
return t("statusGhosted");
|
|
||||||
default:
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function KanbanBoard() {
|
export default function KanbanBoard() {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { normalizeStatus, statusTone, statusLabel, PIPELINE_STATUSES } from './pipeline';
|
||||||
|
|
||||||
|
describe('pipeline', () => {
|
||||||
|
test('normalizeStatus canonicalizes casing and synonyms', () => {
|
||||||
|
expect(normalizeStatus('applied')).toBe('Applied');
|
||||||
|
expect(normalizeStatus(' OFFER ')).toBe('Offer');
|
||||||
|
expect(normalizeStatus('Interviewing')).toBe('Interview');
|
||||||
|
expect(normalizeStatus('declined')).toBe('Rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeStatus preserves unknown as Other and empty as Applied', () => {
|
||||||
|
expect(normalizeStatus('Take-home')).toBe('Other');
|
||||||
|
expect(normalizeStatus('')).toBe('Applied');
|
||||||
|
expect(normalizeStatus(null)).toBe('Applied');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('statusTone maps stages to palette keys', () => {
|
||||||
|
expect(statusTone('Offer')).toBe('success');
|
||||||
|
expect(statusTone('Rejected')).toBe('error');
|
||||||
|
expect(statusTone('Waiting')).toBe('warning');
|
||||||
|
expect(statusTone('Ghosted')).toBe('warning');
|
||||||
|
expect(statusTone('Interview')).toBe('info');
|
||||||
|
expect(statusTone('Applied')).toBe('primary');
|
||||||
|
expect(statusTone('Take-home')).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('statusLabel localizes canonical and passes through custom', () => {
|
||||||
|
const t = (key: string) => ({ statusApplied: 'Applied', statusOffer: 'Offer' } as Record<string, string>)[key] ?? key;
|
||||||
|
expect(statusLabel(t, 'Applied')).toBe('Applied');
|
||||||
|
expect(statusLabel(t, 'Interviewing')).toBe('statusInterview'); // maps to canonical key
|
||||||
|
expect(statusLabel(t, 'Take-home assignment')).toBe('Take-home assignment');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canonical stage list is stable and ordered', () => {
|
||||||
|
expect(PIPELINE_STATUSES).toEqual(['Applied', 'Waiting', 'Interview', 'Offer', 'Rejected', 'Ghosted']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Single frontend source of truth for the canonical job pipeline.
|
||||||
|
// Mirrors the backend JobPipeline (JobTrackerApi/Services/JobPipeline.cs); keep the two in sync.
|
||||||
|
|
||||||
|
export const PIPELINE_STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||||
|
|
||||||
|
export type PipelineStatus = (typeof PIPELINE_STATUSES)[number];
|
||||||
|
|
||||||
|
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> = {
|
||||||
|
interviewing: "Interview",
|
||||||
|
interviews: "Interview",
|
||||||
|
interviewed: "Interview",
|
||||||
|
declined: "Rejected",
|
||||||
|
"no response": "Ghosted",
|
||||||
|
"no reply": "Ghosted",
|
||||||
|
pending: "Waiting",
|
||||||
|
"awaiting response": "Waiting",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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":
|
||||||
|
return "error";
|
||||||
|
case "Waiting":
|
||||||
|
case "Ghosted":
|
||||||
|
return "warning";
|
||||||
|
case "Interview":
|
||||||
|
return "info";
|
||||||
|
case "Applied":
|
||||||
|
return "primary";
|
||||||
|
default:
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL_KEYS: Record<PipelineStatus, string> = {
|
||||||
|
Applied: "statusApplied",
|
||||||
|
Waiting: "statusWaiting",
|
||||||
|
Interview: "statusInterview",
|
||||||
|
Offer: "statusOffer",
|
||||||
|
Rejected: "statusRejected",
|
||||||
|
Ghosted: "statusGhosted",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user