Complete S03 runtime closure and S04 control loop

This commit is contained in:
2026-03-24 11:33:55 +01:00
parent 0cacb4e51b
commit 8890906231
17 changed files with 588 additions and 74 deletions
@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
@@ -22,6 +23,7 @@ import AutoGraphIcon from "@mui/icons-material/AutoGraph";
import { api } from "../api";
import { getUserKeyFromToken } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "../jobWorkspaceRoute";
interface JobStats {
total: number;
@@ -34,8 +36,12 @@ interface JobStats {
type ReminderJob = {
id: number;
jobTitle: string;
status: string;
followUpAt?: string | null;
tailoredCvText?: string | null;
followUpReason?: string | null;
company?: { name?: string | null };
};
type AnalyticsPoint = { month: string; applied: number; responses: number };
@@ -127,6 +133,7 @@ function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: an
export default function DashboardView() {
const theme = useTheme();
const navigate = useNavigate();
const { t } = useI18n();
const [stats, setStats] = useState<JobStats | null>(null);
const [overview, setOverview] = useState<OverviewAnalytics | null>(null);
@@ -206,6 +213,15 @@ export default function DashboardView() {
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 openReminderJob = (job: ReminderJob) => {
const reason = (job.followUpReason ?? '').toLowerCase();
if (reason.includes('tailored cv')) {
navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.tailoredCv }));
return;
}
navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.followUp, followMode: 'waiting-update' }));
};
return (
<Box>
@@ -370,6 +386,31 @@ export default function DashboardView() {
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.15fr 0.85fr" }, gap: 2, mt: 2 }}>
<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 ? (
<Typography sx={{ color: "text.secondary" }}>{t("remindersNothing")}</Typography>
) : (
<Stack spacing={1.1}>
{priorityJobs.map((job) => (
<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>
<Typography sx={{ fontWeight: 900 }}>{job.company?.name ?? t("jobTableCompany")} {job.jobTitle}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
</Box>
<Button variant="outlined" onClick={() => openReminderJob(job)}>
{job.followUpReason?.toLowerCase().includes('tailored cv') ? t("jobDetailsTabTailoredCv") : t("jobTableFollowUp")}
</Button>
</Box>
))}
</Stack>
)}
<Box sx={{ mt: 1.5 }}>
<Button variant="text" onClick={() => navigate('/reminders')}>{t("reminders")}</Button>
</Box>
</SectionCard>
{prefs.companies ? (
<SectionCard>
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopCompaniesByActivity")}</Typography>
+4 -3
View File
@@ -47,6 +47,7 @@ import { useToast } from "../toast";
import SavedViewsMenu, { SavedViewParams } from "./SavedViewsMenu";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "../jobWorkspaceRoute";
interface JobApplication {
id: number;
@@ -376,9 +377,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<span>{job.jobTitle}</span>
{job.needsFollowUp ? <Chip size="small" label={t("jobTableFollowUp")} title={job.followUpReason ?? undefined} sx={{ fontWeight: 800 }} /> : null}
{!job.tailoredCvText && !job.isDeleted ? <Chip size="small" label={t("jobTableCvMissing")} color="warning" variant="outlined" /> : null}
{job.tailoredCvText ? <Chip size="small" label={t("jobTableCvReady")} color="success" variant="outlined" /> : null}
{job.needsFollowUp ? <Chip size="small" label={t("jobTableFollowUp")} title={job.followUpReason ?? undefined} sx={{ fontWeight: 800, cursor: "pointer" }} onClick={() => navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.followUp, followMode: "waiting-update" }))} /> : null}
{!job.tailoredCvText && !job.isDeleted ? <Chip size="small" label={t("jobTableCvMissing")} color="warning" variant="outlined" sx={{ cursor: "pointer" }} onClick={() => navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.tailoredCv }))} /> : null}
{job.tailoredCvText ? <Chip size="small" label={t("jobTableCvReady")} color="success" variant="outlined" sx={{ cursor: "pointer" }} onClick={() => navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.tailoredCv }))} /> : null}
</Box>
</TableCell>
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null}
+18 -11
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material";
@@ -6,8 +7,7 @@ import { api } from "../api";
import { JobApplication } from "../types";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import JobDetailsDialog from "./JobDetailsDialog";
import { buildJobWorkspacePath, JOB_DETAILS_TABS } from "../jobWorkspaceRoute";
type ReminderGroups = {
missingCv: JobApplication[];
@@ -28,7 +28,7 @@ function groupItems(items: JobApplication[]): ReminderGroups {
return groups;
}
function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: string; items: JobApplication[]; onOpen: (id: number) => void; onSetFollowUp: (id: number, days: number | null) => void }) {
function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: string; items: JobApplication[]; onOpen: (job: JobApplication) => void; onSetFollowUp: (id: number, days: number | null) => void }) {
const { t } = useI18n();
if (items.length === 0) return null;
@@ -49,7 +49,7 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
<Button size="small" variant="outlined" onClick={() => onOpen(j.id)}>{t("remindersOpen")}</Button>
<Button size="small" variant="outlined" onClick={() => onOpen(j)}>{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>
@@ -61,10 +61,10 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin
}
export default function RemindersView() {
const navigate = useNavigate();
const { toast } = useToast();
const { t } = useI18n();
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 } });
@@ -77,6 +77,15 @@ export default function RemindersView() {
const grouped = useMemo(() => groupItems(items), [items]);
const openJob = (job: JobApplication) => {
const reason = (job.followUpReason ?? '').toLowerCase();
if (reason.includes('tailored cv')) {
navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.tailoredCv }));
return;
}
navigate(buildJobWorkspacePath(job.id, { tab: JOB_DETAILS_TABS.followUp, followMode: 'waiting-update' }));
};
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);
@@ -96,10 +105,10 @@ export default function RemindersView() {
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<ReminderSection title={t("remindersMissingTailoredCv")} items={grouped.missingCv} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersMissingInterviewPrep")} items={grouped.missingInterviewNotes} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersFollowUpDue")} items={grouped.overdueFollowUp} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
<ReminderSection title={t("remindersOther")} items={grouped.other} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
<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} />
{items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>{t("remindersNothing")}</Typography> : null}
</Box>
@@ -108,8 +117,6 @@ export default function RemindersView() {
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{t("remindersTip")}
</Typography>
<JobDetailsDialog open={openJobId !== null} jobId={openJobId} onClose={() => setOpenJobId(null)} />
</Paper>
);
}