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