Files
jobtrackingapp/job-tracker-ui/src/components/ApplicationAssets.tsx
T
cesnimda 4759f1f610
CI and Deploy / test (push) Failing after 1m23s
CI and Deploy / deploy (push) Has been skipped
fix(cv): support application variant deep links
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>
2026-07-19 16:47:32 +02:00

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>
);
}