Optimize workspace and daily-loop surfaces
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
@@ -135,8 +135,7 @@ export default function DashboardView() {
|
||||
const [months, setMonths] = useState<6 | 12 | 24>(12);
|
||||
const [prefs, setPrefs] = useState<Prefs>(() => loadPrefs());
|
||||
const [prefsAnchor, setPrefsAnchor] = useState<HTMLElement | null>(null);
|
||||
const summaryResource = useViewResource(
|
||||
async () => {
|
||||
const loadSummary = useCallback(async () => {
|
||||
const [statsResponse, overviewResponse, remindersResponse] = await Promise.all([
|
||||
api.get<JobStats>("/jobapplications/stats"),
|
||||
api.get<OverviewAnalytics>("/jobapplications/analytics-overview"),
|
||||
@@ -148,7 +147,10 @@ export default function DashboardView() {
|
||||
overview: overviewResponse.data,
|
||||
reminderJobs: Array.isArray(remindersResponse.data) ? remindersResponse.data : [],
|
||||
};
|
||||
},
|
||||
}, []);
|
||||
|
||||
const summaryResource = useViewResource(
|
||||
loadSummary,
|
||||
{
|
||||
initialData: { stats: null as JobStats | null, overview: null as OverviewAnalytics | null, reminderJobs: [] as ReminderJob[] },
|
||||
errorMessage: "Unable to load dashboard summary data right now.",
|
||||
@@ -156,8 +158,7 @@ export default function DashboardView() {
|
||||
},
|
||||
);
|
||||
|
||||
const trendsResource = useViewResource(
|
||||
async () => {
|
||||
const loadTrends = useCallback(async () => {
|
||||
const params = { months };
|
||||
const [analyticsResponse, tagsResponse, trendsResponse] = await Promise.all([
|
||||
api.get<AnalyticsPoint[]>("/jobapplications/analytics", { params }),
|
||||
@@ -170,7 +171,10 @@ export default function DashboardView() {
|
||||
tags: tagsResponse.data ?? [],
|
||||
tagTrends: trendsResponse.data,
|
||||
};
|
||||
},
|
||||
}, [months]);
|
||||
|
||||
const trendsResource = useViewResource(
|
||||
loadTrends,
|
||||
{
|
||||
initialData: { analytics: [] as AnalyticsPoint[], tags: [] as TagPoint[], tagTrends: null as TagTrendResponse | null },
|
||||
errorMessage: "Unable to load dashboard trends right now.",
|
||||
@@ -185,25 +189,54 @@ export default function DashboardView() {
|
||||
const tags = trendsResource.data.tags;
|
||||
const tagTrends = trendsResource.data.tagTrends;
|
||||
|
||||
const tagColors = useMemo(
|
||||
() => [theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, theme.palette.info.main, theme.palette.error.main],
|
||||
[theme.palette.error.main, theme.palette.info.main, theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main],
|
||||
);
|
||||
|
||||
const summaryView = useMemo(() => {
|
||||
const topSource = overview?.responseRateBySource?.[0];
|
||||
const missingCvCount = reminderJobs.filter((job) => job.workflowSignal?.hasPackageGap).length;
|
||||
|
||||
return {
|
||||
funnelMax: overview?.funnel?.length ? Math.max(...overview.funnel.map((item) => item.count)) : 0,
|
||||
topSource,
|
||||
missingCvCount,
|
||||
priorityJobs: reminderJobs.slice(0, 5),
|
||||
};
|
||||
}, [overview, reminderJobs]);
|
||||
|
||||
const trendsView = useMemo(() => {
|
||||
const appliedValues = analytics.map((x) => x.applied);
|
||||
const responseValues = analytics.map((x) => x.responses);
|
||||
const chartWidth = isMobile ? Math.max(420, analytics.length * 70) : 860;
|
||||
const chartHeight = isMobile ? 210 : 250;
|
||||
const appliedPath = buildLinePath(appliedValues, chartWidth, chartHeight);
|
||||
const responsePath = buildLinePath(responseValues, chartWidth, chartHeight);
|
||||
const tagColors = [theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, theme.palette.info.main, theme.palette.error.main];
|
||||
const funnelMax = overview?.funnel?.length ? Math.max(...overview.funnel.map((item) => item.count)) : 0;
|
||||
const topSource = overview?.responseRateBySource?.[0];
|
||||
const missingCvCount = reminderJobs.filter((job) => job.workflowSignal?.hasPackageGap).length;
|
||||
const totalApplied = appliedValues.reduce((sum, value) => sum + value, 0);
|
||||
const totalResponses = responseValues.reduce((sum, value) => sum + value, 0);
|
||||
const maxTagCount = Math.max(...tags.map((item) => item.count), 1);
|
||||
|
||||
const metricCards = [
|
||||
return {
|
||||
appliedValues,
|
||||
responseValues,
|
||||
chartWidth,
|
||||
chartHeight,
|
||||
appliedPath: buildLinePath(appliedValues, chartWidth, chartHeight),
|
||||
responsePath: buildLinePath(responseValues, chartWidth, chartHeight),
|
||||
totalApplied,
|
||||
totalResponses,
|
||||
responseRate: totalApplied > 0 ? Math.round((totalResponses / totalApplied) * 100) : 0,
|
||||
maxTagCount,
|
||||
};
|
||||
}, [analytics, isMobile, tags]);
|
||||
|
||||
const metricCards = useMemo(() => ([
|
||||
{
|
||||
label: t("dashboardActiveApplications"),
|
||||
value: stats?.active ?? 0,
|
||||
sub: t("dashboardCurrentlyInProgress"),
|
||||
icon: <TrendingUpIcon fontSize="small" />,
|
||||
tone: theme.palette.primary.main,
|
||||
spark: appliedValues,
|
||||
spark: trendsView.appliedValues,
|
||||
},
|
||||
{
|
||||
label: t("dashboardApplied30Days"),
|
||||
@@ -211,7 +244,7 @@ export default function DashboardView() {
|
||||
sub: t("dashboardNewApplications"),
|
||||
icon: <AutoGraphIcon fontSize="small" />,
|
||||
tone: theme.palette.success.main,
|
||||
spark: appliedValues.slice(-6),
|
||||
spark: trendsView.appliedValues.slice(-6),
|
||||
},
|
||||
{
|
||||
label: t("dashboardMedianFirstResponse"),
|
||||
@@ -219,7 +252,7 @@ export default function DashboardView() {
|
||||
sub: t("dashboardDaysUntilFirstReply"),
|
||||
icon: <MailOutlineIcon fontSize="small" />,
|
||||
tone: theme.palette.info.main,
|
||||
spark: responseValues,
|
||||
spark: trendsView.responseValues,
|
||||
},
|
||||
{
|
||||
label: t("dashboardResponsesLogged"),
|
||||
@@ -227,30 +260,28 @@ export default function DashboardView() {
|
||||
sub: t("dashboardAcrossActiveJobs"),
|
||||
icon: <BusinessOutlinedIcon fontSize="small" />,
|
||||
tone: theme.palette.warning.main,
|
||||
spark: responseValues.slice(-6),
|
||||
spark: trendsView.responseValues.slice(-6),
|
||||
},
|
||||
];
|
||||
]), [overview?.medianDaysToFirstResponse, overview?.totalResponses, stats?.active, stats?.appliedLast30Days, t, theme.palette.info.main, theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, trendsView.appliedValues, trendsView.responseValues]);
|
||||
|
||||
const togglePref = (key: keyof Prefs) => {
|
||||
const next = { ...prefs, [key]: !prefs[key] };
|
||||
setPrefs(next);
|
||||
const togglePref = useCallback((key: keyof Prefs) => {
|
||||
setPrefs((current) => {
|
||||
const next = { ...current, [key]: !current[key] };
|
||||
savePrefs(next);
|
||||
};
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const totalApplied = appliedValues.reduce((sum, value) => sum + value, 0);
|
||||
const totalResponses = responseValues.reduce((sum, value) => sum + value, 0);
|
||||
const responseRate = totalApplied > 0 ? Math.round((totalResponses / totalApplied) * 100) : 0;
|
||||
const priorityJobs = reminderJobs.slice(0, 5);
|
||||
const getReminderAction = (job: ReminderJob) => getWorkflowAction(job, {
|
||||
const getReminderAction = useCallback((job: ReminderJob) => getWorkflowAction(job, {
|
||||
packageWork: t("jobTablePackageWork"),
|
||||
followUp: t("jobTableFollowUp"),
|
||||
interviewPrep: t("jobTableInterviewStage"),
|
||||
readiness: t("jobTableReadiness"),
|
||||
});
|
||||
}), [t]);
|
||||
|
||||
const openReminderJob = (job: ReminderJob) => {
|
||||
const openReminderJob = useCallback((job: ReminderJob) => {
|
||||
navigate(buildWorkflowPath(job));
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -287,9 +318,9 @@ export default function DashboardView() {
|
||||
</Typography>
|
||||
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={1.25} sx={{ mt: 2.25, flexWrap: "wrap" }}>
|
||||
<Chip color="primary" variant="outlined" label={t("dashboardResponseRate", { rate: responseRate })} />
|
||||
<Chip variant="outlined" label={`${missingCvCount} ${t("dashboardMissingTailoredCv").toLowerCase()}`} />
|
||||
<Chip variant="outlined" label={topSource ? `${topSource.label}: ${topSource.rate}%` : t("dashboardResponseSources")} />
|
||||
<Chip color="primary" variant="outlined" label={t("dashboardResponseRate", { rate: trendsView.responseRate })} />
|
||||
<Chip variant="outlined" label={`${summaryView.missingCvCount} ${t("dashboardMissingTailoredCv").toLowerCase()}`} />
|
||||
<Chip variant="outlined" label={summaryView.topSource ? `${summaryView.topSource.label}: ${summaryView.topSource.rate}%` : t("dashboardResponseSources")} />
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -378,27 +409,27 @@ export default function DashboardView() {
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardMonthlyApplicationsResponses")}</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
<Chip size="small" label={t("dashboardAppliedCount", { count: totalApplied })} variant="outlined" />
|
||||
<Chip size="small" label={t("dashboardResponsesCount", { count: totalResponses })} variant="outlined" />
|
||||
<Chip size="small" label={t("dashboardAppliedCount", { count: trendsView.totalApplied })} variant="outlined" />
|
||||
<Chip size="small" label={t("dashboardResponsesCount", { count: trendsView.totalResponses })} variant="outlined" />
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, overflowX: "auto", mx: isMobile ? -0.5 : 0, px: isMobile ? 0.5 : 0 }}>
|
||||
<Box sx={{ minWidth: chartWidth }}>
|
||||
<svg width={chartWidth} height={chartHeight} viewBox={`0 0 ${chartWidth} ${chartHeight}`}>
|
||||
<Box sx={{ minWidth: trendsView.chartWidth }}>
|
||||
<svg width={trendsView.chartWidth} height={trendsView.chartHeight} viewBox={`0 0 ${trendsView.chartWidth} ${trendsView.chartHeight}`}>
|
||||
{[0.2, 0.4, 0.6, 0.8].map((tick) => (
|
||||
<line
|
||||
key={tick}
|
||||
x1="0"
|
||||
x2={chartWidth}
|
||||
y1={Math.round(chartHeight * tick)}
|
||||
y2={Math.round(chartHeight * tick)}
|
||||
x2={trendsView.chartWidth}
|
||||
y1={Math.round(trendsView.chartHeight * tick)}
|
||||
y2={Math.round(trendsView.chartHeight * tick)}
|
||||
stroke={alpha(theme.palette.text.primary, 0.08)}
|
||||
strokeDasharray="6 6"
|
||||
/>
|
||||
))}
|
||||
{responsePath ? <path d={responsePath} fill="none" stroke={alpha(theme.palette.info.main, 0.95)} strokeWidth="3" strokeLinecap="round" /> : null}
|
||||
{appliedPath ? <path d={appliedPath} fill="none" stroke={alpha(theme.palette.success.main, 0.95)} strokeWidth="3" strokeLinecap="round" /> : null}
|
||||
{trendsView.responsePath ? <path d={trendsView.responsePath} fill="none" stroke={alpha(theme.palette.info.main, 0.95)} strokeWidth="3" strokeLinecap="round" /> : null}
|
||||
{trendsView.appliedPath ? <path d={trendsView.appliedPath} fill="none" stroke={alpha(theme.palette.success.main, 0.95)} strokeWidth="3" strokeLinecap="round" /> : null}
|
||||
</svg>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1 }}>
|
||||
{analytics.map((point) => (
|
||||
@@ -418,7 +449,7 @@ export default function DashboardView() {
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("dashboardResponseSources")}</Typography>
|
||||
<Stack spacing={1.2}>
|
||||
{(overview?.funnel ?? []).map((item) => {
|
||||
const width = funnelMax ? clamp((item.count / funnelMax) * 100, 0, 100) : 0;
|
||||
const width = summaryView.funnelMax ? clamp((item.count / summaryView.funnelMax) * 100, 0, 100) : 0;
|
||||
return (
|
||||
<Box key={item.label}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
|
||||
@@ -444,10 +475,10 @@ export default function DashboardView() {
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{topSource?.label ?? t("dashboardResponseSources")}</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950, mt: 0.5 }}>{topSource ? `${topSource.rate}%` : "—"}</Typography>
|
||||
<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>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
||||
{topSource ? t("dashboardResponseConversion", { responses: topSource.responses, total: topSource.total }) : t("dashboardNoSourceData")}
|
||||
{summaryView.topSource ? t("dashboardResponseConversion", { responses: summaryView.topSource.responses, total: summaryView.topSource.total }) : t("dashboardNoSourceData")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
@@ -459,11 +490,11 @@ export default function DashboardView() {
|
||||
<SectionCard>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("remindersTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("remindersSubtitle")}</Typography>
|
||||
{priorityJobs.length === 0 ? (
|
||||
{summaryView.priorityJobs.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("remindersNothing")}</Typography>
|
||||
) : (
|
||||
<Stack spacing={1.1}>
|
||||
{priorityJobs.map((job) => {
|
||||
{summaryView.priorityJobs.map((job) => {
|
||||
const action = getReminderAction(job);
|
||||
return (
|
||||
<Box key={job.id} sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: alpha(theme.palette.primary.main, 0.03), display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
|
||||
@@ -28,26 +28,12 @@ import Correspondence from "./Correspondence";
|
||||
import Attachments from "./Attachments";
|
||||
import JobFlowBar from "./JobFlowBar";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
type AttachmentItem = {
|
||||
id: number;
|
||||
fileName: string;
|
||||
uploadDate: string;
|
||||
fileType: string;
|
||||
fileSize: number;
|
||||
purpose?: string | null;
|
||||
useForAi: boolean;
|
||||
};
|
||||
import { useJobWorkspaceBaseData } from "./job-workspace/useJobWorkspaceBaseData";
|
||||
import { useWorkspaceTabCache } from "./job-workspace/useWorkspaceTabCache";
|
||||
|
||||
type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview";
|
||||
type CoverLetterStyle = "balanced" | "concise" | "formal" | "bold";
|
||||
|
||||
type PackageWorkspaceState = {
|
||||
coverLetter: string;
|
||||
applicationAnswer: string;
|
||||
recruiterMessage: string;
|
||||
};
|
||||
|
||||
type TailoredCvPreviewResponse = {
|
||||
templateId: string;
|
||||
html: string;
|
||||
@@ -91,20 +77,6 @@ function copyLines(items: string[]) {
|
||||
const APPLICATION_ANSWER_START = "<<<APPLICATION_ANSWER_DRAFT>>>";
|
||||
const APPLICATION_ANSWER_END = "<<<END_APPLICATION_ANSWER_DRAFT>>>";
|
||||
|
||||
function extractApplicationAnswerDraft(notes?: string | null) {
|
||||
const value = (notes ?? "").trim();
|
||||
if (!value) return "";
|
||||
|
||||
const startIndex = value.indexOf(APPLICATION_ANSWER_START);
|
||||
const endIndex = value.indexOf(APPLICATION_ANSWER_END);
|
||||
if (startIndex >= 0 && endIndex > startIndex) {
|
||||
return value.slice(startIndex + APPLICATION_ANSWER_START.length, endIndex).trim();
|
||||
}
|
||||
|
||||
const legacyMatch = value.match(/Application answer draft:\s*\n([\s\S]*)$/i);
|
||||
return legacyMatch?.[1]?.trim() ?? "";
|
||||
}
|
||||
|
||||
function upsertApplicationAnswerDraft(notes: string | null | undefined, draft: string) {
|
||||
const trimmedNotes = (notes ?? "").trim();
|
||||
const trimmedDraft = draft.trim();
|
||||
@@ -156,11 +128,41 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { confirmAction } = useDialogActions();
|
||||
const followUpCache = useWorkspaceTabCache<FollowUpDraft | null>();
|
||||
const candidateFitCache = useWorkspaceTabCache<CandidateFit | null>();
|
||||
const focusPlanCache = useWorkspaceTabCache<FocusPlanResponse | null>();
|
||||
const interviewPrepCache = useWorkspaceTabCache<InterviewPrepResponse | null>();
|
||||
const readinessCache = useWorkspaceTabCache<ReadinessResponse | null>();
|
||||
const tailoredDraftCache = useWorkspaceTabCache<TailoredCvDraft>();
|
||||
|
||||
const {
|
||||
job,
|
||||
setJob,
|
||||
tab,
|
||||
setTab,
|
||||
history,
|
||||
isAdmin,
|
||||
jobAttachments,
|
||||
selectedAttachmentIds,
|
||||
setSelectedAttachmentIds,
|
||||
profileAvatarImageDataUrl,
|
||||
packageWorkspace,
|
||||
setPackageWorkspace,
|
||||
savedPackageWorkspace,
|
||||
setSavedPackageWorkspace,
|
||||
packageGeneratedAt,
|
||||
setPackageGeneratedAt,
|
||||
draftRecipient,
|
||||
setDraftRecipient,
|
||||
followUpMode,
|
||||
setFollowUpMode,
|
||||
} = useJobWorkspaceBaseData({
|
||||
open,
|
||||
jobId,
|
||||
initialTab,
|
||||
initialFollowUpMode,
|
||||
});
|
||||
|
||||
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 }[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [followUpDraft, setFollowUpDraft] = useState<FollowUpDraft | null>(null);
|
||||
const [loadingDraft, setLoadingDraft] = useState(false);
|
||||
const [sendingDraft, setSendingDraft] = useState(false);
|
||||
@@ -174,8 +176,6 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const [loadingInterviewPrep, setLoadingInterviewPrep] = useState(false);
|
||||
const [readiness, setReadiness] = useState<ReadinessResponse | null>(null);
|
||||
const [loadingReadiness, setLoadingReadiness] = useState(false);
|
||||
const [jobAttachments, setJobAttachments] = useState<AttachmentItem[]>([]);
|
||||
const [selectedAttachmentIds, setSelectedAttachmentIds] = useState<number[]>([]);
|
||||
const [savingApplicationDrafts, setSavingApplicationDrafts] = useState(false);
|
||||
const [generatingPackage, setGeneratingPackage] = useState(false);
|
||||
const [applicationPackage, setApplicationPackage] = useState<ApplicationPackageResponse | null>(null);
|
||||
@@ -189,14 +189,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const [tailoredCvPreview, setTailoredCvPreview] = useState<TailoredCvPreviewResponse | null>(null);
|
||||
const [loadingTailoredCvPreview, setLoadingTailoredCvPreview] = useState(false);
|
||||
const [exportingTailoredCvPdf, setExportingTailoredCvPdf] = useState(false);
|
||||
const [profileAvatarImageDataUrl, setProfileAvatarImageDataUrl] = useState<string | null>(null);
|
||||
const [customPhotoDataUrl, setCustomPhotoDataUrl] = useState<string | null>(null);
|
||||
const [useProfilePhoto, setUseProfilePhoto] = useState(true);
|
||||
const [packageWorkspace, setPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
const [savedPackageWorkspace, setSavedPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
const [packageGeneratedAt, setPackageGeneratedAt] = useState<string | null>(null);
|
||||
const [draftRecipient, setDraftRecipient] = useState("");
|
||||
const [followUpMode, setFollowUpMode] = useState(initialFollowUpMode || "post-apply");
|
||||
const [draftReloadToken, setDraftReloadToken] = useState(0);
|
||||
const [draftSubject, setDraftSubject] = useState("");
|
||||
const [draftBody, setDraftBody] = useState("");
|
||||
@@ -204,60 +198,43 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId) return;
|
||||
setTab(Math.max(0, Math.min(9, initialTab)));
|
||||
setFollowUpDraft(null);
|
||||
setCandidateFit(null);
|
||||
setFocusPlan(null);
|
||||
setInterviewPrep(null);
|
||||
setReadiness(null);
|
||||
setApplicationPackage(null);
|
||||
setJobAttachments([]);
|
||||
setSelectedAttachmentIds([]);
|
||||
setPackageGeneratedAt(null);
|
||||
setTailoredCvDraft(emptyTailoredCvDraft());
|
||||
setSavedTailoredCvDraft(emptyTailoredCvDraft());
|
||||
setTailoredCvPreview(null);
|
||||
setProfileAvatarImageDataUrl(null);
|
||||
setCustomPhotoDataUrl(null);
|
||||
setUseProfilePhoto(true);
|
||||
setPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
setSavedPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
|
||||
setJob(r.data);
|
||||
const savedWorkspace = {
|
||||
coverLetter: r.data.coverLetterText ?? "",
|
||||
applicationAnswer: extractApplicationAnswerDraft(r.data.notes),
|
||||
recruiterMessage: r.data.recruiterMessageDraft ?? "",
|
||||
};
|
||||
setSavedPackageWorkspace(savedWorkspace);
|
||||
setPackageWorkspace(savedWorkspace);
|
||||
setDraftRecipient(r.data.company?.recruiterEmail ?? "");
|
||||
setFollowUpMode(initialFollowUpMode || (r.data.status?.includes("Interview") ? "post-interview" : r.data.status === "Waiting" ? "waiting-update" : r.data.status === "Offer" ? "offer-checkin" : r.data.status === "Rejected" ? "feedback-request" : "post-apply"));
|
||||
});
|
||||
api.get<AttachmentItem[]>(`/attachments/${jobId}`).then((r) => {
|
||||
const items = Array.isArray(r.data) ? r.data : [];
|
||||
setJobAttachments(items);
|
||||
const defaultIds = items.filter((item) => item.useForAi !== false).slice(0, 3).map((item) => item.id);
|
||||
setSelectedAttachmentIds(defaultIds.length > 0 ? defaultIds : items.slice(0, 3).map((item) => item.id));
|
||||
}).catch(() => {
|
||||
setJobAttachments([]);
|
||||
setSelectedAttachmentIds([]);
|
||||
});
|
||||
api.get(`/auth/me`).then((r) => {
|
||||
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
|
||||
setProfileAvatarImageDataUrl(r.data?.avatarImageDataUrl ?? null);
|
||||
}).catch(() => {
|
||||
setIsAdmin(false);
|
||||
setProfileAvatarImageDataUrl(null);
|
||||
});
|
||||
api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([]));
|
||||
}, [open, jobId, initialTab, initialFollowUpMode]);
|
||||
setDraftReloadToken(0);
|
||||
setDraftSubject("");
|
||||
setDraftBody("");
|
||||
followUpCache.clearCached();
|
||||
candidateFitCache.clearCached();
|
||||
focusPlanCache.clearCached();
|
||||
interviewPrepCache.clearCached();
|
||||
readinessCache.clearCached();
|
||||
tailoredDraftCache.clearCached();
|
||||
}, [open, jobId, followUpCache, candidateFitCache, focusPlanCache, interviewPrepCache, readinessCache, tailoredDraftCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 3) return;
|
||||
const cacheKey = `${jobId}:tailored-cv-draft`;
|
||||
const cached = tailoredDraftCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
const normalized = normalizeTailoredCvDraft(cached);
|
||||
setTailoredCvDraft(normalized);
|
||||
setSavedTailoredCvDraft(normalized);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingTailoredCvDraft(true);
|
||||
api.get<TailoredCvDraft>(`/jobapplications/${jobId}/tailored-cv-draft`).then((r) => {
|
||||
const normalized = normalizeTailoredCvDraft(r.data);
|
||||
tailoredDraftCache.setCached(cacheKey, normalized);
|
||||
setTailoredCvDraft(normalized);
|
||||
setSavedTailoredCvDraft(normalized);
|
||||
}).catch(() => {
|
||||
@@ -265,35 +242,75 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
setTailoredCvDraft(empty);
|
||||
setSavedTailoredCvDraft(empty);
|
||||
}).finally(() => setLoadingTailoredCvDraft(false));
|
||||
}, [open, jobId, tab]);
|
||||
}, [open, jobId, tab, tailoredDraftCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 4) return;
|
||||
const cacheKey = `${jobId}:followup:${followUpMode}:${selectedAttachmentCsv || "none"}:${draftReloadToken}`;
|
||||
const cached = followUpCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
setFollowUpDraft(cached);
|
||||
setDraftSubject(cached.subject);
|
||||
setDraftBody(cached.body);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingDraft(true);
|
||||
api.get<FollowUpDraft>(`/jobapplications/${jobId}/followup-draft`, { params: { mode: followUpMode, attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
|
||||
followUpCache.setCached(cacheKey, r.data);
|
||||
setFollowUpDraft(r.data);
|
||||
setDraftSubject(r.data.subject);
|
||||
setDraftBody(r.data.body);
|
||||
}).catch(() => setFollowUpDraft(null)).finally(() => setLoadingDraft(false));
|
||||
}, [open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv]);
|
||||
}, [open, jobId, tab, followUpMode, draftReloadToken, selectedAttachmentCsv, followUpCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 5 || candidateFit) return;
|
||||
const cacheKey = `${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`;
|
||||
const cached = candidateFitCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
setCandidateFit(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingCandidateFit(true);
|
||||
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setCandidateFit(r.data)).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
|
||||
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv]);
|
||||
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
|
||||
candidateFitCache.setCached(cacheKey, r.data);
|
||||
setCandidateFit(r.data);
|
||||
}).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false));
|
||||
}, [open, jobId, tab, candidateFit, selectedAttachmentCsv, candidateFitCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 6 || focusPlan) return;
|
||||
const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`;
|
||||
const cached = focusPlanCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
setFocusPlan(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingFocusPlan(true);
|
||||
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setFocusPlan(r.data)).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
|
||||
}, [open, jobId, tab, focusPlan, selectedAttachmentCsv]);
|
||||
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
|
||||
focusPlanCache.setCached(cacheKey, r.data);
|
||||
setFocusPlan(r.data);
|
||||
}).catch(() => setFocusPlan(null)).finally(() => setLoadingFocusPlan(false));
|
||||
}, [open, jobId, tab, focusPlan, selectedAttachmentCsv, focusPlanCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 7 || interviewPrep) return;
|
||||
const cacheKey = `${jobId}:interview-prep:${selectedAttachmentCsv || "none"}`;
|
||||
const cached = interviewPrepCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
setInterviewPrep(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingInterviewPrep(true);
|
||||
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => setInterviewPrep(r.data)).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false));
|
||||
}, [open, jobId, tab, interviewPrep, selectedAttachmentCsv]);
|
||||
api.get<InterviewPrepResponse>(`/jobapplications/${jobId}/interview-prep`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }).then((r) => {
|
||||
interviewPrepCache.setCached(cacheKey, r.data);
|
||||
setInterviewPrep(r.data);
|
||||
}).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false));
|
||||
}, [open, jobId, tab, interviewPrep, selectedAttachmentCsv, interviewPrepCache]);
|
||||
|
||||
useEffect(() => {
|
||||
setFollowUpDraft(null);
|
||||
@@ -304,9 +321,19 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 8 || readiness) return;
|
||||
const cacheKey = `${jobId}:readiness`;
|
||||
const cached = readinessCache.getCached(cacheKey);
|
||||
if (cached) {
|
||||
setReadiness(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingReadiness(true);
|
||||
api.get<ReadinessResponse>(`/jobapplications/${jobId}/readiness`).then((r) => setReadiness(r.data)).catch(() => setReadiness(null)).finally(() => setLoadingReadiness(false));
|
||||
}, [open, jobId, tab, readiness]);
|
||||
api.get<ReadinessResponse>(`/jobapplications/${jobId}/readiness`).then((r) => {
|
||||
readinessCache.setCached(cacheKey, r.data);
|
||||
setReadiness(r.data);
|
||||
}).catch(() => setReadiness(null)).finally(() => setLoadingReadiness(false));
|
||||
}, [open, jobId, tab, readiness, readinessCache]);
|
||||
|
||||
const tags: string[] = (() => {
|
||||
const raw = job?.tags;
|
||||
@@ -392,6 +419,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
renderOptions: normalized.renderOptions,
|
||||
status: normalized.status,
|
||||
});
|
||||
tailoredDraftCache.setCached(`${jobId}:tailored-cv-draft`, normalized);
|
||||
setTailoredCvDraft(normalized);
|
||||
setSavedTailoredCvDraft(normalized);
|
||||
setJob((prev) => prev ? {
|
||||
@@ -399,6 +427,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
tailoredCvText: normalized.renderedText,
|
||||
tailoredCvUpdatedAt: new Date().toISOString(),
|
||||
} : prev);
|
||||
readinessCache.clearCached();
|
||||
setReadiness(null);
|
||||
toast("Tailored CV draft saved.", "success");
|
||||
} catch (error: any) {
|
||||
@@ -422,6 +451,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
setGeneratingTailoredCvDraft(true);
|
||||
const res = await api.post<TailoredCvDraft>(`/jobapplications/${jobId}/generate-tailored-cv-draft`, null, { params: { mode: generationMode } });
|
||||
const normalized = normalizeTailoredCvDraft(res.data);
|
||||
tailoredDraftCache.setCached(`${jobId}:tailored-cv-draft`, normalized);
|
||||
setTailoredCvDraft(normalized);
|
||||
setSavedTailoredCvDraft(normalized);
|
||||
setJob((prev) => prev ? {
|
||||
@@ -519,6 +549,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
notes: nextNotes,
|
||||
} : prev);
|
||||
setSavedPackageWorkspace({ ...packageWorkspace });
|
||||
readinessCache.clearCached();
|
||||
interviewPrepCache.clearCached();
|
||||
setReadiness(null);
|
||||
setInterviewPrep(null);
|
||||
toast("Application package saved to this job.", "success");
|
||||
@@ -578,6 +610,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
api.get<CandidateFit>(`/jobapplications/${jobId}/candidate-fit`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }),
|
||||
api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`, { params: { attachmentIds: selectedAttachmentCsv || undefined } }),
|
||||
]);
|
||||
candidateFitCache.setCached(`${jobId}:candidate-fit:${selectedAttachmentCsv || "none"}`, fitRes.data);
|
||||
focusPlanCache.setCached(`${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`, focusRes.data);
|
||||
setCandidateFit(fitRes.data);
|
||||
setFocusPlan(focusRes.data);
|
||||
} catch {
|
||||
@@ -1007,6 +1041,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
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);
|
||||
readinessCache.clearCached();
|
||||
setReadiness(null);
|
||||
toast(t("jobDetailsFollowUpSent"), "success");
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
@@ -61,6 +61,26 @@ interface PagedResult<T> {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
type RowActionSignal = {
|
||||
label: string;
|
||||
detail: string;
|
||||
onClick: () => void;
|
||||
variant: "contained" | "outlined";
|
||||
color: "warning" | "primary";
|
||||
};
|
||||
|
||||
type JobRowViewModel = {
|
||||
job: JobApplication;
|
||||
toneName: string;
|
||||
overview: string;
|
||||
tags: string[];
|
||||
actionSignals: RowActionSignal[];
|
||||
primaryAction: RowActionSignal | null;
|
||||
appliedDateLabel: string;
|
||||
isSelected: boolean;
|
||||
isExpanded: boolean;
|
||||
};
|
||||
|
||||
export type JobTableColumns = {
|
||||
status: boolean;
|
||||
dateApplied: boolean;
|
||||
@@ -107,6 +127,13 @@ function statusTone(status: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function generateOverview(job: JobApplication): string {
|
||||
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;
|
||||
}
|
||||
|
||||
export default function JobTable({ refreshToken, pageSize, onPageSizeChange, columns, onColumnsChange, mode = "jobs" }: Props) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||
@@ -210,7 +237,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
return jobs.filter((job) => needsWorkflowWork(job));
|
||||
}, [jobs, readinessFilter]);
|
||||
|
||||
const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIds.includes(job.id));
|
||||
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
|
||||
const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIdSet.has(job.id));
|
||||
|
||||
const toggleSelectAll = (checked: boolean) => {
|
||||
setSelectedIds(checked ? filteredJobs.map((job) => job.id) : []);
|
||||
@@ -262,7 +291,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
|
||||
const runBulkAction = async (action: "delete" | "restore" | "status", value?: string) => {
|
||||
if (selectedIds.length === 0) return;
|
||||
const selectedJobs = jobs.filter((job) => selectedIds.includes(job.id));
|
||||
const selectedJobs = jobs.filter((job) => selectedIdSet.has(job.id));
|
||||
if (action === "delete" && !(await confirmDelete(selectedJobs))) return;
|
||||
try {
|
||||
await Promise.all(selectedIds.map((id) => {
|
||||
@@ -278,45 +307,38 @@ 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;
|
||||
};
|
||||
|
||||
const buildWorkflowActionDetail = (job: JobApplication) => getWorkflowAction(job, {
|
||||
const buildWorkflowActionSignal = useCallback((job: JobApplication): RowActionSignal | null => {
|
||||
const action = getWorkflowAction(job, {
|
||||
packageWork: t("jobTablePackageWork"),
|
||||
followUp: t("jobTableFollowUp"),
|
||||
interviewPrep: t("jobTableInterviewStage"),
|
||||
readiness: t("jobTableReadiness"),
|
||||
});
|
||||
|
||||
const getActionSignals = (job: JobApplication) => {
|
||||
const action = buildWorkflowActionDetail(job);
|
||||
if (!action || job.isDeleted) return [];
|
||||
|
||||
return [{
|
||||
label: action.label,
|
||||
detail: action.detail,
|
||||
onClick: () => navigate(action.path),
|
||||
variant: action.key === "follow-up" ? "contained" as const : "outlined" as const,
|
||||
color: action.key === "follow-up" ? "warning" as const : "primary" as const,
|
||||
}];
|
||||
};
|
||||
|
||||
const getPrimaryAction = (job: JobApplication) => {
|
||||
const action = buildWorkflowActionDetail(job);
|
||||
if (!action || job.isDeleted) return null;
|
||||
|
||||
return {
|
||||
label: action.label,
|
||||
detail: action.detail,
|
||||
onClick: () => navigate(action.path),
|
||||
variant: action.key === "follow-up" ? "contained" as const : "outlined" as const,
|
||||
color: action.key === "follow-up" ? "warning" as const : "primary" as const,
|
||||
variant: action.key === "follow-up" ? "contained" : "outlined",
|
||||
color: action.key === "follow-up" ? "warning" : "primary",
|
||||
};
|
||||
}, [navigate, t]);
|
||||
|
||||
const rowModels = useMemo<JobRowViewModel[]>(() => filteredJobs.map((job) => {
|
||||
const actionSignal = buildWorkflowActionSignal(job);
|
||||
return {
|
||||
job,
|
||||
toneName: statusTone(job.status),
|
||||
overview: generateOverview(job),
|
||||
tags: parseTags(job.tags),
|
||||
actionSignals: actionSignal ? [actionSignal] : [],
|
||||
primaryAction: actionSignal,
|
||||
appliedDateLabel: new Date(job.dateApplied).toLocaleDateString(),
|
||||
isSelected: selectedIdSet.has(job.id),
|
||||
isExpanded: expanded.includes(job.id),
|
||||
};
|
||||
}), [buildWorkflowActionSignal, expanded, filteredJobs, selectedIdSet]);
|
||||
|
||||
const statusOptions = ["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
const visibleDesktopColumns = 4 + Number(columns.status) + Number(columns.dateApplied) + Number(columns.daysSince) + Number(columns.jobUrl);
|
||||
@@ -498,11 +520,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Box>
|
||||
|
||||
{jobsResource.loading ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("loading")}</Typography> : null}
|
||||
{!jobsResource.loading && !jobsResource.error && filteredJobs.map((job) => {
|
||||
const toneName = statusTone(job.status);
|
||||
const primaryAction = getPrimaryAction(job);
|
||||
const actionSignals = getActionSignals(job);
|
||||
const tags = parseTags(job.tags).slice(0, 6);
|
||||
{!jobsResource.loading && !jobsResource.error && rowModels.map(({ job, toneName, actionSignals, tags, overview, primaryAction, appliedDateLabel, isSelected }) => {
|
||||
const compactTags = tags.slice(0, 6);
|
||||
return (
|
||||
<Paper
|
||||
key={job.id}
|
||||
@@ -517,7 +536,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
<Stack spacing={1.25}>
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 1 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1, minWidth: 0, flex: 1 }}>
|
||||
<Checkbox checked={selectedIds.includes(job.id)} onChange={(e) => toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} />
|
||||
<Checkbox checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>
|
||||
{job.company?.name ?? t("jobTableCompany")}
|
||||
@@ -550,7 +569,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
{columns.dateApplied ? (
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableDateApplied")}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{new Date(job.dateApplied).toLocaleDateString()}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{appliedDateLabel}</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
{columns.daysSince ? (
|
||||
@@ -576,16 +595,16 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{tags.length > 0 ? (
|
||||
{compactTags.length > 0 ? (
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
|
||||
{tags.map((tag) => <Chip key={tag} size="small" label={tag} sx={{ borderRadius: 999 }} />)}
|
||||
{compactTags.map((tag) => <Chip key={tag} size="small" label={tag} sx={{ borderRadius: 999 }} />)}
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableOverview")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, whiteSpace: "pre-wrap", textWrap: "pretty" }}>
|
||||
{generateOverview(job) || t("jobTableNoSummaryYet")}
|
||||
{overview || t("jobTableNoSummaryYet")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -647,17 +666,14 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{jobsResource.loading ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("loading")}</Typography></TableCell></TableRow> : null}
|
||||
{!jobsResource.loading && !jobsResource.error && filteredJobs.map((job) => {
|
||||
const open = expanded.includes(job.id);
|
||||
const toneName = statusTone(job.status);
|
||||
{!jobsResource.loading && !jobsResource.error && rowModels.map(({ job, toneName, actionSignals, primaryAction, appliedDateLabel, overview, tags, isSelected, isExpanded }) => {
|
||||
const tone = toneName === "error" ? theme.palette.error.main : toneName === "warning" ? theme.palette.warning.main : toneName === "success" ? theme.palette.success.main : toneName === "info" ? theme.palette.info.main : theme.palette.primary.main;
|
||||
const primaryAction = getPrimaryAction(job);
|
||||
const actionSignals = getActionSignals(job);
|
||||
const detailTags = tags.slice(0, 8);
|
||||
return (
|
||||
<React.Fragment key={job.id}>
|
||||
<TableRow sx={{ backgroundColor: alpha(tone, theme.palette.mode === "dark" ? 0.1 : 0.06) }}>
|
||||
<TableCell padding="checkbox"><Checkbox checked={selectedIds.includes(job.id)} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell>
|
||||
<TableCell><IconButton size="small" onClick={() => toggleExpanded(job.id)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell>
|
||||
<TableCell padding="checkbox"><Checkbox checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell>
|
||||
<TableCell><IconButton size="small" onClick={() => toggleExpanded(job.id)}>{isExpanded ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell>
|
||||
<TableCell>{job.company?.name ?? ""}</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
@@ -679,7 +695,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.dateApplied ? <TableCell>{new Date(job.dateApplied).toLocaleDateString()}</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}
|
||||
<TableCell align="right">
|
||||
@@ -708,13 +724,13 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell sx={{ py: 0 }} colSpan={visibleDesktopColumns}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<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("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 }}>{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).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" }}>{generateOverview(job) || t("jobTableNoSummaryYet")}</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>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../../api";
|
||||
import { AttachmentItem, JobApplication } from "../../types";
|
||||
|
||||
type PackageWorkspaceState = {
|
||||
coverLetter: string;
|
||||
applicationAnswer: string;
|
||||
recruiterMessage: string;
|
||||
};
|
||||
|
||||
const APPLICATION_ANSWER_START = "<<<APPLICATION_ANSWER_DRAFT>>>";
|
||||
const APPLICATION_ANSWER_END = "<<<END_APPLICATION_ANSWER_DRAFT>>>";
|
||||
|
||||
function extractApplicationAnswerDraft(notes?: string | null) {
|
||||
const value = (notes ?? "").trim();
|
||||
if (!value) return "";
|
||||
|
||||
const startIndex = value.indexOf(APPLICATION_ANSWER_START);
|
||||
const endIndex = value.indexOf(APPLICATION_ANSWER_END);
|
||||
if (startIndex >= 0 && endIndex > startIndex) {
|
||||
return value.slice(startIndex + APPLICATION_ANSWER_START.length, endIndex).trim();
|
||||
}
|
||||
|
||||
const legacyMatch = value.match(/Application answer draft:\s*\n([\s\S]*)$/i);
|
||||
return legacyMatch?.[1]?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function useJobWorkspaceBaseData({
|
||||
open,
|
||||
jobId,
|
||||
initialTab,
|
||||
initialFollowUpMode,
|
||||
}: {
|
||||
open: boolean;
|
||||
jobId: number | null;
|
||||
initialTab: number;
|
||||
initialFollowUpMode?: string;
|
||||
}) {
|
||||
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 }[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [jobAttachments, setJobAttachments] = useState<AttachmentItem[]>([]);
|
||||
const [selectedAttachmentIds, setSelectedAttachmentIds] = useState<number[]>([]);
|
||||
const [profileAvatarImageDataUrl, setProfileAvatarImageDataUrl] = useState<string | null>(null);
|
||||
const [packageWorkspace, setPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
const [savedPackageWorkspace, setSavedPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
const [packageGeneratedAt, setPackageGeneratedAt] = useState<string | null>(null);
|
||||
const [draftRecipient, setDraftRecipient] = useState("");
|
||||
const [followUpMode, setFollowUpMode] = useState(initialFollowUpMode || "post-apply");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId) return;
|
||||
|
||||
setTab(Math.max(0, Math.min(9, initialTab)));
|
||||
setJob(null);
|
||||
setHistory([]);
|
||||
setIsAdmin(false);
|
||||
setJobAttachments([]);
|
||||
setSelectedAttachmentIds([]);
|
||||
setProfileAvatarImageDataUrl(null);
|
||||
setPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
setSavedPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
setPackageGeneratedAt(null);
|
||||
|
||||
void api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
|
||||
setJob(r.data);
|
||||
const savedWorkspace = {
|
||||
coverLetter: r.data.coverLetterText ?? "",
|
||||
applicationAnswer: extractApplicationAnswerDraft(r.data.notes),
|
||||
recruiterMessage: r.data.recruiterMessageDraft ?? "",
|
||||
};
|
||||
setSavedPackageWorkspace(savedWorkspace);
|
||||
setPackageWorkspace(savedWorkspace);
|
||||
setDraftRecipient(r.data.company?.recruiterEmail ?? "");
|
||||
setFollowUpMode(initialFollowUpMode || (r.data.status?.includes("Interview") ? "post-interview" : r.data.status === "Waiting" ? "waiting-update" : r.data.status === "Offer" ? "offer-checkin" : r.data.status === "Rejected" ? "feedback-request" : "post-apply"));
|
||||
});
|
||||
|
||||
void api.get<AttachmentItem[]>(`/attachments/${jobId}`).then((r) => {
|
||||
const items = Array.isArray(r.data) ? r.data : [];
|
||||
setJobAttachments(items);
|
||||
const defaultIds = items.filter((item) => item.useForAi !== false).slice(0, 3).map((item) => item.id);
|
||||
setSelectedAttachmentIds(defaultIds.length > 0 ? defaultIds : items.slice(0, 3).map((item) => item.id));
|
||||
}).catch(() => {
|
||||
setJobAttachments([]);
|
||||
setSelectedAttachmentIds([]);
|
||||
});
|
||||
|
||||
void api.get(`/auth/me`).then((r) => {
|
||||
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
|
||||
setProfileAvatarImageDataUrl(r.data?.avatarImageDataUrl ?? null);
|
||||
}).catch(() => {
|
||||
setIsAdmin(false);
|
||||
setProfileAvatarImageDataUrl(null);
|
||||
});
|
||||
|
||||
void api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([]));
|
||||
}, [open, jobId, initialTab, initialFollowUpMode]);
|
||||
|
||||
return {
|
||||
job,
|
||||
setJob,
|
||||
tab,
|
||||
setTab,
|
||||
history,
|
||||
setHistory,
|
||||
isAdmin,
|
||||
jobAttachments,
|
||||
setJobAttachments,
|
||||
selectedAttachmentIds,
|
||||
setSelectedAttachmentIds,
|
||||
profileAvatarImageDataUrl,
|
||||
packageWorkspace,
|
||||
setPackageWorkspace,
|
||||
savedPackageWorkspace,
|
||||
setSavedPackageWorkspace,
|
||||
packageGeneratedAt,
|
||||
setPackageGeneratedAt,
|
||||
draftRecipient,
|
||||
setDraftRecipient,
|
||||
followUpMode,
|
||||
setFollowUpMode,
|
||||
};
|
||||
}
|
||||
|
||||
export { extractApplicationAnswerDraft };
|
||||
export type { PackageWorkspaceState };
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
|
||||
export function useWorkspaceTabCache<T>() {
|
||||
const cacheRef = useRef(new Map<string, T>());
|
||||
|
||||
const getCached = useCallback((key: string) => cacheRef.current.get(key), []);
|
||||
const setCached = useCallback((key: string, value: T) => {
|
||||
cacheRef.current.set(key, value);
|
||||
}, []);
|
||||
const clearCached = useCallback((prefix?: string) => {
|
||||
if (!prefix) {
|
||||
cacheRef.current.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key of Array.from(cacheRef.current.keys())) {
|
||||
if (key.startsWith(prefix)) {
|
||||
cacheRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return useMemo(() => ({ getCached, setCached, clearCached }), [getCached, setCached, clearCached]);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DependencyList, Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { DependencyList, Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { getApiErrorMessage } from "../api";
|
||||
|
||||
@@ -64,12 +64,18 @@ export function useViewResource<T>(
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [error, setError] = useState<ViewResourceError | null>(null);
|
||||
const hasLoadedRef = useRef(hasLoaded);
|
||||
|
||||
useEffect(() => {
|
||||
hasLoadedRef.current = hasLoaded;
|
||||
}, [hasLoaded]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
|
||||
setLoading((current) => !hasLoaded && current);
|
||||
setRefreshing(hasLoaded);
|
||||
const alreadyLoaded = hasLoadedRef.current;
|
||||
setLoading(!alreadyLoaded);
|
||||
setRefreshing(alreadyLoaded);
|
||||
try {
|
||||
const next = await load();
|
||||
setData(next);
|
||||
@@ -82,7 +88,7 @@ export function useViewResource<T>(
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [enabled, errorMessage, hasLoaded, load]);
|
||||
}, [enabled, errorMessage, load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
|
||||
@@ -225,6 +225,16 @@ export interface CorrespondenceMessage {
|
||||
attachmentMetadataJson?: string | null;
|
||||
}
|
||||
|
||||
export interface AttachmentItem {
|
||||
id: number;
|
||||
fileName: string;
|
||||
uploadDate: string;
|
||||
fileType: string;
|
||||
fileSize: number;
|
||||
purpose?: string | null;
|
||||
useForAi: boolean;
|
||||
}
|
||||
|
||||
export interface GmailJobMatchReason {
|
||||
label: string;
|
||||
value: string;
|
||||
|
||||
Reference in New Issue
Block a user