feat: add application package generation and grouped readiness workflows

This commit is contained in:
cesnimda
2026-03-22 18:28:02 +01:00
parent f1c7c38a19
commit 9188039e9d
14 changed files with 1014 additions and 373 deletions
+71 -105
View File
@@ -1,12 +1,6 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Button,
Chip,
Paper,
Typography,
} from "@mui/material";
import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material";
import { api } from "../api";
import { JobApplication } from "../types";
@@ -14,15 +8,63 @@ import { useToast } from "../toast";
import JobDetailsDialog from "./JobDetailsDialog";
type ReminderGroups = {
missingCv: JobApplication[];
missingInterviewNotes: JobApplication[];
overdueFollowUp: JobApplication[];
other: JobApplication[];
};
function groupItems(items: JobApplication[]): ReminderGroups {
const groups: ReminderGroups = { missingCv: [], missingInterviewNotes: [], overdueFollowUp: [], other: [] };
items.forEach((item) => {
const reason = (item.followUpReason ?? "").toLowerCase();
if (reason.includes("tailored cv")) groups.missingCv.push(item);
else if (reason.includes("interview prep") || reason.includes("prep notes")) groups.missingInterviewNotes.push(item);
else if (reason.includes("follow-up") || reason.includes("follow up")) groups.overdueFollowUp.push(item);
else groups.other.push(item);
});
return groups;
}
function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: string; items: JobApplication[]; onOpen: (id: number) => void; onSetFollowUp: (id: number, days: number | null) => void }) {
if (items.length === 0) return null;
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
<Typography variant="h6">{title}</Typography>
{items.map((j) => (
<Paper key={j.id} sx={{ p: 1.5, display: "grid", gridTemplateColumns: "1fr auto", gap: 1, alignItems: "center" }}>
<Box>
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
{j.company?.name ?? ""} <span style={{ fontWeight: 700, opacity: 0.7 }}></span> {j.jobTitle}
</Typography>
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
{j.needsFollowUp ? <Chip size="small" color="warning" label="Follow up" /> : null}
{j.followUpReason ? <Chip size="small" label={j.followUpReason} variant="outlined" /> : null}
{j.followUpAt ? <Chip size="small" label={`Follow-up: ${new Date(j.followUpAt).toLocaleDateString()}`} variant="outlined" /> : null}
<Chip size="small" label={j.status} variant="outlined" />
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
<Button size="small" variant="outlined" onClick={() => onOpen(j.id)}>Open</Button>
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button>
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button>
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>Clear</Button>
</Box>
</Paper>
))}
</Box>
);
}
export default function RemindersView() {
const { toast } = useToast();
const [items, setItems] = useState<JobApplication[]>([]);
const [openJobId, setOpenJobId] = useState<number | null>(null);
const load = async () => {
const res = await api.get<JobApplication[]>("/jobapplications/reminders", {
params: { upcomingDays: 14 },
});
const res = await api.get<JobApplication[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } });
setItems(res.data);
};
@@ -30,17 +72,12 @@ export default function RemindersView() {
void load();
}, []);
const grouped = useMemo(() => groupItems(items), [items]);
const setFollowUp = async (id: number, daysFromNow: number | null) => {
try {
const d =
daysFromNow === null
? null
: new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
await api.patch(`/jobapplications/${id}/followup`, {
followUpAt: d,
});
const d = daysFromNow === null ? null : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
await api.patch(`/jobapplications/${id}/followup`, { followUpAt: d });
toast(daysFromNow === null ? "Follow-up cleared." : "Follow-up set.", "success");
await load();
} catch {
@@ -50,97 +87,26 @@ export default function RemindersView() {
return (
<Paper sx={{ mt: 0, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
Needs Follow-up
</Typography>
<Typography variant="h6" sx={{ mb: 1 }}>Needs Follow-up</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
Based on your rules and upcoming follow-up dates.
Grouped by the most useful next action so you can fix gaps faster.
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{items.map((j) => (
<Paper
key={j.id}
sx={{
p: 1.5,
display: "grid",
gridTemplateColumns: "1fr auto",
gap: 1,
alignItems: "center",
}}
>
<Box>
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
{j.company?.name ?? ""}{" "}
<span style={{ fontWeight: 700, opacity: 0.7 }}>Â</span>{" "}
{j.jobTitle}
</Typography>
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
{j.needsFollowUp ? (
<Chip size="small" color="warning" label="Follow up" />
) : null}
{j.followUpReason ? (
<Chip size="small" label={j.followUpReason} variant="outlined" />
) : null}
{j.followUpAt ? (
<Chip
size="small"
label={`Follow-up: ${new Date(j.followUpAt).toLocaleDateString()}`}
variant="outlined"
/>
) : null}
<Chip size="small" label={j.status} variant="outlined" />
</Box>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<ReminderSection title="Missing tailored CV" items={grouped.missingCv} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
<ReminderSection title="Missing interview prep" items={grouped.missingInterviewNotes} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
<ReminderSection title="Follow-up due" items={grouped.overdueFollowUp} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
<ReminderSection title="Other reminders" items={grouped.other} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
<Button size="small" variant="outlined" onClick={() => setOpenJobId(j.id)}>
Open
</Button>
<Button size="small" variant="outlined" onClick={() => void setFollowUp(j.id, 1)}>
Tomorrow
</Button>
<Button size="small" variant="outlined" onClick={() => void setFollowUp(j.id, 3)}>
+3d
</Button>
<Button size="small" variant="outlined" onClick={() => void setFollowUp(j.id, 7)}>
+7d
</Button>
<Button size="small" onClick={() => void setFollowUp(j.id, null)}>
Clear
</Button>
</Box>
</Paper>
))}
{items.length === 0 && (
<Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>
Nothing to follow up right now.
</Typography>
)}
{items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>Nothing to follow up right now.</Typography> : null}
</Box>
<JobDetailsDialog
open={openJobId !== null}
jobId={openJobId}
onClose={() => setOpenJobId(null)}
/>
<Divider sx={{ my: 2 }} />
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Tip: focus on tailored CV and interview prep first for the highest-value roles.
</Typography>
<JobDetailsDialog open={openJobId !== null} jobId={openJobId} onClose={() => setOpenJobId(null)} />
</Paper>
);
}
lign: "center", py: 3 }}>
Nothing to follow up right now.
</Typography>
)}
</Box>
<JobDetailsDialog
open={openJobId !== null}
jobId={openJobId}
onClose={() => setOpenJobId(null)}
/>
</Paper>
);
}