feat(workspace): refine career workflows
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.
This commit is contained in:
@@ -4,6 +4,8 @@ import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "
|
||||
|
||||
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";
|
||||
@@ -14,6 +16,7 @@ export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: numbe
|
||||
const [suggestion, setSuggestion] = useState<StatusSuggestion | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -31,9 +34,9 @@ export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: numbe
|
||||
await api.patch(`/jobapplications/${jobId}/status`, { status: suggestion.suggestedStatus });
|
||||
setSuggestion(null);
|
||||
onApplied();
|
||||
toast("Application status updated from the latest message.", "success");
|
||||
toast(t("workspaceStatusSuggestionApplied"), "success");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Could not apply the suggested status."), "error");
|
||||
toast(getApiErrorMessage(error, t("workspaceStatusSuggestionFailed")), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -42,9 +45,12 @@ export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: numbe
|
||||
return (
|
||||
<Alert
|
||||
severity="info"
|
||||
action={<Button color="inherit" size="small" disabled={busy} onClick={() => void apply()}>Apply {suggestion.suggestedStatus}</Button>}
|
||||
action={<Button color="inherit" size="small" disabled={busy} onClick={() => void apply()}>{t("workspaceStatusSuggestionApply", { status: statusLabel(t, suggestion.suggestedStatus) })}</Button>}
|
||||
>
|
||||
A recent recruiter message suggests moving this application from {suggestion.currentStatus || "its current stage"} to {suggestion.suggestedStatus}.
|
||||
{t("workspaceStatusSuggestionBody", {
|
||||
current: suggestion.currentStatus ? statusLabel(t, suggestion.currentStatus) : t("workspaceStatusSuggestionCurrent"),
|
||||
suggested: statusLabel(t, suggestion.suggestedStatus),
|
||||
})}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +58,7 @@ export function ApplicationStatusSuggestion({ jobId, onApplied }: { jobId: numbe
|
||||
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);
|
||||
@@ -95,10 +102,10 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
|
||||
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]);
|
||||
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);
|
||||
@@ -106,9 +113,9 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
|
||||
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");
|
||||
toast(data.created ? t("workspaceStrategyQueued") : t("workspaceStrategyAlreadyQueued"), "info");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Could not queue the strategy snapshot."), "error");
|
||||
toast(getApiErrorMessage(error, t("workspaceStrategyQueueFailed")), "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -121,7 +128,7 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
|
||||
const { data } = await api.post<UserOperation>(`/operations/${operation.id}/${action}`);
|
||||
setOperation(data);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, `Could not ${action} the strategy snapshot.`), "error");
|
||||
toast(getApiErrorMessage(error, action === "cancel" ? t("workspaceStrategyCancelFailed") : t("workspaceStrategyRetryFailed")), "error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,29 +137,29 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
|
||||
<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>
|
||||
<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 ? "Pro required" : plan ? "Regenerate" : "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")}>Cancel</Button> : null}
|
||||
{operation.canRetry ? <Button size="small" color="inherit" onClick={() => void mutateOperation("retry")}>Retry</Button> : null}
|
||||
{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="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} />
|
||||
<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">No strategy snapshot yet. Generate one when you want AI-assisted planning.</Typography>}
|
||||
) : <Typography color="text.secondary">{t("workspaceStrategyEmpty")}</Typography>}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user