merge: reconcile perf/wave1-perf with main (Wave 0 features)
Resolve conflicts from main's Wave 0 (PR #1) landing after this branch was cut: - useViewResource.ts: main'se352aaealready fixes the render loop the same way (load in a ref, dropped from deps) — took main's canonical version. My independent fix is superseded (my branch predatede352aae, which is why the loop reproduced live). - JobApplicationsController.cs: keep BOTH main's IJobCvMatchService and my AnalyticsService (ctor gets both optional params). GetAnalyticsOverview stays delegated to AnalyticsService. - Fold main's H3 additions into the extracted AnalyticsService: pipeline-driven funnel (JobPipeline.Normalize/Stages) + time-in-stage (StageAnalytics) and add StageDurationDto + TimeInStage to Models/AnalyticsDtos.cs, preserving the API contract the frontend expects. Build clean; backend suite 135/135 green.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
|
||||
@@ -30,12 +30,14 @@ import { Company, JobImportResult } from "../types";
|
||||
import { invalidateCompaniesCache, useCompanies } from "../hooks/useCompanies";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { PIPELINE_STATUSES, statusLabel as pipelineStatusLabel } from "../pipeline";
|
||||
import TagsInput from "./TagsInput";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
initialUrl?: string;
|
||||
}
|
||||
|
||||
type DuplicateCandidate = {
|
||||
@@ -60,7 +62,6 @@ type CreatedJobResponse = {
|
||||
type AttachmentBucketKey = "resume" | "coverLetter" | "portfolio" | "other";
|
||||
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 FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
|
||||
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
|
||||
@@ -97,7 +98,7 @@ function normalizeLanguage(value?: string | null) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { t, language } = useI18n();
|
||||
|
||||
@@ -115,9 +116,13 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
|
||||
const [dateApplied, setDateApplied] = useState(() => getTodayIso());
|
||||
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 [salary, setSalary] = useState("");
|
||||
const [salaryMin, setSalaryMin] = useState("");
|
||||
const [salaryMax, setSalaryMax] = useState("");
|
||||
const [salaryCurrency, setSalaryCurrency] = useState("");
|
||||
const [salaryPeriod, setSalaryPeriod] = useState("");
|
||||
const [jobUrl, setJobUrl] = useState("");
|
||||
const [deadline, setDeadline] = useState("");
|
||||
|
||||
@@ -133,6 +138,21 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
setCompanies(cachedCompanies);
|
||||
}, [cachedCompanies]);
|
||||
|
||||
// Quick-capture: when opened with a URL (from the bookmarklet), prefill and auto-import once.
|
||||
const autoImportedUrlRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
autoImportedUrlRef.current = null;
|
||||
return;
|
||||
}
|
||||
const url = initialUrl?.trim();
|
||||
if (!url || autoImportedUrlRef.current === url) return;
|
||||
autoImportedUrlRef.current = url;
|
||||
setJobUrl(url);
|
||||
void importFromUrl(url);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initialUrl]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCompany(null);
|
||||
setCompanyInput("");
|
||||
@@ -219,16 +239,17 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const importFromUrl = async () => {
|
||||
const importFromUrl = async (urlArg?: string) => {
|
||||
if (importing) return;
|
||||
if (!jobUrl.trim()) {
|
||||
const url = (urlArg ?? jobUrl).trim();
|
||||
if (!url) {
|
||||
toast(t("addJobModalPasteUrlFirst"), "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const res = await api.post<JobImportResult>("/jobimport/preview", { url: jobUrl.trim() });
|
||||
const res = await api.post<JobImportResult>("/jobimport/preview", { url });
|
||||
const r = res.data;
|
||||
if (!r?.success) throw new Error(r?.error || t("addJobModalImportFailed"));
|
||||
|
||||
@@ -291,6 +312,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
status,
|
||||
location,
|
||||
salary,
|
||||
salaryMin: salaryMin.trim() ? Number(salaryMin) : null,
|
||||
salaryMax: salaryMax.trim() ? Number(salaryMax) : null,
|
||||
salaryCurrency: salaryCurrency.trim() || null,
|
||||
salaryPeriod: salaryPeriod || null,
|
||||
nextAction: null,
|
||||
followUpAt: null,
|
||||
jobUrl,
|
||||
@@ -342,18 +367,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[]) => {
|
||||
if (files.length === 0) return t("addJobModalNoFilesSelected");
|
||||
if (files.length === 1) return files[0].name;
|
||||
@@ -471,9 +484,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}>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
{PIPELINE_STATUSES.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{statusLabel(s)}
|
||||
{pipelineStatusLabel(t, s)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
@@ -482,6 +495,15 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
|
||||
|
||||
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
|
||||
<TextField label={t("salaryMinLabel")} type="number" value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} sx={FIELD_SX} />
|
||||
<TextField label={t("salaryMaxLabel")} type="number" value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} sx={FIELD_SX} />
|
||||
<TextField label={t("salaryCurrencyLabel")} value={salaryCurrency} onChange={(e) => setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} />
|
||||
<TextField select SelectProps={{ native: true }} label={t("salaryPeriodLabel")} value={salaryPeriod} onChange={(e) => setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}>
|
||||
<option value=""></option>
|
||||
<option value="year">{t("salaryPeriodYear")}</option>
|
||||
<option value="month">{t("salaryPeriodMonth")}</option>
|
||||
<option value="hour">{t("salaryPeriodHour")}</option>
|
||||
</TextField>
|
||||
<DatePicker
|
||||
label={t("addJobModalDeadline")}
|
||||
value={parsePickerDate(deadline)}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { api } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import { getUserKeyFromToken } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { statusLabel } from "../pipeline";
|
||||
import { buildWorkflowPath, getWorkflowAction } from "../jobWorkflowSignals";
|
||||
import { JobApplication } from "../types";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
@@ -49,6 +50,7 @@ type OverviewAnalytics = {
|
||||
medianDaysToFirstResponse?: number | null;
|
||||
totalResponses: number;
|
||||
totalActive: number;
|
||||
timeInStage?: { stage: string; medianDays: number; count: number }[];
|
||||
};
|
||||
type TagTrendResponse = { months: string[]; series: { tag: string; counts: number[] }[] };
|
||||
|
||||
@@ -453,7 +455,7 @@ export default function DashboardView() {
|
||||
return (
|
||||
<Box key={item.label}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{item.label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.label)}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
@@ -474,6 +476,22 @@ export default function DashboardView() {
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{overview?.timeInStage?.length ? (
|
||||
<Box sx={{ mt: 2.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, mb: 1 }}>{t("dashboardTimeInStageTitle")}</Typography>
|
||||
<Stack spacing={0.75}>
|
||||
{overview.timeInStage.map((item) => (
|
||||
<Box key={item.stage} sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.stage)}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{summaryView.topSource?.label ?? t("dashboardResponseSources")}</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950, mt: 0.5 }}>{summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}</Typography>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useToast } from "../toast";
|
||||
import { useCompanies } from "../hooks/useCompanies";
|
||||
import TagsInput from "./TagsInput";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { PIPELINE_STATUSES, statusLabel } from "../pipeline";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -32,7 +33,6 @@ interface Props {
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
|
||||
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
|
||||
|
||||
@@ -80,6 +80,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
const [dateApplied, setDateApplied] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [location, setLocation] = useState("");
|
||||
const [salary, setSalary] = useState("");
|
||||
const [salaryMin, setSalaryMin] = useState("");
|
||||
const [salaryMax, setSalaryMax] = useState("");
|
||||
const [salaryCurrency, setSalaryCurrency] = useState("");
|
||||
const [salaryPeriod, setSalaryPeriod] = useState("");
|
||||
const [nextAction, setNextAction] = useState("");
|
||||
const [followUpAt, setFollowUpAt] = useState<string>("");
|
||||
const [jobUrl, setJobUrl] = useState("");
|
||||
@@ -110,6 +114,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
setDateApplied(toDateInputValue(j.dateApplied));
|
||||
setLocation(j.location ?? "");
|
||||
setSalary(j.salary ?? "");
|
||||
setSalaryMin(j.salaryMin != null ? String(j.salaryMin) : "");
|
||||
setSalaryMax(j.salaryMax != null ? String(j.salaryMax) : "");
|
||||
setSalaryCurrency(j.salaryCurrency ?? "");
|
||||
setSalaryPeriod(j.salaryPeriod ?? "");
|
||||
setNextAction((j as any).nextAction ?? "");
|
||||
setFollowUpAt((j as any).followUpAt ? toDateInputValue((j as any).followUpAt) : "");
|
||||
setJobUrl(j.jobUrl ?? "");
|
||||
@@ -144,6 +152,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
responseDate: responseReceived && responseDate ? responseDate : null,
|
||||
location: location.trim() || null,
|
||||
salary: salary.trim() || null,
|
||||
salaryMin: salaryMin.trim() ? Number(salaryMin) : null,
|
||||
salaryMax: salaryMax.trim() ? Number(salaryMax) : null,
|
||||
salaryCurrency: salaryCurrency.trim() || null,
|
||||
salaryPeriod: salaryPeriod || null,
|
||||
nextAction: nextAction.trim() || null,
|
||||
followUpAt: followUpAt || null,
|
||||
hasResume,
|
||||
@@ -195,7 +207,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
<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 }}>
|
||||
<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>
|
||||
<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>
|
||||
@@ -210,6 +222,15 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
||||
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
|
||||
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
|
||||
<TextField label={t("salaryMinLabel")} type="number" value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} sx={FIELD_SX} />
|
||||
<TextField label={t("salaryMaxLabel")} type="number" value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} sx={FIELD_SX} />
|
||||
<TextField label={t("salaryCurrencyLabel")} value={salaryCurrency} onChange={(e) => setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} />
|
||||
<TextField select SelectProps={{ native: true }} label={t("salaryPeriodLabel")} value={salaryPeriod} onChange={(e) => setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}>
|
||||
<option value=""></option>
|
||||
<option value="year">{t("salaryPeriodYear")}</option>
|
||||
<option value="month">{t("salaryPeriodMonth")}</option>
|
||||
<option value="hour">{t("salaryPeriodHour")}</option>
|
||||
</TextField>
|
||||
<DatePicker label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
|
||||
<TextField label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} sx={FIELD_SX} />
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput value={tags} onChange={setTags} /></Box>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
LinearProgress,
|
||||
MenuItem,
|
||||
Select,
|
||||
Tab,
|
||||
@@ -17,9 +18,11 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { alpha } from "@mui/material/styles";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse, TailoredCvDraft } from "../types";
|
||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, StatusSuggestion, TailoredCvDraft } from "../types";
|
||||
import { statusLabel } from "../pipeline";
|
||||
import { useToast } from "../toast";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft";
|
||||
@@ -130,6 +133,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const { confirmAction } = useDialogActions();
|
||||
const followUpCache = useWorkspaceTabCache<FollowUpDraft | null>();
|
||||
const candidateFitCache = useWorkspaceTabCache<CandidateFit | null>();
|
||||
const matchScoreCache = useWorkspaceTabCache<MatchScore | null>();
|
||||
const focusPlanCache = useWorkspaceTabCache<FocusPlanResponse | null>();
|
||||
const interviewPrepCache = useWorkspaceTabCache<InterviewPrepResponse | null>();
|
||||
const readinessCache = useWorkspaceTabCache<ReadinessResponse | null>();
|
||||
@@ -168,6 +172,10 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const [sendingDraft, setSendingDraft] = useState(false);
|
||||
const [refreshingAi, setRefreshingAi] = useState(false);
|
||||
const [candidateFit, setCandidateFit] = useState<CandidateFit | null>(null);
|
||||
const [matchScore, setMatchScore] = useState<MatchScore | null>(null);
|
||||
const [loadingMatchScore, setLoadingMatchScore] = useState(false);
|
||||
const [statusSuggestion, setStatusSuggestion] = useState<StatusSuggestion | null>(null);
|
||||
const [applyingStatusSuggestion, setApplyingStatusSuggestion] = useState(false);
|
||||
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(null);
|
||||
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
|
||||
const [loadingFocusPlan, setLoadingFocusPlan] = useState(false);
|
||||
@@ -200,6 +208,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
if (!open || !jobId) return;
|
||||
setFollowUpDraft(null);
|
||||
setCandidateFit(null);
|
||||
setMatchScore(null);
|
||||
setStatusSuggestion(null);
|
||||
setFocusPlan(null);
|
||||
setInterviewPrep(null);
|
||||
setReadiness(null);
|
||||
@@ -280,6 +290,49 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
|
||||
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]);
|
||||
|
||||
// Match score is deterministic and cheap: load it on the Candidate Fit tab
|
||||
// independently of the slow AI narrative so users see the number instantly.
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 5 || matchScore) return;
|
||||
const cacheKey = `${jobId}:match-score`;
|
||||
const cached = matchScoreCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
setMatchScore(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingMatchScore(true);
|
||||
api.get<MatchScore>(`/jobapplications/${jobId}/match-score`).then((r) => {
|
||||
matchScoreCache.setCached(cacheKey, r.data);
|
||||
setMatchScore(r.data);
|
||||
}).catch(() => setMatchScore(null)).finally(() => setLoadingMatchScore(false));
|
||||
}, [open, jobId, tab, matchScore, matchScoreCache]);
|
||||
|
||||
// Suggest a status move from the latest inbound email when the workspace opens.
|
||||
useEffect(() => {
|
||||
if (!open || !jobId) return;
|
||||
let cancelled = false;
|
||||
api.get<StatusSuggestion>(`/jobapplications/${jobId}/status-suggestion`)
|
||||
.then((r) => { if (!cancelled) setStatusSuggestion(r.data?.hasSuggestion ? r.data : null); })
|
||||
.catch(() => { if (!cancelled) setStatusSuggestion(null); });
|
||||
return () => { cancelled = true; };
|
||||
}, [open, jobId]);
|
||||
|
||||
const applyStatusSuggestion = async () => {
|
||||
if (!jobId || !statusSuggestion?.suggestedStatus) return;
|
||||
setApplyingStatusSuggestion(true);
|
||||
try {
|
||||
await api.patch(`/jobapplications/${jobId}/status`, { status: statusSuggestion.suggestedStatus });
|
||||
setJob((prev) => prev ? { ...prev, status: statusSuggestion.suggestedStatus! } : prev);
|
||||
setStatusSuggestion(null);
|
||||
toast(t("statusSuggestionApplied"), "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, t("statusSuggestionFailed")), "error");
|
||||
} finally {
|
||||
setApplyingStatusSuggestion(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 6 || focusPlan) return;
|
||||
const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`;
|
||||
@@ -598,6 +651,25 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
|
||||
{attachmentPicker}
|
||||
|
||||
{statusSuggestion?.hasSuggestion ? (
|
||||
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "warning.main", backgroundColor: (theme) => alpha(theme.palette.warning.main, 0.08), display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
||||
{t("statusSuggestionTitle", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{t("statusSuggestionReason", { signal: statusSuggestion.signal ?? "", current: statusLabel(t, statusSuggestion.currentStatus ?? "") })}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button size="small" variant="contained" color="warning" disabled={applyingStatusSuggestion} onClick={() => void applyStatusSuggestion()}>
|
||||
{t("statusSuggestionApply", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })}
|
||||
</Button>
|
||||
<Button size="small" variant="text" onClick={() => setStatusSuggestion(null)}>{t("statusSuggestionDismiss")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{tab === 0 && (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
@@ -1058,6 +1130,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
|
||||
{tab === 5 && (
|
||||
<Box>
|
||||
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
|
||||
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
@@ -1136,6 +1209,73 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
);
|
||||
}
|
||||
|
||||
function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading: boolean }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (loading && !score) {
|
||||
return (
|
||||
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<CircularProgress size={18} />
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreLoading")}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!score) return null;
|
||||
|
||||
const color: "success" | "warning" | "error" | "inherit" =
|
||||
!score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error";
|
||||
const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums" }}>{score.hasEnoughSignal ? `${score.score}%` : "—"}</Typography>
|
||||
<Typography variant="overline">{t("matchScoreTitle")}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
|
||||
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
|
||||
</Box>
|
||||
</Box>
|
||||
{score.hasEnoughSignal ? (
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={score.score}
|
||||
color={color === "inherit" ? "primary" : color}
|
||||
sx={{ height: 8, borderRadius: 4, mb: 1.5 }}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography>
|
||||
)}
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{score.matchedKeywords.length ? score.matchedKeywords.map((k) => <Chip key={k} label={k} color="success" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreNoneYet")}</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">{t("matchScoreMissing")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{score.missingKeywords.length ? score.missingKeywords.map((k) => <Chip key={k} label={k} color="warning" variant="outlined" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreAllCovered")}</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
{score.sectionCoverage.length ? (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Typography variant="overline">{t("matchScoreSectionCoverage")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{score.sectionCoverage.map((s) => <Chip key={s.section} size="small" variant="outlined" label={`${s.section}: ${s.matched}/${s.total}`} />)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ import { api } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import { useCompanies } from "../hooks/useCompanies";
|
||||
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
||||
import { formatSalary } from "../salary";
|
||||
import { statusLabel, statusTone } from "../pipeline";
|
||||
import JobDetailsDialog from "./JobDetailsDialog";
|
||||
import EditJobDialog from "./EditJobDialog";
|
||||
import { useToast } from "../toast";
|
||||
@@ -97,10 +99,6 @@ interface Props {
|
||||
mode?: "jobs" | "trash";
|
||||
}
|
||||
|
||||
function normalizeStatus(status: string): string {
|
||||
return status === "Interviewing" ? "Interview" : status;
|
||||
}
|
||||
|
||||
function parseTags(raw?: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
@@ -111,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 {
|
||||
if (job.fullSummary) return job.fullSummary;
|
||||
@@ -546,7 +529,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Typography>
|
||||
</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 sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
|
||||
@@ -584,7 +567,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("addJobModalSalary")}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{job.salary ?? "-"}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{formatSalary(job) ?? "-"}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -694,7 +677,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
))}
|
||||
</Box>
|
||||
</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.daysSince ? <TableCell>{job.daysSince}</TableCell> : null}
|
||||
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null}
|
||||
@@ -727,7 +710,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ p: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job.location ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{job.salary ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{formatSalary(job) ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("settingsColumnJobUrl")}</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a> : "-"}</Typography></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableSkills")}</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{detailTags.length ? detailTags.map((tag) => <Chip key={tag} label={tag} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobTableNoTags")}</Typography>}</Box></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableOverview")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{overview || t("jobTableNoSummaryYet")}</Typography></Box>
|
||||
|
||||
@@ -19,41 +19,22 @@ 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";
|
||||
|
||||
const STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
type Status = (typeof STATUSES)[number];
|
||||
const STATUSES = PIPELINE_STATUSES;
|
||||
type Status = PipelineStatus;
|
||||
|
||||
function normalizeStatus(status: string): Status | "Other" {
|
||||
if (status === "Interviewing") return "Interview";
|
||||
if ((STATUSES as readonly string[]).includes(status)) return status as Status;
|
||||
return "Other";
|
||||
}
|
||||
const TONE_PALETTE: Record<string, (theme: any) => string> = {
|
||||
error: (theme) => theme.palette.error.main,
|
||||
warning: (theme) => theme.palette.warning.main,
|
||||
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 {
|
||||
if (status === "Rejected") return theme.palette.error.main;
|
||||
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;
|
||||
}
|
||||
return TONE_PALETTE[statusTone(status)](theme);
|
||||
}
|
||||
|
||||
export default function KanbanBoard() {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
import { Box, Paper, TextField, Typography } from "@mui/material";
|
||||
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useToast } from "../toast";
|
||||
|
||||
/** The bookmarklet opens the app at /?add=<current page url>, which triggers quick-capture. */
|
||||
function buildBookmarklet(origin: string): string {
|
||||
// Kept as a single minified expression; opens a small popup so the user's tab is undisturbed.
|
||||
return `javascript:void(window.open('${origin}/?add='+encodeURIComponent(location.href),'jobbjakt','width=520,height=720'))`;
|
||||
}
|
||||
|
||||
export default function QuickCaptureCard() {
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const linkRef = useRef<HTMLAnchorElement>(null);
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const bookmarklet = buildBookmarklet(origin);
|
||||
|
||||
// React refuses to render javascript: hrefs, so set it directly on the DOM node.
|
||||
useEffect(() => {
|
||||
if (linkRef.current) linkRef.current.setAttribute("href", bookmarklet);
|
||||
}, [bookmarklet]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsQuickCaptureTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("settingsQuickCaptureSubtitle")}</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", mb: 1.5 }}>
|
||||
<Box
|
||||
component="a"
|
||||
ref={linkRef}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
// Clicking (vs dragging) shouldn't navigate; the value is meant to be dragged to the bar.
|
||||
e.preventDefault();
|
||||
toast(t("settingsQuickCaptureDragHint"), "info");
|
||||
}}
|
||||
sx={{
|
||||
display: "inline-block",
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "primary.main",
|
||||
color: "primary.main",
|
||||
fontWeight: 800,
|
||||
textDecoration: "none",
|
||||
cursor: "grab",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{t("settingsQuickCaptureButton")}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("settingsQuickCaptureDragHint")}</Typography>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label={t("settingsQuickCaptureManual")}
|
||||
value={bookmarklet}
|
||||
fullWidth
|
||||
size="small"
|
||||
InputProps={{ readOnly: true }}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import ImportExportJobs from "./ImportExportJobs";
|
||||
import GoogleAuthCard from "./GoogleAuthCard";
|
||||
import RulesSettingsCard from "./RulesSettingsCard";
|
||||
import BackupCard from "./BackupCard";
|
||||
import QuickCaptureCard from "./QuickCaptureCard";
|
||||
import AuthStatusCard from "./AuthStatusCard";
|
||||
import { ThemeModePref } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -297,6 +298,8 @@ export default function SettingsView({
|
||||
|
||||
<ImportExportJobs />
|
||||
</Paper>
|
||||
|
||||
<QuickCaptureCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user