9edcbfc5de
Make job/CV comparisons language-aware and filter recruitment noise. Improve responsive career navigation, shared spacing, dashboard priorities, settings, localized workspace controls, and portable browser tests.
166 lines
7.4 KiB
TypeScript
166 lines
7.4 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 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(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);
|
|
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 }}>{t("workspaceStrategyTitle")}</Typography>
|
|
<Typography variant="caption" color="text.secondary">{t("workspaceStrategySubtitle")}</Typography>
|
|
</Box>
|
|
<Button variant="outlined" size="small" disabled={!canUseAi || loading || working} onClick={() => void generate()}>
|
|
{!canUseAi ? t("workspaceStrategyProRequired") : plan ? t("workspaceStrategyRegenerate") : t("workspaceStrategyGenerate")}
|
|
</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")}>{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}
|
|
{loading && !plan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></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>
|
|
) : <Typography color="text.secondary">{t("workspaceStrategyEmpty")}</Typography>}
|
|
</Paper>
|
|
);
|
|
}
|