feat(ui): migrate reminders and protect follow-up recovery

This commit is contained in:
cesnimda
2026-10-13 13:49:29 +02:00
parent c723a4e303
commit 627955afa9
14 changed files with 3412 additions and 51 deletions
+80 -43
View File
@@ -1,15 +1,24 @@
import React, { useMemo } from "react";
import React, { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material";
import { Box, Button, Chip, Divider, Paper, Stack, Typography } from "@mui/material";
import { api } from "../api";
import { api, getApiErrorMessage } from "../api";
import ViewStateNotice from "./ViewStateNotice";
import { JobApplication } from "../types";
import { buildWorkflowPath, getReminderGroup, getWorkflowAction } from "../jobWorkflowSignals";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { useViewResource } from "../hooks/useViewResource";
import { useViewResource, type ViewResourceError } from "../hooks/useViewResource";
import { PageHeader } from "../layout/PageLayout";
import { statusLabel } from "../pipeline";
type FollowUpFailure = {
job: Pick<JobApplication, "id" | "jobTitle">;
days: number | null;
date: string | null;
error: ViewResourceError;
};
type ReminderGroups = {
missingCv: JobApplication[];
@@ -27,13 +36,16 @@ function groupItems(items: JobApplication[]): ReminderGroups {
return groups;
}
function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: string; items: JobApplication[]; onOpen: (job: JobApplication) => void; onSetFollowUp: (id: number, days: number | null) => void }) {
const { t } = useI18n();
function ReminderSection({ title, items, disabled, onOpen, onSetFollowUp }: { title: string; items: JobApplication[]; disabled: boolean; onOpen: (job: JobApplication) => void; onSetFollowUp: (job: JobApplication, days: number | null) => void }) {
const { language, t } = useI18n();
if (items.length === 0) return null;
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
<Typography variant="h6">{title}</Typography>
<Box component="section" aria-label={title} sx={{ display: "flex", flexDirection: "column", gap: 1.25, minWidth: 0 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<Typography component="h3" variant="sectionTitle">{title}</Typography>
<Chip size="small" label={items.length} />
</Stack>
{items.map((j) => {
const action = getWorkflowAction(j, {
packageWork: t("jobTablePackageWork"),
@@ -43,23 +55,22 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin
});
return (
<Paper key={j.id} sx={{ p: 1.5, display: "grid", gridTemplateColumns: "1fr auto", gap: 1, alignItems: "center" }}>
<Box>
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
<Paper key={j.id} component="article" aria-label={j.jobTitle} variant="outlined" sx={{ p: "var(--app-card-padding)", minWidth: 0, display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) auto" }, gap: 1.5, alignItems: "center" }}>
<Box sx={{ minWidth: 0 }}>
<Typography component="h4" variant="body1" sx={{ fontWeight: 700, lineHeight: 1.4, overflowWrap: "anywhere" }}>
{j.company?.name ?? ""} <span style={{ fontWeight: 700, opacity: 0.7 }}>•</span> {j.jobTitle}
</Typography>
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap", minWidth: 0, "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 24 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } }}>
{j.needsFollowUp ? <Chip size="small" color="warning" label={t("remindersFollowUpLabel")} /> : null}
{(j.workflowSignal?.reason ?? j.followUpReason) ? <Chip size="small" label={j.workflowSignal?.reason ?? j.followUpReason} variant="outlined" /> : null}
{j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString() })} variant="outlined" /> : null}
<Chip size="small" label={j.status} variant="outlined" />
{j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") })} variant="outlined" /> : null}
<Chip size="small" label={statusLabel(t, j.status)} variant="outlined" />
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
<Button size="small" variant="outlined" onClick={() => onOpen(j)}>{action?.label ?? t("remindersOpen")}</Button>
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button>
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button>
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>{t("remindersClear")}</Button>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", minWidth: 0, justifyContent: { xs: "flex-start", md: "flex-end" }, "& > button": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
<Button size="small" variant="outlined" disabled={disabled} aria-label={`${action?.label ?? t("remindersOpen")}: ${j.jobTitle}`} onClick={() => onOpen(j)}>{action?.label ?? t("remindersOpen")}</Button>
{[3, 7].map(days => <Button key={days} size="small" variant="outlined" disabled={disabled} aria-label={t("remindersSnoozeForJob", { days, job: j.jobTitle })} onClick={() => onSetFollowUp(j, days)}>{t("remindersSnoozeDays", { days })}</Button>)}
<Button size="small" disabled={disabled} aria-label={t("remindersClearForJob", { job: j.jobTitle })} onClick={() => onSetFollowUp(j, null)}>{t("remindersClear")}</Button>
</Box>
</Paper>
);
@@ -72,6 +83,10 @@ export default function RemindersView() {
const navigate = useNavigate();
const { toast } = useToast();
const { t } = useI18n();
const pending = useRef(false);
const [busy, setBusy] = useState(false);
const [failure, setFailure] = useState<FollowUpFailure | null>(null);
const resultsRef = useRef<HTMLDivElement>(null);
const remindersResource = useViewResource(
async () => {
@@ -80,59 +95,81 @@ export default function RemindersView() {
},
{
initialData: [],
errorMessage: t("remindersLoadFailed"),
deps: [t],
errorMessage: "",
},
);
const items = remindersResource.data;
const grouped = useMemo(() => groupItems(items), [items]);
const loading = busy || remindersResource.loading || remindersResource.refreshing;
const disabled = loading || !!remindersResource.error;
const openJob = (job: JobApplication) => {
if (pending.current || disabled) return;
navigate(buildWorkflowPath(job));
};
const setFollowUp = async (id: number, daysFromNow: number | null) => {
const setFollowUp = async (job: Pick<JobApplication, "id" | "jobTitle">, daysFromNow: number | null,
date = daysFromNow === null ? null : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)) => {
if (pending.current || disabled) return;
pending.current = true;
resultsRef.current?.focus();
setBusy(true);
setFailure(null);
try {
const d = daysFromNow === null ? null : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
await api.patch(`/jobapplications/${id}/followup`, { followUpAt: d });
await api.patch(`/jobapplications/${job.id}/followup`, { followUpAt: date });
toast(daysFromNow === null ? t("remindersFollowUpCleared") : t("remindersFollowUpSet"), "success");
await remindersResource.reload();
} catch {
} catch (error) {
const status = (error as { response?: { status?: number } })?.response?.status;
const unauthorized = status === 401 || status === 403;
setFailure({ job: { id: job.id, jobTitle: job.jobTitle }, days: daysFromNow, date,
error: { kind: unauthorized ? "unauthorized" : "error", message: getApiErrorMessage(error, ""), retryable: !unauthorized, status } });
toast(t("remindersFollowUpFailed"), "error");
} finally {
pending.current = false;
setBusy(false);
}
};
return (
<Paper sx={{ mt: 0, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>{t("remindersTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
{t("remindersSubtitle")}
</Typography>
const retryRead = async () => {
if (pending.current || loading) return;
pending.current = true;
resultsRef.current?.focus();
setBusy(true);
try { await remindersResource.reload(); }
finally { pending.current = false; setBusy(false); }
};
return (
<Stack spacing={2} sx={{ minWidth: 0 }}>
<PageHeader level="h2" title={t("remindersTitle")} description={t("remindersSubtitle")} />
<Box ref={resultsRef} role="region" aria-label={t("remindersTitle")} tabIndex={-1} aria-busy={loading} sx={{ minWidth: 0 }}>
<ViewStateNotice
loading={remindersResource.loading}
error={remindersResource.error}
title={t("remindersLoadFailed")}
description={t("remindersLoadFailedBody")}
onRetry={remindersResource.reload}
compact loading={loading}
error={failure?.error ?? (remindersResource.error ? { ...remindersResource.error, message: remindersResource.error.message || t("remindersLoadFailed") } : null)}
title={failure ? t("remindersMutationFailedForJob", { job: failure.job.jobTitle }) : t("remindersLoadFailed")}
description={failure ? undefined : t("remindersLoadFailedBody")}
onRetry={failure ? () => setFollowUp(failure.job, failure.days, failure.date) : retryRead}
/>
{!remindersResource.loading && !remindersResource.error ? (
{!remindersResource.loading && remindersResource.error?.kind !== "unauthorized" ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<ReminderSection title={t("remindersMissingTailoredCv")} items={grouped.missingCv} onOpen={openJob} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersMissingInterviewPrep")} items={grouped.missingInterviewNotes} onOpen={openJob} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersFollowUpDue")} items={grouped.overdueFollowUp} onOpen={openJob} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersOther")} items={grouped.other} onOpen={openJob} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersMissingTailoredCv")} items={grouped.missingCv} disabled={disabled} onOpen={openJob} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersMissingInterviewPrep")} items={grouped.missingInterviewNotes} disabled={disabled} onOpen={openJob} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersFollowUpDue")} items={grouped.overdueFollowUp} disabled={disabled} onOpen={openJob} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersOther")} items={grouped.other} disabled={disabled} onOpen={openJob} onSetFollowUp={setFollowUp} />
{items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>{t("remindersNothing")}</Typography> : null}
{!loading && !remindersResource.error && items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>{t("remindersNothing")}</Typography> : null}
</Box>
) : null}
</Box>
<Divider sx={{ my: 2 }} />
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{t("remindersTip")}
</Typography>
</Paper>
</Stack>
);
}