Optimize workspace and daily-loop surfaces

This commit is contained in:
2026-04-11 12:03:49 +02:00
parent 27fd70a2d7
commit 33ac4b963b
7 changed files with 474 additions and 224 deletions
+80 -49
View File
@@ -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) {
+66 -50
View File
@@ -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]);
}
+10 -4
View File
@@ -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) {
+10
View File
@@ -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;