feat: add application package generation and grouped readiness workflows
This commit is contained in:
@@ -178,7 +178,7 @@ export default function DashboardView() {
|
||||
{ label: "Applied (30 days)", value: stats?.appliedLast30Days ?? "-", sub: "New applications" },
|
||||
{ label: "Median first response", value: overview?.medianDaysToFirstResponse ?? "-", sub: "Days until first reply" },
|
||||
{ label: "Responses logged", value: overview?.totalResponses ?? 0, sub: "Across active jobs" },
|
||||
{ label: "In trash", value: stats?.deleted ?? "-", sub: "Soft-deleted" },
|
||||
{ label: "Low readiness", value: reminderJobs.filter((job) => !job.tailoredCvText).length, sub: "Reminder jobs missing tailored CV" },
|
||||
];
|
||||
|
||||
const togglePref = (key: keyof Prefs) => {
|
||||
@@ -433,3 +433,13 @@ export default function DashboardView() {
|
||||
|
||||
|
||||
|
||||
cent summarizer errors recorded."}</Typography>
|
||||
</Paper>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
} from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import { JobApplication } from "../types";
|
||||
import { ApplicationPackageResponse, CandidateFit, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
|
||||
import Correspondence from "./Correspondence";
|
||||
import Attachments from "./Attachments";
|
||||
@@ -50,8 +51,21 @@ function statusChipColor(status: string): "default" | "primary" | "warning" | "e
|
||||
}
|
||||
}
|
||||
|
||||
function getFitLevel(candidateFit: CandidateFit | null): { label: string; color: "success" | "warning" | "default" } | null {
|
||||
if (!candidateFit) return null;
|
||||
if (candidateFit.fitLevel === "Strong match") return { label: candidateFit.fitLevel, color: "success" };
|
||||
if (candidateFit.fitLevel === "Potential match") return { label: candidateFit.fitLevel, color: "warning" };
|
||||
return { label: candidateFit.fitLevel, color: "default" };
|
||||
}
|
||||
|
||||
function copyLines(items: string[]) {
|
||||
return navigator.clipboard.writeText(items.map((item) => `• ${item}`).join("\n"));
|
||||
}
|
||||
|
||||
export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { confirmAction } = useDialogActions();
|
||||
|
||||
const [job, setJob] = useState<JobApplication | null>(null);
|
||||
const [tab, setTab] = useState(0);
|
||||
const [history, setHistory] = useState<{ id: number; type: string; oldValue?: string; newValue?: string; note?: string; at: string }[]>([]);
|
||||
@@ -60,6 +74,16 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
const [loadingDraft, setLoadingDraft] = useState(false);
|
||||
const [sendingDraft, setSendingDraft] = useState(false);
|
||||
const [refreshingAi, setRefreshingAi] = useState(false);
|
||||
const [candidateFit, setCandidateFit] = useState<CandidateFit | null>(null);
|
||||
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
|
||||
const [interviewPrep, setInterviewPrep] = useState<InterviewPrepResponse | null>(null);
|
||||
const [loadingInterviewPrep, setLoadingInterviewPrep] = useState(false);
|
||||
const [readiness, setReadiness] = useState<ReadinessResponse | null>(null);
|
||||
const [loadingReadiness, setLoadingReadiness] = useState(false);
|
||||
const [savingTailoredCv, setSavingTailoredCv] = useState(false);
|
||||
const [generatingPackage, setGeneratingPackage] = useState(false);
|
||||
const [applicationPackage, setApplicationPackage] = useState<ApplicationPackageResponse | null>(null);
|
||||
const [tailoredCvText, setTailoredCvText] = useState("");
|
||||
const [draftRecipient, setDraftRecipient] = useState("");
|
||||
const [draftSubject, setDraftSubject] = useState("");
|
||||
const [draftBody, setDraftBody] = useState("");
|
||||
@@ -68,27 +92,47 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
if (!open || !jobId) return;
|
||||
setTab(0);
|
||||
setFollowUpDraft(null);
|
||||
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => { setJob(r.data); setDraftRecipient(r.data.company?.recruiterEmail ?? ""); });
|
||||
api
|
||||
.get(`/auth/me`)
|
||||
.then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin"))))
|
||||
.catch(() => setIsAdmin(false));
|
||||
api
|
||||
.get(`/jobapplications/${jobId}/history`)
|
||||
.then((r) => setHistory(r.data))
|
||||
.catch(() => setHistory([]));
|
||||
setCandidateFit(null);
|
||||
setInterviewPrep(null);
|
||||
setReadiness(null);
|
||||
setApplicationPackage(null);
|
||||
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
|
||||
setJob(r.data);
|
||||
setTailoredCvText(r.data.tailoredCvText ?? "");
|
||||
setDraftRecipient(r.data.company?.recruiterEmail ?? "");
|
||||
});
|
||||
api.get(`/auth/me`).then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin")))).catch(() => setIsAdmin(false));
|
||||
api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([]));
|
||||
}, [open, jobId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 4 || followUpDraft) return;
|
||||
setLoadingDraft(true);
|
||||
api
|
||||
.get<FollowUpDraft>(`/jobapplications/${jobId}/followup-draft`)
|
||||
.then((r) => { setFollowUpDraft(r.data); setDraftSubject(r.data.subject); setDraftBody(r.data.body); })
|
||||
.catch(() => setFollowUpDraft(null))
|
||||
.finally(() => setLoadingDraft(false));
|
||||
api.get<FollowUpDraft>(`/jobapplications/${jobId}/followup-draft`).then((r) => {
|
||||
setFollowUpDraft(r.data);
|
||||
setDraftSubject(r.data.subject);
|
||||
setDraftBody(r.data.body);
|
||||
}).catch(() => setFollowUpDraft(null)).finally(() => setLoadingDraft(false));
|
||||
}, [open, jobId, tab, followUpDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 5 || candidateFit) return;
|
||||
setLoadingCandidateFit(true);
|
||||
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`).then((r) => setCandidateFit(r.data)).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
|
||||
}, [open, jobId, tab, candidateFit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 6 || interviewPrep) return;
|
||||
setLoadingInterviewPrep(true);
|
||||
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`).then((r) => setInterviewPrep(r.data)).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false));
|
||||
}, [open, jobId, tab, interviewPrep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 7 || readiness) return;
|
||||
setLoadingReadiness(true);
|
||||
api.get<ReadinessResponse>(`/jobapplications/${jobId}/readiness`).then((r) => setReadiness(r.data)).catch(() => setReadiness(null)).finally(() => setLoadingReadiness(false));
|
||||
}, [open, jobId, tab, readiness]);
|
||||
|
||||
const tags: string[] = (() => {
|
||||
const raw = job?.tags;
|
||||
if (!raw) return [];
|
||||
@@ -102,9 +146,12 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
|
||||
const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : "Job Application";
|
||||
const checklist = [job?.hasResume ? "Resume" : null, job?.hasCoverLetter ? "Cover letter" : null, job?.hasPortfolio ? "Portfolio" : null, job?.hasOtherAttachment ? "Other" : null].filter(Boolean).join(", ") || "";
|
||||
const summaryFirstText = job?.fullSummary ?? job?.shortSummary ?? "No summary yet.";
|
||||
const rawDescriptionText = job?.translatedDescription || job?.description || "";
|
||||
const fitLevel = useMemo(() => getFitLevel(candidateFit), [candidateFit]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="lg">
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
@@ -118,182 +165,215 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
<DialogContent>
|
||||
<JobFlowBar job={job} history={history} />
|
||||
<Box sx={{ mt: 1.5, mb: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{job?.fullSummary ?? job?.shortSummary ?? "Track company context, communication, files, and next steps in one place."}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{summaryFirstText}</Typography>
|
||||
</Box>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }} variant="scrollable" allowScrollButtonsMobile>
|
||||
<Tab label="Overview" />
|
||||
<Tab label="Correspondence" />
|
||||
<Tab label="Attachments" />
|
||||
<Tab label="Cover Letter" />
|
||||
<Tab label="Tailored CV" />
|
||||
<Tab label="Follow-up draft" />
|
||||
<Tab label="Candidate fit" />
|
||||
<Tab label="Interview prep" />
|
||||
<Tab label="Readiness" />
|
||||
{isAdmin ? <Tab label="History" /> : null}
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="overline">Date Applied</Typography>
|
||||
<Typography>{job ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Days Since</Typography>
|
||||
<Typography>{job?.daysSince ?? ""}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Location</Typography>
|
||||
<Typography>{job?.location ?? ""}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Salary</Typography>
|
||||
<Typography>{job?.salary ?? ""}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Next Action</Typography>
|
||||
<Typography>{job?.nextAction ?? ""}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Follow Up</Typography>
|
||||
<Typography>{job?.followUpAt ? new Date(job.followUpAt).toLocaleDateString() : ""}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Deadline</Typography>
|
||||
<Typography>{job?.deadline ? new Date(job.deadline).toLocaleDateString() : ""}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Tags</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{tags.length === 0 ? <Typography sx={{ color: "text.secondary" }}>-</Typography> : tags.map((t) => <Chip key={t} label={t} size="small" />)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline">Attachment Types</Typography>
|
||||
<Typography>{checklist}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline">Job URL</Typography>
|
||||
<Typography>
|
||||
{job?.jobUrl ? (
|
||||
<a href={job.jobUrl} target="_blank" rel="noreferrer">
|
||||
{job.jobUrl}
|
||||
</a>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline">Description (original)</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.description ?? ""}</Typography>
|
||||
</Box>
|
||||
{job?.translatedDescription ? (
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline">Translated description</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job.translatedDescription}</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box><Typography variant="overline">Date Applied</Typography><Typography>{job ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">Days Since</Typography><Typography>{job?.daysSince ?? ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">Location</Typography><Typography>{job?.location ?? ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">Salary</Typography><Typography>{job?.salary ?? ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">Next Action</Typography><Typography>{job?.nextAction ?? ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">Follow Up</Typography><Typography>{job?.followUpAt ? new Date(job.followUpAt).toLocaleDateString() : ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">Deadline</Typography><Typography>{job?.deadline ? new Date(job.deadline).toLocaleDateString() : ""}</Typography></Box>
|
||||
<Box><Typography variant="overline">Tags</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{tags.length === 0 ? <Typography sx={{ color: "text.secondary" }}>-</Typography> : tags.map((t) => <Chip key={t} label={t} size="small" />)}</Box></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Attachment Types</Typography><Typography>{checklist}</Typography></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Job URL</Typography><Typography>{job?.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{job.jobUrl}</a> : ""}</Typography></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", flexWrap: "wrap", mb: 0.5 }}>
|
||||
<Typography variant="overline">Summary and skills</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={refreshingAi}
|
||||
onClick={async () => {
|
||||
if (!jobId) return;
|
||||
if (!(await confirmAction("Overwrite the current summary and skills with a freshly generated version?", { title: "Refresh AI summary", confirmLabel: "Refresh" }))) return;
|
||||
setRefreshingAi(true);
|
||||
try {
|
||||
const res = await api.post<JobApplication>(`/jobapplications/${jobId}/refresh-ai`);
|
||||
setJob(res.data);
|
||||
toast("Summary and skills refreshed.", "success");
|
||||
} catch (error: any) {
|
||||
toast(error?.response?.data || "Failed to refresh summary and skills.", "error");
|
||||
} finally {
|
||||
setRefreshingAi(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{refreshingAi ? "Refreshing..." : "Refresh summary and skills"}
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" disabled={refreshingAi} onClick={async () => {
|
||||
if (!jobId) return;
|
||||
if (!(await confirmAction("Overwrite the current summary and skills with a freshly generated version?", { title: "Refresh AI summary", confirmLabel: "Refresh" }))) return;
|
||||
setRefreshingAi(true);
|
||||
try {
|
||||
const res = await api.post<JobApplication>(`/jobapplications/${jobId}/refresh-ai`);
|
||||
setJob(res.data);
|
||||
toast("Summary and skills refreshed.", "success");
|
||||
} catch (error: any) {
|
||||
toast(error?.response?.data || "Failed to refresh summary and skills.", "error");
|
||||
} finally {
|
||||
setRefreshingAi(false);
|
||||
}
|
||||
}}>{refreshingAi ? "Refreshing..." : "Refresh summary and skills"}</Button>
|
||||
</Box>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.fullSummary ?? job?.shortSummary ?? "No summary yet."}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline">Notes</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.notes ?? ""}</Typography>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{summaryFirstText}</Typography>
|
||||
</Box>
|
||||
{rawDescriptionText ? <Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Original role text</Typography><Typography sx={{ whiteSpace: "pre-wrap", color: "text.secondary" }}>{rawDescriptionText}</Typography></Box> : null}
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Notes</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.notes ?? ""}</Typography></Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 1 && jobId && <Correspondence jobId={jobId} />}
|
||||
{tab === 2 && jobId && <Attachments jobId={jobId} />}
|
||||
|
||||
{tab === 3 && (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}>
|
||||
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(job?.coverLetterText || "")}>Copy</Button>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box sx={{ p: 1.5, 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 }}>
|
||||
<Typography variant="overline">Tailored CV for this role</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button size="small" variant="outlined" onClick={async () => {
|
||||
try {
|
||||
const me = await api.get<{ profileCvText?: string | null }>("/auth/me");
|
||||
setTailoredCvText(me.data?.profileCvText ?? "");
|
||||
toast("Loaded your master CV into the tailored editor.", "success");
|
||||
} catch {
|
||||
toast("Failed to load your master CV.", "error");
|
||||
}
|
||||
}}>Start from master CV</Button>
|
||||
<Button size="small" variant="outlined" disabled={generatingPackage} onClick={async () => {
|
||||
if (!jobId) return;
|
||||
setGeneratingPackage(true);
|
||||
try {
|
||||
const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`);
|
||||
setApplicationPackage(res.data);
|
||||
setTailoredCvText(res.data.tailoredCvText ?? "");
|
||||
toast("Application package generated.", "success");
|
||||
} catch (error: any) {
|
||||
toast(error?.response?.data || "Failed to generate application package.", "error");
|
||||
} finally {
|
||||
setGeneratingPackage(false);
|
||||
}
|
||||
}}>{generatingPackage ? "Generating..." : "Generate application package"}</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => setTailoredCvText("")}>Clear</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(tailoredCvText)}>Copy</Button>
|
||||
<Button size="small" variant="contained" disabled={savingTailoredCv} onClick={async () => {
|
||||
if (!jobId) return;
|
||||
setSavingTailoredCv(true);
|
||||
try {
|
||||
await api.put(`/jobapplications/${jobId}/tailored-cv`, { tailoredCvText });
|
||||
setJob((prev) => prev ? { ...prev, tailoredCvText, tailoredCvUpdatedAt: new Date().toISOString() } : prev);
|
||||
setReadiness(null);
|
||||
setInterviewPrep(null);
|
||||
toast("Tailored CV saved.", "success");
|
||||
} catch (error: any) {
|
||||
toast(error?.response?.data || "Failed to save tailored CV.", "error");
|
||||
} finally {
|
||||
setSavingTailoredCv(false);
|
||||
}
|
||||
}}>{savingTailoredCv ? "Saving..." : "Save tailored CV"}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>Start from your master CV, generate a tailored application package, then edit the resume specifically for this company, role, and interview process.</Typography>
|
||||
<TextField value={tailoredCvText} onChange={(e) => setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder="Paste or rewrite the version of your CV you want to use for this role." />
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>Last updated: {job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : "Not saved yet"}</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.coverLetterText ?? ""}</Typography>
|
||||
|
||||
{applicationPackage ? (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<DraftCard title="Cover letter draft" content={applicationPackage.coverLetterDraft ?? "No draft available."} />
|
||||
<DraftCard title="Short application answer" content={applicationPackage.applicationAnswerDraft ?? "No draft available."} />
|
||||
<DraftCard title="Recruiter message draft" content={applicationPackage.recruiterMessageDraft ?? "No draft available."} />
|
||||
<ListCard title="Key points to emphasize" items={applicationPackage.keyPoints} />
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 4 && (
|
||||
<Box>
|
||||
{loadingDraft ? (
|
||||
<Box sx={{ py: 4, display: "flex", justifyContent: "center" }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : followUpDraft ? (
|
||||
{loadingDraft ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : followUpDraft ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="overline">Reason</Typography>
|
||||
<Typography>{followUpDraft.reason}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline">Suggested send date</Typography>
|
||||
<Typography>{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()}</Typography>
|
||||
</Box>
|
||||
<Box><Typography variant="overline">Reason</Typography><Typography>{followUpDraft.reason}</Typography></Box>
|
||||
<Box><Typography variant="overline">Suggested send date</Typography><Typography>{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()}</Typography></Box>
|
||||
<TextField label="Recipient" value={draftRecipient} onChange={(e) => setDraftRecipient(e.target.value)} helperText="Defaults to the company recruiter email when available." />
|
||||
<TextField label="Subject" value={draftSubject} onChange={(e) => setDraftSubject(e.target.value)} />
|
||||
<TextField label="Draft" multiline minRows={8} value={draftBody} onChange={(e) => setDraftBody(e.target.value)} />
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="outlined" onClick={() => navigator.clipboard.writeText(`${draftSubject}\n\n${draftBody}`)}>Copy draft</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={sendingDraft || !draftSubject.trim() || !draftBody.trim()}
|
||||
onClick={async () => {
|
||||
if (!jobId) return;
|
||||
setSendingDraft(true);
|
||||
try {
|
||||
await api.post(`/jobapplications/${jobId}/send-followup`, {
|
||||
toEmail: draftRecipient || null,
|
||||
subject: draftSubject,
|
||||
body: draftBody,
|
||||
nextFollowUpAt: followUpDraft.suggestedSendOn || null,
|
||||
});
|
||||
setJob((prev) => prev ? { ...prev, followUpAt: followUpDraft.suggestedSendOn } : prev);
|
||||
} finally {
|
||||
setSendingDraft(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{sendingDraft ? "Sending..." : "Send and log email"}
|
||||
</Button>
|
||||
<Button variant="contained" disabled={sendingDraft || !draftSubject.trim() || !draftBody.trim()} onClick={async () => {
|
||||
if (!jobId) return;
|
||||
setSendingDraft(true);
|
||||
try {
|
||||
await api.post(`/jobapplications/${jobId}/send-followup`, { toEmail: draftRecipient || null, subject: draftSubject, body: draftBody, nextFollowUpAt: followUpDraft.suggestedSendOn || null });
|
||||
setJob((prev) => prev ? { ...prev, followUpAt: followUpDraft.suggestedSendOn } : prev);
|
||||
setReadiness(null);
|
||||
toast("Follow-up sent and logged.", "success");
|
||||
} catch (error: any) {
|
||||
toast(error?.response?.data || "Failed to send follow-up.", "error");
|
||||
} finally {
|
||||
setSendingDraft(false);
|
||||
}
|
||||
}}>{sendingDraft ? "Sending..." : "Send and log email"}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography sx={{ color: "text.secondary" }}>No draft available.</Typography>
|
||||
)}
|
||||
) : <Typography sx={{ color: "text.secondary" }}>No draft available.</Typography>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 5 && isAdmin && (
|
||||
{tab === 5 && (
|
||||
<Box>
|
||||
{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" }}>
|
||||
<Box><Typography variant="overline">How you match</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Chip label={`${candidateFit.matchScore}% match`} color={candidateFit.matchScore >= 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} size="small" />
|
||||
{fitLevel ? <Chip label={fitLevel.label} color={fitLevel.color} size="small" /> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
<DraftCard title="Tailored pitch" content={candidateFit.tailoredPitch} />
|
||||
<SectionChips title="Strong matches" items={candidateFit.strengths} color="success" />
|
||||
<SectionChips title="Possible gaps" items={candidateFit.gaps} color="warning" outlined />
|
||||
<TwoColumnSection leftTitle="What to mention" leftItems={candidateFit.mention} rightTitle="What not to overstate" rightItems={candidateFit.avoid} />
|
||||
<TwoColumnSection leftTitle="Improve your CV for this role" leftItems={candidateFit.cvImprovements} rightTitle="Missing keywords to consider" rightItems={candidateFit.missingKeywords} />
|
||||
<TwoColumnSection leftTitle="Interview prep" leftItems={candidateFit.interviewPrep} rightTitle="CV guidance" rightItems={candidateFit.guidance.cv} />
|
||||
<TwoColumnSection leftTitle="Cover letter guidance" leftItems={candidateFit.guidance.coverLetter} rightTitle="Recruiter message guidance" rightItems={candidateFit.guidance.recruiterMessage} />
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<DraftCard title="Cover letter draft" content={candidateFit.coverLetterDraft ?? "No draft available yet."} />
|
||||
<DraftCard title="Recruiter message draft" content={candidateFit.recruiterMessageDraft ?? "No draft available yet."} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : <Typography sx={{ color: "text.secondary" }}>Add your profile CV text on the Profile page to generate a candidate fit analysis for this role.</Typography>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 6 && (
|
||||
<Box>
|
||||
{loadingInterviewPrep ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : interviewPrep ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<DraftCard title="Interview prep brief" content={interviewPrep.summary} />
|
||||
<TwoColumnSection leftTitle="Talking points" leftItems={interviewPrep.talkingPoints} rightTitle="Likely questions" rightItems={interviewPrep.likelyQuestions} />
|
||||
<ListCard title="Weak spots to prepare for" items={interviewPrep.weakSpots} />
|
||||
</Box>
|
||||
) : <Typography sx={{ color: "text.secondary" }}>No interview prep available yet.</Typography>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 7 && (
|
||||
<Box>
|
||||
{loadingReadiness ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : readiness ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="h6">Application readiness</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Chip label={`${readiness.score}% ready`} color={readiness.score >= 80 ? "success" : readiness.score >= 60 ? "warning" : "default"} />
|
||||
<Chip label={readiness.level} variant="outlined" />
|
||||
</Box>
|
||||
</Box>
|
||||
<TwoColumnSection leftTitle="Completed" leftItems={readiness.completed} rightTitle="Still missing" rightItems={readiness.missing} />
|
||||
<ListCard title="Smart reminders" items={readiness.reminders} />
|
||||
</Box>
|
||||
) : <Typography sx={{ color: "text.secondary" }}>No readiness analysis available yet.</Typography>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 8 && isAdmin && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{history.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>No history yet.</Typography>
|
||||
) : (
|
||||
history.map((e) => <PaperRow key={e.id} type={e.type} oldValue={e.oldValue} newValue={e.newValue} at={e.at} note={e.note} />)
|
||||
)}
|
||||
{history.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No history yet.</Typography> : history.map((entry) => <PaperRow key={entry.id} type={entry.type} oldValue={entry.oldValue} newValue={entry.newValue} at={entry.at} note={entry.note} />)}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
@@ -301,16 +381,61 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="overline">{title}</Typography>
|
||||
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>Copy</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
|
||||
{items.length ? items.map((item) => <Chip key={item} label={item} color={color} variant={outlined ? "outlined" : "filled"} size="small" />) : <Typography sx={{ color: "text.secondary" }}>Nothing highlighted yet.</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TwoColumnSection({ leftTitle, leftItems, rightTitle, rightItems }: { leftTitle: string; leftItems: string[]; rightTitle: string; rightItems: string[] }) {
|
||||
return (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<ListCard title={leftTitle} items={leftItems} />
|
||||
<ListCard title={rightTitle} items={rightItems} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ListCard({ title, items }: { title: string; items: string[] }) {
|
||||
return (
|
||||
<Box sx={{ p: 1.5, 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 }}>
|
||||
<Typography variant="overline">{title}</Typography>
|
||||
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>Copy</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
||||
{items.length ? items.map((item, index) => <Typography key={`${title}-${index}-${item}`} sx={{ color: "text.primary" }}>• {item}</Typography>) : <Typography sx={{ color: "text.secondary" }}>Nothing highlighted yet.</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftCard({ title, content }: { title: string; content: string }) {
|
||||
return (
|
||||
<Box sx={{ p: 1.5, 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 }}>
|
||||
<Typography variant="overline">{title}</Typography>
|
||||
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(content)}>Copy</Button>
|
||||
</Box>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{content}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PaperRow({ type, oldValue, newValue, at, note }: { type: string; oldValue?: string; newValue?: string; at: string; note?: string }) {
|
||||
return (
|
||||
<Box sx={{ border: "1px solid rgba(15,23,42,0.08)", borderRadius: 2, p: 1.25, background: "rgba(255,255,255,0.6)" }}>
|
||||
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
||||
{type}
|
||||
{oldValue || newValue ? (
|
||||
<span style={{ fontWeight: 700, opacity: 0.7 }}>
|
||||
{" "}({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""})
|
||||
</span>
|
||||
) : null}
|
||||
{oldValue || newValue ? <span style={{ fontWeight: 700, opacity: 0.7 }}>{" "}({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""})</span> : null}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{at ? new Date(at).toLocaleString() : ""}
|
||||
|
||||
@@ -265,6 +265,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
};
|
||||
|
||||
const generateOverview = (job: JobApplication) => {
|
||||
if (job.fullSummary) return job.fullSummary;
|
||||
if (job.shortSummary) return job.shortSummary;
|
||||
const src = (job.description || job.notes || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
return src.length > 220 ? `${src.slice(0, 220)}...` : src;
|
||||
@@ -357,6 +358,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<span>{job.jobTitle}</span>
|
||||
{job.needsFollowUp ? <Chip size="small" label="Follow up" title={job.followUpReason ?? undefined} sx={{ fontWeight: 800 }} /> : null}
|
||||
{!job.tailoredCvText && !job.isDeleted ? <Chip size="small" label="CV missing" color="warning" variant="outlined" /> : null}
|
||||
{job.tailoredCvText ? <Chip size="small" label="CV ready" color="success" variant="outlined" /> : null}
|
||||
</Box>
|
||||
</TableCell>
|
||||
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Paper,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import { JobApplication } from "../types";
|
||||
@@ -14,15 +8,63 @@ import { useToast } from "../toast";
|
||||
|
||||
import JobDetailsDialog from "./JobDetailsDialog";
|
||||
|
||||
type ReminderGroups = {
|
||||
missingCv: JobApplication[];
|
||||
missingInterviewNotes: JobApplication[];
|
||||
overdueFollowUp: JobApplication[];
|
||||
other: JobApplication[];
|
||||
};
|
||||
|
||||
function groupItems(items: JobApplication[]): ReminderGroups {
|
||||
const groups: ReminderGroups = { missingCv: [], missingInterviewNotes: [], overdueFollowUp: [], other: [] };
|
||||
items.forEach((item) => {
|
||||
const reason = (item.followUpReason ?? "").toLowerCase();
|
||||
if (reason.includes("tailored cv")) groups.missingCv.push(item);
|
||||
else if (reason.includes("interview prep") || reason.includes("prep notes")) groups.missingInterviewNotes.push(item);
|
||||
else if (reason.includes("follow-up") || reason.includes("follow up")) groups.overdueFollowUp.push(item);
|
||||
else groups.other.push(item);
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: string; items: JobApplication[]; onOpen: (id: number) => void; onSetFollowUp: (id: number, days: number | null) => void }) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
{items.map((j) => (
|
||||
<Paper key={j.id} sx={{ p: 1.5, display: "grid", gridTemplateColumns: "1fr auto", gap: 1, alignItems: "center" }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
||||
{j.company?.name ?? ""} <span style={{ fontWeight: 700, opacity: 0.7 }}>•</span> {j.jobTitle}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
|
||||
{j.needsFollowUp ? <Chip size="small" color="warning" label="Follow up" /> : null}
|
||||
{j.followUpReason ? <Chip size="small" label={j.followUpReason} variant="outlined" /> : null}
|
||||
{j.followUpAt ? <Chip size="small" label={`Follow-up: ${new Date(j.followUpAt).toLocaleDateString()}`} variant="outlined" /> : null}
|
||||
<Chip size="small" label={j.status} variant="outlined" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
<Button size="small" variant="outlined" onClick={() => onOpen(j.id)}>Open</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button>
|
||||
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>Clear</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RemindersView() {
|
||||
const { toast } = useToast();
|
||||
const [items, setItems] = useState<JobApplication[]>([]);
|
||||
const [openJobId, setOpenJobId] = useState<number | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
const res = await api.get<JobApplication[]>("/jobapplications/reminders", {
|
||||
params: { upcomingDays: 14 },
|
||||
});
|
||||
const res = await api.get<JobApplication[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } });
|
||||
setItems(res.data);
|
||||
};
|
||||
|
||||
@@ -30,17 +72,12 @@ export default function RemindersView() {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const grouped = useMemo(() => groupItems(items), [items]);
|
||||
|
||||
const setFollowUp = async (id: number, daysFromNow: number | null) => {
|
||||
try {
|
||||
const d =
|
||||
daysFromNow === null
|
||||
? null
|
||||
: new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
await api.patch(`/jobapplications/${id}/followup`, {
|
||||
followUpAt: d,
|
||||
});
|
||||
const d = daysFromNow === null ? null : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
await api.patch(`/jobapplications/${id}/followup`, { followUpAt: d });
|
||||
toast(daysFromNow === null ? "Follow-up cleared." : "Follow-up set.", "success");
|
||||
await load();
|
||||
} catch {
|
||||
@@ -50,97 +87,26 @@ export default function RemindersView() {
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Needs Follow-up
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>Needs Follow-up</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
|
||||
Based on your rules and upcoming follow-up dates.
|
||||
Grouped by the most useful next action so you can fix gaps faster.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{items.map((j) => (
|
||||
<Paper
|
||||
key={j.id}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
gap: 1,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
||||
{j.company?.name ?? ""}{" "}
|
||||
<span style={{ fontWeight: 700, opacity: 0.7 }}>�</span>{" "}
|
||||
{j.jobTitle}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
|
||||
{j.needsFollowUp ? (
|
||||
<Chip size="small" color="warning" label="Follow up" />
|
||||
) : null}
|
||||
{j.followUpReason ? (
|
||||
<Chip size="small" label={j.followUpReason} variant="outlined" />
|
||||
) : null}
|
||||
{j.followUpAt ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Follow-up: ${new Date(j.followUpAt).toLocaleDateString()}`}
|
||||
variant="outlined"
|
||||
/>
|
||||
) : null}
|
||||
<Chip size="small" label={j.status} variant="outlined" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<ReminderSection title="Missing tailored CV" items={grouped.missingCv} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title="Missing interview prep" items={grouped.missingInterviewNotes} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title="Follow-up due" items={grouped.overdueFollowUp} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title="Other reminders" items={grouped.other} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
<Button size="small" variant="outlined" onClick={() => setOpenJobId(j.id)}>
|
||||
Open
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => void setFollowUp(j.id, 1)}>
|
||||
Tomorrow
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => void setFollowUp(j.id, 3)}>
|
||||
+3d
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => void setFollowUp(j.id, 7)}>
|
||||
+7d
|
||||
</Button>
|
||||
<Button size="small" onClick={() => void setFollowUp(j.id, null)}>
|
||||
Clear
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
{items.length === 0 && (
|
||||
<Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>
|
||||
Nothing to follow up right now.
|
||||
</Typography>
|
||||
)}
|
||||
{items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>Nothing to follow up right now.</Typography> : null}
|
||||
</Box>
|
||||
|
||||
<JobDetailsDialog
|
||||
open={openJobId !== null}
|
||||
jobId={openJobId}
|
||||
onClose={() => setOpenJobId(null)}
|
||||
/>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
Tip: focus on tailored CV and interview prep first for the highest-value roles.
|
||||
</Typography>
|
||||
|
||||
<JobDetailsDialog open={openJobId !== null} jobId={openJobId} onClose={() => setOpenJobId(null)} />
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
lign: "center", py: 3 }}>
|
||||
Nothing to follow up right now.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<JobDetailsDialog
|
||||
open={openJobId !== null}
|
||||
jobId={openJobId}
|
||||
onClose={() => setOpenJobId(null)}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Alert, Avatar, Box, Button, Chip, Divider, Paper, TextField, Typography } from "@mui/material";
|
||||
import { Alert, Avatar, Box, Button, Chip, Divider, LinearProgress, Paper, TextField, Typography } from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
@@ -14,6 +14,7 @@ type MeResponse = {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
displayName?: string;
|
||||
profileCvText?: string;
|
||||
roles?: string[];
|
||||
googleLink?: {
|
||||
linked: boolean;
|
||||
@@ -35,8 +36,10 @@ function initialsFrom(values: Array<string | undefined>) {
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { toast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadingCv, setUploadingCv] = useState(false);
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [userName, setUserName] = useState("");
|
||||
@@ -44,6 +47,7 @@ export default function ProfilePage() {
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [headline, setHeadline] = useState("");
|
||||
const [profileCvText, setProfileCvText] = useState("");
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
@@ -57,6 +61,7 @@ export default function ProfilePage() {
|
||||
setFirstName(r.data?.firstName ?? "");
|
||||
setLastName(r.data?.lastName ?? "");
|
||||
setDisplayName(r.data?.displayName ?? "");
|
||||
setProfileCvText(r.data?.profileCvText ?? "");
|
||||
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
|
||||
} catch {
|
||||
setMe(null);
|
||||
@@ -70,6 +75,7 @@ export default function ProfilePage() {
|
||||
const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]);
|
||||
const isLocal = me?.provider === "local";
|
||||
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
|
||||
const cvWordCount = profileCvText.trim() ? profileCvText.trim().split(/\s+/).length : 0;
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2.5 }}>
|
||||
@@ -87,6 +93,7 @@ export default function ProfilePage() {
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Chip label={me?.provider === "local" ? "Local account" : me?.provider === "google" ? "Google session" : "External session"} color={me?.provider === "local" ? "primary" : "default"} />
|
||||
<Chip label={me?.googleLink?.linked ? `Google linked${me.googleLink.email ? `: ${me.googleLink.email}` : ""}` : "Google not linked"} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
|
||||
<Chip label={profileCvText.trim() ? `CV ready · ${cvWordCount} words` : "CV missing"} color={profileCvText.trim() ? "success" : "warning"} variant={profileCvText.trim() ? "filled" : "outlined"} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -115,6 +122,67 @@ export default function ProfilePage() {
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1", p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6">Master CV</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Paste your resume text here or import a .txt/.md version. The app uses it to explain fit, gaps, interview talking points, and tailored messaging.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".txt,.md,text/plain,text/markdown"
|
||||
style={{ display: "none" }}
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
setUploadingCv(true);
|
||||
try {
|
||||
await api.post("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
await loadProfile();
|
||||
toast("CV text imported.", "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || "Failed to import CV text."), "error");
|
||||
} finally {
|
||||
setUploadingCv(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button variant="outlined" disabled={!isLocal || uploadingCv} onClick={() => fileInputRef.current?.click()}>
|
||||
{uploadingCv ? "Importing..." : "Import .txt/.md"}
|
||||
</Button>
|
||||
<Button variant="text" disabled={!profileCvText.trim()} onClick={() => navigator.clipboard.writeText(profileCvText)}>
|
||||
Copy CV text
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
{uploadingCv ? <LinearProgress sx={{ mb: 1.5 }} /> : null}
|
||||
<TextField
|
||||
label="Profile CV / master resume text"
|
||||
value={profileCvText}
|
||||
onChange={(e) => setProfileCvText(e.target.value)}
|
||||
helperText="Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next."
|
||||
multiline
|
||||
minRows={12}
|
||||
disabled={!isLocal}
|
||||
fullWidth
|
||||
/>
|
||||
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{cvWordCount} words
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
Tip: plain text works best right now.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Google account: {me?.googleLink?.linked ? `Linked${me.googleLink.email ? ` to ${me.googleLink.email}` : ""}` : "Not linked"}
|
||||
@@ -125,7 +193,7 @@ export default function ProfilePage() {
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.put("/auth/profile", { email, userName, firstName, lastName, displayName });
|
||||
await api.put("/auth/profile", { email, userName, firstName, lastName, displayName, profileCvText });
|
||||
window.localStorage.setItem("profileHeadline", headline.trim());
|
||||
await loadProfile();
|
||||
toast("Profile updated.", "success");
|
||||
|
||||
@@ -48,6 +48,53 @@ export interface JobApplication {
|
||||
followUpReason?: string;
|
||||
}
|
||||
|
||||
export interface CandidateFitChannelGuidance {
|
||||
cv: string[];
|
||||
coverLetter: string[];
|
||||
interview: string[];
|
||||
recruiterMessage: string[];
|
||||
}
|
||||
|
||||
export interface CandidateFit {
|
||||
matchSummary: string;
|
||||
fitLevel: string;
|
||||
matchScore: number;
|
||||
strengths: string[];
|
||||
gaps: string[];
|
||||
mention: string[];
|
||||
avoid: string[];
|
||||
cvImprovements: string[];
|
||||
missingKeywords: string[];
|
||||
interviewPrep: string[];
|
||||
tailoredPitch: string;
|
||||
guidance: CandidateFitChannelGuidance;
|
||||
coverLetterDraft?: string | null;
|
||||
recruiterMessageDraft?: string | null;
|
||||
}
|
||||
|
||||
export interface InterviewPrepResponse {
|
||||
summary: string;
|
||||
talkingPoints: string[];
|
||||
likelyQuestions: string[];
|
||||
weakSpots: string[];
|
||||
}
|
||||
|
||||
export interface ReadinessResponse {
|
||||
score: number;
|
||||
level: string;
|
||||
completed: string[];
|
||||
missing: string[];
|
||||
reminders: string[];
|
||||
}
|
||||
|
||||
export interface ApplicationPackageResponse {
|
||||
tailoredCvText: string;
|
||||
coverLetterDraft?: string | null;
|
||||
applicationAnswerDraft?: string | null;
|
||||
recruiterMessageDraft?: string | null;
|
||||
keyPoints: string[];
|
||||
}
|
||||
|
||||
export interface CorrespondenceMessage {
|
||||
id: number;
|
||||
jobApplicationId: number;
|
||||
|
||||
Reference in New Issue
Block a user