196 lines
9.7 KiB
TypeScript
196 lines
9.7 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 { useI18n } from "../i18n/I18nProvider";
|
|
import { statusLabel } from "../pipeline";
|
|
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();
|
|
const { t } = useI18n();
|
|
|
|
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(t("workspaceStatusSuggestionApplied"), "success");
|
|
} catch (error) {
|
|
toast(getApiErrorMessage(error, t("workspaceStatusSuggestionFailed")), "error");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Alert
|
|
severity="info"
|
|
action={<Button color="inherit" size="small" disabled={busy} onClick={() => void apply()}>{t("workspaceStatusSuggestionApply", { status: statusLabel(t, suggestion.suggestedStatus) })}</Button>}
|
|
>
|
|
{t("workspaceStatusSuggestionBody", {
|
|
current: suggestion.currentStatus ? statusLabel(t, suggestion.currentStatus) : t("workspaceStatusSuggestionCurrent"),
|
|
suggested: statusLabel(t, suggestion.suggestedStatus),
|
|
})}
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
|
|
const { canUseAi } = useAccountPlan();
|
|
const { toast } = useToast();
|
|
const { t } = useI18n();
|
|
const [plan, setPlan] = useState<FocusPlanResponse | null>(null);
|
|
const [operation, setOperation] = useState<UserOperation | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [readingPlan, setReadingPlan] = useState(false);
|
|
const [readError, setReadError] = useState<{ cause: unknown; retryable: boolean } | null>(null);
|
|
const announced = useRef<string | null>(null);
|
|
const owner = useRef({ mounted: false, jobId, generation: 0 });
|
|
const readSequence = useRef(0);
|
|
const readPending = useRef(false);
|
|
const headingRef = useRef<HTMLHeadingElement>(null);
|
|
|
|
const loadPlan = useCallback(async () => {
|
|
if (!owner.current.mounted || owner.current.jobId !== jobId || readPending.current) return;
|
|
const lifetime = owner.current.generation;
|
|
const request = ++readSequence.current;
|
|
const active = () => owner.current.mounted && owner.current.jobId === jobId && owner.current.generation === lifetime && request === readSequence.current;
|
|
readPending.current = true;
|
|
setReadingPlan(true);
|
|
try {
|
|
const { data } = await api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`);
|
|
if (!active()) return;
|
|
setPlan(data);
|
|
setReadError(null);
|
|
} catch (cause) {
|
|
if (!active()) return;
|
|
const response = (cause as { response?: { status?: number; data?: { code?: string } } })?.response;
|
|
if (response?.status === 404 && response.data?.code === "strategy_not_generated") { setPlan(null); setReadError(null); }
|
|
else setReadError({ cause, retryable: !response?.status || response.status >= 500 });
|
|
} finally {
|
|
if (active()) { readPending.current = false; setReadingPlan(false); }
|
|
}
|
|
}, [jobId]);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
owner.current = { mounted: true, jobId, generation: owner.current.generation + 1 };
|
|
readPending.current = false;
|
|
setPlan(null); setReadError(null); setOperation(null);
|
|
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; owner.current.mounted = false; owner.current.generation += 1; readSequence.current += 1; readPending.current = false; };
|
|
}, [jobId, loadPlan]);
|
|
|
|
useEffect(() => {
|
|
if (!operation || terminal(operation.status)) return;
|
|
let active = true;
|
|
const timer = window.setTimeout(() => {
|
|
api.get<UserOperation>(`/operations/${operation.id}`)
|
|
.then(({ data }) => { if (active) setOperation(data); })
|
|
.catch(() => undefined);
|
|
}, 1000);
|
|
return () => { active = false; 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(t("workspaceStrategyCompleted"), "success");
|
|
} else if (operation.status === "failed") toast(t("workspaceStrategyFailed"), "error");
|
|
else toast(t("workspaceStrategyCancelled"), "info");
|
|
}, [loadPlan, operation, t, 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 ? t("workspaceStrategyQueued") : t("workspaceStrategyAlreadyQueued"), "info");
|
|
} catch (error) {
|
|
toast(getApiErrorMessage(error, t("workspaceStrategyQueueFailed")), "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, action === "cancel" ? t("workspaceStrategyCancelFailed") : t("workspaceStrategyRetryFailed")), "error");
|
|
}
|
|
};
|
|
|
|
const working = !!operation && !terminal(operation.status);
|
|
const reading = loading || readingPlan;
|
|
const readErrorTitle = t("workspaceStrategyLoadFailed");
|
|
const readErrorDetail = readError ? getApiErrorMessage(readError.cause, readErrorTitle) : "";
|
|
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 ref={headingRef} tabIndex={-1} component="h2" variant="subtitle2" sx={{ fontWeight: 800 }}>{t("workspaceStrategyTitle")}</Typography>
|
|
<Typography variant="caption" color="text.secondary">{t("workspaceStrategySubtitle")}</Typography>
|
|
</Box>
|
|
<Button variant="outlined" size="small" disabled={!canUseAi || reading || working} onClick={() => void generate()}>
|
|
{!canUseAi ? t("workspaceStrategyProRequired") : plan ? t("workspaceStrategyRegenerate") : t("workspaceStrategyGenerate")}
|
|
</Button>
|
|
</Stack>
|
|
{readError ? <Alert severity="error" sx={{ mb: 2, overflowWrap: "anywhere" }} action={readError.retryable ? <Button color="inherit" size="small" disabled={reading} onClick={() => { headingRef.current?.focus(); void loadPlan(); }}>{t("retry")}</Button> : undefined}>
|
|
<Typography variant="body2">{readErrorTitle}</Typography>
|
|
{readErrorDetail !== readErrorTitle ? <Typography variant="body2">{readErrorDetail}</Typography> : null}
|
|
</Alert> : null}
|
|
{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")}>{t("cancel")}</Button> : null}
|
|
{operation.canRetry ? <Button size="small" color="inherit" onClick={() => void mutateOperation("retry")}>{t("retry")}</Button> : null}
|
|
</>}>
|
|
{operation.progressStage || operation.status.replaceAll("_", " ")}{operation.progressPercent != null ? ` · ${operation.progressPercent}%` : ""}
|
|
</Alert>
|
|
) : null}
|
|
{reading && !plan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} aria-label={t("loading")} /></Box> : plan ? (
|
|
<Stack spacing={2}>
|
|
<DraftCard title={t("jobDetailsFocusSummary")} content={plan.strategicSummary} />
|
|
<TwoColumnSection leftTitle={t("jobDetailsImmediatePriorities")} leftItems={plan.immediatePriorities} rightTitle={t("jobDetailsProofPoints")} rightItems={plan.proofPointsToLeadWith} />
|
|
<TwoColumnSection leftTitle={t("jobDetailsCvBulletIdeas")} leftItems={plan.cvBulletIdeas} rightTitle={t("jobDetailsCoverLetterAngles")} rightItems={plan.coverLetterAngles} />
|
|
<ListCard title={t("jobDetailsFollowUpApproach")} items={plan.followUpApproach} />
|
|
</Stack>
|
|
) : !readError ? <Typography color="text.secondary">{t("workspaceStrategyEmpty")}</Typography> : null}
|
|
</Paper>
|
|
);
|
|
}
|