Files
jobtrackingapp/job-tracker-ui/src/components/ApplicationWorkflowAssist.tsx
T
2026-08-24 20:21:09 +02:00

159 lines
7.0 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { useAccountPlan } from "../accountPlan";
import type { FocusPlanResponse, StatusSuggestion, StrategySnapshotOperationResponse, UserOperation } from "../types";
import { useToast } from "../toast";
import { DraftCard, ListCard, TwoColumnSection } from "./JobDetailsPanels";
const terminal = (status: UserOperation["status"]) => ["succeeded", "failed", "cancelled"].includes(status);
export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: number; onApplied: () => void }) {
const [suggestion, setSuggestion] = useState<StatusSuggestion | null>(null);
const [busy, setBusy] = useState(false);
const { toast } = useToast();
useEffect(() => {
let active = true;
api.get<StatusSuggestion>(`/jobapplications/${jobId}/status-suggestion`)
.then(({ data }) => { if (active) setSuggestion(data.hasSuggestion ? data : null); })
.catch(() => { if (active) setSuggestion(null); });
return () => { active = false; };
}, [jobId]);
if (!suggestion?.suggestedStatus) return null;
const apply = async () => {
setBusy(true);
try {
await api.patch(`/jobapplications/${jobId}/status`, { status: suggestion.suggestedStatus });
setSuggestion(null);
onApplied();
toast("Application status updated from the latest message.", "success");
} catch (error) {
toast(getApiErrorMessage(error, "Could not apply the suggested status."), "error");
} finally {
setBusy(false);
}
};
return (
<Alert
severity="info"
action={<Button color="inherit" size="small" disabled={busy} onClick={() => void apply()}>Apply {suggestion.suggestedStatus}</Button>}
>
A recent recruiter message suggests moving this application from {suggestion.currentStatus || "its current stage"} to {suggestion.suggestedStatus}.
</Alert>
);
}
export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
const { canUseAi } = useAccountPlan();
const { toast } = useToast();
const [plan, setPlan] = useState<FocusPlanResponse | null>(null);
const [operation, setOperation] = useState<UserOperation | null>(null);
const [loading, setLoading] = useState(true);
const announced = useRef<string | null>(null);
const loadPlan = useCallback(async () => {
try {
const { data } = await api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`);
setPlan(data);
} catch {
setPlan(null);
}
}, [jobId]);
useEffect(() => {
let active = true;
setLoading(true);
Promise.all([
loadPlan(),
api.get<UserOperation>(`/jobapplications/${jobId}/focus-plan/operation`)
.then(({ data }) => { if (active) setOperation(data); })
.catch(() => { if (active) setOperation(null); }),
]).finally(() => { if (active) setLoading(false); });
return () => { active = false; };
}, [jobId, loadPlan]);
useEffect(() => {
if (!operation || terminal(operation.status)) return;
const timer = window.setTimeout(() => {
api.get<UserOperation>(`/operations/${operation.id}`)
.then(({ data }) => setOperation(data))
.catch(() => undefined);
}, 1000);
return () => window.clearTimeout(timer);
}, [operation]);
useEffect(() => {
if (!operation || !terminal(operation.status)) return;
const key = `${operation.id}:${operation.status}`;
if (announced.current === key) return;
announced.current = key;
if (operation.status === "succeeded") {
void loadPlan();
toast("Strategy snapshot completed.", "success");
} else if (operation.status === "failed") toast("Strategy snapshot failed. You can retry safely.", "error");
else toast("Strategy snapshot cancelled.", "info");
}, [loadPlan, operation, toast]);
const generate = async () => {
setLoading(true);
try {
const { data } = await api.post<StrategySnapshotOperationResponse>(`/jobapplications/${jobId}/focus-plan/operations`, { attachmentIds: null });
announced.current = null;
setOperation(data.operation);
toast(data.created ? "Strategy snapshot queued." : "Strategy snapshot is already queued.", "info");
} catch (error) {
toast(getApiErrorMessage(error, "Could not queue the strategy snapshot."), "error");
} finally {
setLoading(false);
}
};
const mutateOperation = async (action: "cancel" | "retry") => {
if (!operation) return;
try {
announced.current = null;
const { data } = await api.post<UserOperation>(`/operations/${operation.id}/${action}`);
setOperation(data);
} catch (error) {
toast(getApiErrorMessage(error, `Could not ${action} the strategy snapshot.`), "error");
}
};
const working = !!operation && !terminal(operation.status);
return (
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" gap={1} sx={{ mb: 2 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Strategy snapshot</Typography>
<Typography variant="caption" color="text.secondary">An on-demand plan grounded in this advert and your saved career data.</Typography>
</Box>
<Button variant="outlined" size="small" disabled={!canUseAi || loading || working} onClick={() => void generate()}>
{!canUseAi ? "Pro required" : plan ? "Regenerate" : "Generate"}
</Button>
</Stack>
{operation && operation.status !== "succeeded" ? (
<Alert severity={operation.status === "failed" ? "error" : operation.status === "cancelled" ? "warning" : "info"} sx={{ mb: 2 }} action={<>
{operation.canCancel ? <Button size="small" color="inherit" onClick={() => void mutateOperation("cancel")}>Cancel</Button> : null}
{operation.canRetry ? <Button size="small" color="inherit" onClick={() => void mutateOperation("retry")}>Retry</Button> : null}
</>}>
{operation.progressStage || operation.status.replaceAll("_", " ")}{operation.progressPercent != null ? ` · ${operation.progressPercent}%` : ""}
</Alert>
) : null}
{loading && !plan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : plan ? (
<Stack spacing={2}>
<DraftCard title="Strategic summary" content={plan.strategicSummary} />
<TwoColumnSection leftTitle="Immediate priorities" leftItems={plan.immediatePriorities} rightTitle="Proof points" rightItems={plan.proofPointsToLeadWith} />
<TwoColumnSection leftTitle="CV bullet ideas" leftItems={plan.cvBulletIdeas} rightTitle="Cover letter angles" rightItems={plan.coverLetterAngles} />
<ListCard title="Follow-up approach" items={plan.followUpApproach} />
</Stack>
) : <Typography color="text.secondary">No strategy snapshot yet. Generate one when you want AI-assisted planning.</Typography>}
</Paper>
);
}