4759f1f610
The Application Workspace CV section linked to /cv-builder?variant={id}. That
route does not exist: the builder is mounted at /career/builder/:id and reads the
variant from the path, not a query string. The button dead-ended.
Corrected the href. No loading logic was added — the editor already loads the
variant by id and already has a safe error state, and ownership is already
enforced server-side, where CvVariantService scopes every read to the owner and
the controller returns 404.
Added tests for the deep-link entry point, which had none: the variant loads from
the route, a missing variant shows the error state rather than an empty editor,
and another user's variant is refused identically. The asset test now asserts the
exact href, so a route that the router does not serve fails the build instead of
shipping.
118 frontend tests and the production build pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
371 lines
13 KiB
TypeScript
371 lines
13 KiB
TypeScript
import React, { useCallback, useEffect, useState } from "react";
|
|
|
|
import {
|
|
Alert, Box, Button, Chip, Divider, IconButton, MenuItem, Paper, Skeleton, Stack, TextField,
|
|
Tooltip, Typography,
|
|
} from "@mui/material";
|
|
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
|
import RestoreIcon from "@mui/icons-material/Restore";
|
|
|
|
import { getApiErrorMessage } from "../api";
|
|
import {
|
|
ApplicationCv, CoverLetter, TailoringPlan, applicationAssetsApi,
|
|
} from "../applicationWorkspace";
|
|
|
|
// Phase 5.4 — Application Assets sections for the workspace.
|
|
//
|
|
// These compose systems that already exist. CV editing, preview, PDF export, themes and version
|
|
// history all live in the CV builder at /cv-builder — this section only chooses WHICH variant the
|
|
// application uses and links out. The cover letter is the one thing genuinely owned here, because it
|
|
// is application-specific by nature. docs/architecture/application-workspace.md.
|
|
|
|
function useAsset<T>(load: () => Promise<T>, deps: React.DependencyList) {
|
|
const [data, setData] = useState<T | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
const run = useCallback(load, deps);
|
|
|
|
const reload = useCallback(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
run()
|
|
.then((r) => {
|
|
if (!cancelled) {
|
|
setData(r);
|
|
setError(null);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
if (!cancelled) setError(getApiErrorMessage(err, "Could not load this section."));
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [run]);
|
|
|
|
useEffect(() => reload(), [reload]);
|
|
|
|
return { data, error, loading, setData, setError, reload };
|
|
}
|
|
|
|
function Shell({ title, subtitle, loading, error, children }: {
|
|
title: string;
|
|
subtitle?: string;
|
|
loading: boolean;
|
|
error: string | null;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{title}</Typography>
|
|
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
|
|
<Divider sx={{ my: 1.5 }} />
|
|
{loading ? (
|
|
<Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={44} />)}</Stack>
|
|
) : error ? (
|
|
<Alert severity="error">{error}</Alert>
|
|
) : (
|
|
children
|
|
)}
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
// ---------- CV ----------
|
|
|
|
export function ApplicationCvSection({ jobId }: { jobId: number }) {
|
|
const { data, error, loading, setData, setError } = useAsset<ApplicationCv>(
|
|
() => applicationAssetsApi.cv(jobId),
|
|
[jobId],
|
|
);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const attach = async (variantId: number | null) => {
|
|
setBusy(true);
|
|
try {
|
|
setData(await applicationAssetsApi.attachVariant(jobId, variantId));
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(getApiErrorMessage(err, "Could not change the attached CV."));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const attached = data?.attachedVariantId ?? "";
|
|
|
|
return (
|
|
<Stack spacing={2}>
|
|
<Shell
|
|
title="CV"
|
|
subtitle="Which CV variant this application uses. Variants are lenses over your master career profile."
|
|
loading={loading}
|
|
error={error}
|
|
>
|
|
<Stack spacing={2}>
|
|
{(data?.availableVariants.length ?? 0) === 0 ? (
|
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
|
No CV variants yet. Build one in the CV builder — it starts from your master career
|
|
profile, so you never retype your history.
|
|
</Alert>
|
|
) : (
|
|
<TextField
|
|
select
|
|
size="small"
|
|
fullWidth
|
|
label="Attached CV variant"
|
|
value={attached}
|
|
disabled={busy}
|
|
onChange={(e) => attach(e.target.value === "" ? null : Number(e.target.value))}
|
|
helperText="Changing this only re-points the application. The variant itself is untouched."
|
|
>
|
|
<MenuItem value="">None</MenuItem>
|
|
{(data?.availableVariants ?? []).map((v) => (
|
|
<MenuItem key={v.id} value={v.id}>
|
|
{v.name} · {v.themeId} · v{v.version}
|
|
</MenuItem>
|
|
))}
|
|
</TextField>
|
|
)}
|
|
|
|
{data?.attachedVariantId ? (
|
|
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
|
|
<Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" gap={1}>
|
|
<Box>
|
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>{data.attachedVariantName}</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Theme {data.attachedThemeId} · version {data.attachedVersion}
|
|
{data.attachedIsPublic ? " · public" : ""}
|
|
</Typography>
|
|
</Box>
|
|
<Stack direction="row" spacing={1}>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
endIcon={<OpenInNewIcon fontSize="small" />}
|
|
// The builder's real route. It loads the variant from :id and enforces ownership
|
|
// server-side, so there is no second CV loading path here.
|
|
href={`/career/builder/${data.attachedVariantId}`}
|
|
>
|
|
Edit, preview and export
|
|
</Button>
|
|
</Stack>
|
|
</Stack>
|
|
</Paper>
|
|
) : (
|
|
<Typography variant="body2" color="text.secondary">
|
|
No CV attached to this application yet.
|
|
</Typography>
|
|
)}
|
|
|
|
{data?.hasTailoredCvText && (
|
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
|
This application also has legacy tailored CV text saved on it. A CV variant supersedes it.
|
|
</Alert>
|
|
)}
|
|
</Stack>
|
|
</Shell>
|
|
|
|
<ApplicationTailoringSection jobId={jobId} />
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
// ---------- Tailoring ----------
|
|
|
|
export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
|
|
const { data, error, loading } = useAsset<TailoringPlan>(
|
|
() => applicationAssetsApi.tailoring(jobId),
|
|
[jobId],
|
|
);
|
|
|
|
return (
|
|
<Shell
|
|
title="Tailoring"
|
|
subtitle="What to emphasise for this advert. Suggestions only — nothing here edits your profile or your CV."
|
|
loading={loading}
|
|
error={error}
|
|
>
|
|
<Stack spacing={2}>
|
|
{data && !data.hasCareerProfile && (
|
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
|
Build your career profile to get experience and project suggestions.
|
|
</Alert>
|
|
)}
|
|
{data && !data.hasJobDescription && (
|
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
|
Paste the advert text to get keyword and requirement suggestions.
|
|
</Alert>
|
|
)}
|
|
|
|
{(data?.suggestions.length ?? 0) === 0 ? (
|
|
<Typography variant="body2" color="text.secondary">
|
|
Nothing to suggest yet.
|
|
</Typography>
|
|
) : (
|
|
(data?.suggestions ?? []).map((s) => (
|
|
<Box key={s.kind}>
|
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>{s.title}</Typography>
|
|
{s.detail && (
|
|
<Typography variant="caption" color="text.secondary">{s.detail}</Typography>
|
|
)}
|
|
<Stack direction="row" flexWrap="wrap" gap={0.5} sx={{ mt: 0.75 }}>
|
|
{s.items.map((item) => (
|
|
<Chip key={item} size="small" label={item} variant="outlined" />
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
))
|
|
)}
|
|
</Stack>
|
|
</Shell>
|
|
);
|
|
}
|
|
|
|
// ---------- Cover letter ----------
|
|
|
|
const TEMPLATE = `Dear Hiring Manager,
|
|
|
|
I am writing to apply for the [role] position at [company]. [One sentence on why this company, specifically.]
|
|
|
|
In my current role I [the most relevant thing you have done, with a concrete outcome]. [A second example that matches what the advert asks for.]
|
|
|
|
[Why you want this job, in your own words.]
|
|
|
|
I would welcome the chance to talk it through.
|
|
|
|
Kind regards,
|
|
[Your name]`;
|
|
|
|
export function ApplicationCoverLetterSection({ jobId }: { jobId: number }) {
|
|
const { data, error, loading, setData, setError } = useAsset<CoverLetter>(
|
|
() => applicationAssetsApi.coverLetter(jobId),
|
|
[jobId],
|
|
);
|
|
const [draft, setDraft] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
// The textarea is only seeded from the server until the user starts typing, so a reload never
|
|
// clobbers unsaved edits.
|
|
const text = draft ?? data?.text ?? "";
|
|
const dirty = draft !== null && draft !== (data?.text ?? "");
|
|
|
|
const save = async (value: string, source = "manual") => {
|
|
setBusy(true);
|
|
try {
|
|
setData(await applicationAssetsApi.saveCoverLetter(jobId, value, source));
|
|
setDraft(null);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(getApiErrorMessage(err, "Could not save the cover letter."));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const restore = async (version: number) => {
|
|
setBusy(true);
|
|
try {
|
|
setData(await applicationAssetsApi.restoreCoverLetter(jobId, version));
|
|
setDraft(null);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(getApiErrorMessage(err, "Could not restore that version."));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Stack spacing={2}>
|
|
<Shell
|
|
title="Cover letter"
|
|
subtitle="Every save keeps the previous text, so nothing you write is ever lost."
|
|
loading={loading}
|
|
error={error}
|
|
>
|
|
<Stack spacing={2}>
|
|
<TextField
|
|
multiline
|
|
minRows={12}
|
|
fullWidth
|
|
label="Cover letter"
|
|
value={text}
|
|
disabled={busy}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
placeholder="Write it yourself, start from the template, or generate a draft with the AI panel below."
|
|
/>
|
|
|
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
|
<Button variant="contained" disabled={busy || !dirty} onClick={() => save(text)}>
|
|
Save
|
|
</Button>
|
|
<Button disabled={busy || !dirty} onClick={() => setDraft(null)}>
|
|
Discard changes
|
|
</Button>
|
|
<Button
|
|
disabled={busy || text.trim().length > 0}
|
|
onClick={() => setDraft(TEMPLATE)}
|
|
>
|
|
Start from template
|
|
</Button>
|
|
{dirty && (
|
|
<Chip size="small" color="warning" variant="outlined" label="Unsaved changes" />
|
|
)}
|
|
</Stack>
|
|
</Stack>
|
|
</Shell>
|
|
|
|
<Shell title="Version history" loading={loading} error={null}>
|
|
{(data?.versions.length ?? 0) === 0 ? (
|
|
<Typography variant="body2" color="text.secondary">
|
|
No versions yet. The first save starts the history.
|
|
</Typography>
|
|
) : (
|
|
<Stack spacing={0.5}>
|
|
{(data?.versions ?? []).map((v) => (
|
|
<Stack
|
|
key={v.version}
|
|
direction="row"
|
|
alignItems="center"
|
|
justifyContent="space-between"
|
|
sx={{ py: 0.5 }}
|
|
>
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
|
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>v{v.version}</Typography>
|
|
<Chip size="small" variant="outlined" label={v.aiAction ? `${v.source} · ${v.aiAction}` : v.source} />
|
|
{v.isCurrent && <Chip size="small" color="primary" variant="outlined" label="Current" />}
|
|
</Stack>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{new Date(v.createdAtUtc).toLocaleString()} · {v.length} characters
|
|
</Typography>
|
|
</Box>
|
|
{!v.isCurrent && (
|
|
<Tooltip title="Restore this version">
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
disabled={busy}
|
|
aria-label={`Restore version ${v.version}`}
|
|
onClick={() => restore(v.version)}
|
|
>
|
|
<RestoreIcon fontSize="small" />
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
)}
|
|
</Stack>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Shell>
|
|
</Stack>
|
|
);
|
|
}
|