feat(workspace): application assets workflow
Phase 5.4. Connects the career outputs a user already has to one job
application, without building a second copy of any of them.
The flow is strictly one-directional — CareerProfile -> CvVariant ->
application output — and nothing writes back up. No code path in this phase
touches CareerProfile or its children.
CV integration re-points rather than duplicates. GET/PUT /{id}/cv attaches one
variant to an application via CvVariant.JobApplicationId; replacing detaches the
previous variant instead of deleting it. Creating, duplicating, editing, theming,
previewing, exporting PDF and version history all stay in the existing CV
builder, which the section links into. There is no second CV system.
Tailoring composes the Phase 5.3 analysis and match into skills to highlight,
experience to prioritise, projects to emphasise, keywords to include and gaps to
address. Deterministic and advisory: it says what the user could emphasise and
the user edits the variant themselves. Nothing auto-applies.
Cover letters gain the history they were missing. JobApplication.CoverLetterText
stays the current text with its API contract unchanged; CoverLetterVersions
records what it used to be, so an AI rewrite is never destructive. Restore is
additive — the old text comes back as a new version, so what you restored from
still exists. Source and AiAction record whether the user wrote a version or
approved it from a suggestion, and an AI generation only becomes a version once
the user saves it.
Documents are untouched: the existing Attachment system already covers CV, cover
letter, certificates and portfolio files with a Purpose field, so the workspace
mounts that component rather than adding a second upload path.
CoverLetterVersions is the only new table — reconciler-owned, no-op migration,
guarded on JobApplications, and verified on a fresh MariaDB 11: int
AUTO_INCREMENT primary key, varchar(255) owner, datetime(6), composite index
inside the key limit.
360 backend tests, 115 frontend tests, type check, Release build and the
production build all pass locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
ApplicationCoverLetterSection, ApplicationCvSection,
|
||||
} from "./components/ApplicationAssets";
|
||||
import { api } from "./api";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
put: jest.fn(),
|
||||
post: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: (_e: any, fallback?: string) => fallback || "Request failed.",
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
const cv = {
|
||||
attachedVariantId: 3,
|
||||
attachedVariantName: "Backend CV",
|
||||
attachedThemeId: "nordic",
|
||||
attachedVersion: 4,
|
||||
attachedUpdatedAtUtc: "2026-07-19T10:00:00Z",
|
||||
attachedIsPublic: false,
|
||||
hasTailoredCvText: false,
|
||||
availableVariants: [
|
||||
{ id: 3, name: "Backend CV", themeId: "nordic", publicSlug: "abc", isPublic: false, version: 4, jobApplicationId: 7, updatedAtUtc: "2026-07-19T10:00:00Z" },
|
||||
{ id: 5, name: "Generalist CV", themeId: "modern", publicSlug: "def", isPublic: false, version: 2, jobApplicationId: null, updatedAtUtc: "2026-07-18T10:00:00Z" },
|
||||
],
|
||||
};
|
||||
|
||||
const tailoring = {
|
||||
hasJobDescription: true,
|
||||
hasCareerProfile: true,
|
||||
hasAttachedVariant: true,
|
||||
matchScore: 72,
|
||||
suggestions: [
|
||||
{ kind: "highlight-skills", title: "Skills to highlight", detail: "The advert asks for these.", items: ["C#", "SQL"] },
|
||||
{ kind: "gaps", title: "Gaps to address", detail: null, items: ["Kubernetes"] },
|
||||
],
|
||||
aiSuggestionCount: 0,
|
||||
};
|
||||
|
||||
const coverLetter = {
|
||||
text: "Dear team",
|
||||
currentVersion: 2,
|
||||
versions: [
|
||||
{ version: 2, source: "ai", aiAction: "improve", length: 9, createdAtUtc: "2026-07-19T10:00:00Z", isCurrent: true },
|
||||
{ version: 1, source: "manual", aiAction: null, length: 40, createdAtUtc: "2026-07-19T09:00:00Z", isCurrent: false },
|
||||
],
|
||||
aiSuggestionCount: 1,
|
||||
};
|
||||
|
||||
function routeGet(overrides: Record<string, any> = {}) {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url.endsWith("/tailoring")) return Promise.resolve({ data: overrides.tailoring ?? tailoring } as any);
|
||||
if (url.endsWith("/cover-letter")) return Promise.resolve({ data: overrides.coverLetter ?? coverLetter } as any);
|
||||
return Promise.resolve({ data: overrides.cv ?? cv } as any);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
// ---------- CV ----------
|
||||
|
||||
test("cv section shows the attached variant and the ones available to attach", async () => {
|
||||
routeGet();
|
||||
|
||||
render(<ApplicationCvSection jobId={7} />);
|
||||
|
||||
expect(await screen.findByText("Backend CV")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Theme nordic · version 4/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute(
|
||||
"href", "/cv-builder?variant=3");
|
||||
});
|
||||
|
||||
test("attaching a different variant only re-points the application", async () => {
|
||||
routeGet();
|
||||
mockedApi.put.mockResolvedValue({ data: { ...cv, attachedVariantId: 5, attachedVariantName: "Generalist CV" } } as any);
|
||||
|
||||
render(<ApplicationCvSection jobId={7} />);
|
||||
fireEvent.mouseDown(await screen.findByRole("combobox", { name: /Attached CV variant/i }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: /Generalist CV/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cv", { variantId: 5 }));
|
||||
});
|
||||
|
||||
test("cv section points at the builder when there are no variants", async () => {
|
||||
routeGet({ cv: { ...cv, attachedVariantId: null, attachedVariantName: null, availableVariants: [] } });
|
||||
|
||||
render(<ApplicationCvSection jobId={7} />);
|
||||
|
||||
expect(await screen.findByText(/No CV variants yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("tailoring renders suggestions grouped by kind", async () => {
|
||||
routeGet();
|
||||
|
||||
render(<ApplicationCvSection jobId={7} />);
|
||||
|
||||
expect(await screen.findByText("Skills to highlight")).toBeInTheDocument();
|
||||
expect(screen.getByText("Gaps to address")).toBeInTheDocument();
|
||||
expect(screen.getByText("Kubernetes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("tailoring asks for a career profile when there is none", async () => {
|
||||
routeGet({ tailoring: { ...tailoring, hasCareerProfile: false, suggestions: [] } });
|
||||
|
||||
render(<ApplicationCvSection jobId={7} />);
|
||||
|
||||
expect(await screen.findByText(/Build your career profile/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---------- Cover letter ----------
|
||||
|
||||
test("cover letter loads the current text and its history", async () => {
|
||||
routeGet();
|
||||
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
|
||||
expect(await screen.findByDisplayValue("Dear team")).toBeInTheDocument();
|
||||
expect(screen.getByText("v2")).toBeInTheDocument();
|
||||
expect(screen.getByText("ai · improve")).toBeInTheDocument();
|
||||
expect(screen.getByText("Current")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("editing marks the draft dirty and saving sends the new text", async () => {
|
||||
routeGet();
|
||||
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "Dear hiring team" } } as any);
|
||||
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.change(await screen.findByLabelText("Cover letter"), { target: { value: "Dear hiring team" } });
|
||||
|
||||
expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
|
||||
"/jobapplications/7/cover-letter",
|
||||
{ text: "Dear hiring team", source: "manual", aiAction: undefined },
|
||||
));
|
||||
});
|
||||
|
||||
test("discarding returns to the saved text", async () => {
|
||||
routeGet();
|
||||
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.change(await screen.findByLabelText("Cover letter"), { target: { value: "scratch" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Discard changes/i }));
|
||||
|
||||
expect(await screen.findByDisplayValue("Dear team")).toBeInTheDocument();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("restoring an old version calls the restore endpoint", async () => {
|
||||
routeGet();
|
||||
mockedApi.post.mockResolvedValue({ data: coverLetter } as any);
|
||||
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Restore version 1/i }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(
|
||||
"/jobapplications/7/cover-letter/versions/1/restore"));
|
||||
});
|
||||
|
||||
test("an empty cover letter offers the template and an empty history", async () => {
|
||||
routeGet({ coverLetter: { text: null, currentVersion: 0, versions: [], aiSuggestionCount: 0 } });
|
||||
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
|
||||
expect(await screen.findByText(/No versions yet/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Start from template/i }));
|
||||
expect((screen.getByLabelText("Cover letter") as HTMLTextAreaElement).value)
|
||||
.toContain("Dear Hiring Manager");
|
||||
});
|
||||
|
||||
test("a failed load surfaces an error", async () => {
|
||||
mockedApi.get.mockRejectedValue(new Error("boom"));
|
||||
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
|
||||
expect(await screen.findByText(/Could not load this section/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -79,8 +79,8 @@ export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string; mile
|
||||
{ key: "analysis", label: "Analysis" },
|
||||
{ key: "match", label: "Match" },
|
||||
{ key: "checklist", label: "Checklist" },
|
||||
{ key: "cv", label: "CV", milestone: 6 },
|
||||
{ key: "cover-letter", label: "Cover Letter", milestone: 7 },
|
||||
{ key: "cv", label: "CV" },
|
||||
{ key: "cover-letter", label: "Cover Letter" },
|
||||
{ key: "portfolio", label: "Portfolio", milestone: 8 },
|
||||
{ key: "documents", label: "Documents" },
|
||||
{ key: "interview", label: "Interview Prep" },
|
||||
@@ -169,6 +169,71 @@ export const applicationIntelligenceApi = {
|
||||
match: (jobId: number) => api.get<CareerMatch>(`/jobapplications/${jobId}/match`).then((r) => r.data),
|
||||
};
|
||||
|
||||
// Phase 5.4 — Application Assets. CV variant CRUD, preview, PDF export and version history stay on
|
||||
// /api/cv (the existing CV builder). These types cover only what is application-scoped.
|
||||
export type CvVariantSummary = {
|
||||
id: number;
|
||||
name: string;
|
||||
themeId: string;
|
||||
publicSlug: string;
|
||||
isPublic: boolean;
|
||||
version: number;
|
||||
jobApplicationId: number | null;
|
||||
updatedAtUtc: string;
|
||||
};
|
||||
|
||||
export type ApplicationCv = {
|
||||
attachedVariantId: number | null;
|
||||
attachedVariantName: string | null;
|
||||
attachedThemeId: string | null;
|
||||
attachedVersion: number | null;
|
||||
attachedUpdatedAtUtc: string | null;
|
||||
attachedIsPublic: boolean;
|
||||
hasTailoredCvText: boolean;
|
||||
availableVariants: CvVariantSummary[];
|
||||
};
|
||||
|
||||
export type TailoringSuggestion = { kind: string; title: string; detail: string | null; items: string[] };
|
||||
|
||||
export type TailoringPlan = {
|
||||
hasJobDescription: boolean;
|
||||
hasCareerProfile: boolean;
|
||||
hasAttachedVariant: boolean;
|
||||
matchScore: number;
|
||||
suggestions: TailoringSuggestion[];
|
||||
aiSuggestionCount: number;
|
||||
};
|
||||
|
||||
export type CoverLetterVersion = {
|
||||
version: number;
|
||||
source: string;
|
||||
aiAction: string | null;
|
||||
length: number;
|
||||
createdAtUtc: string;
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
export type CoverLetter = {
|
||||
text: string | null;
|
||||
currentVersion: number;
|
||||
versions: CoverLetterVersion[];
|
||||
aiSuggestionCount: number;
|
||||
};
|
||||
|
||||
export const applicationAssetsApi = {
|
||||
cv: (jobId: number) => api.get<ApplicationCv>(`/jobapplications/${jobId}/cv`).then((r) => r.data),
|
||||
attachVariant: (jobId: number, variantId: number | null) =>
|
||||
api.put<ApplicationCv>(`/jobapplications/${jobId}/cv`, { variantId }).then((r) => r.data),
|
||||
tailoring: (jobId: number) =>
|
||||
api.get<TailoringPlan>(`/jobapplications/${jobId}/tailoring`).then((r) => r.data),
|
||||
coverLetter: (jobId: number) =>
|
||||
api.get<CoverLetter>(`/jobapplications/${jobId}/cover-letter`).then((r) => r.data),
|
||||
saveCoverLetter: (jobId: number, text: string, source = "manual", aiAction?: string) =>
|
||||
api.put<CoverLetter>(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction }).then((r) => r.data),
|
||||
restoreCoverLetter: (jobId: number, version: number) =>
|
||||
api.post<CoverLetter>(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const applicationChecklistApi = {
|
||||
get: (jobId: number) =>
|
||||
api.get<Checklist>(`/jobapplications/${jobId}/checklist`).then((r) => r.data),
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
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" />}
|
||||
href={`/cv-builder?variant=${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>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,9 @@ import ApplicationChecklist from "../components/ApplicationChecklist";
|
||||
import {
|
||||
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
|
||||
} from "../components/ApplicationIntelligence";
|
||||
import {
|
||||
ApplicationCoverLetterSection, ApplicationCvSection,
|
||||
} from "../components/ApplicationAssets";
|
||||
import {
|
||||
WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi,
|
||||
} from "../applicationWorkspace";
|
||||
@@ -113,7 +116,15 @@ export default function ApplicationWorkspacePage() {
|
||||
{section === "checklist" && jobId > 0 && (
|
||||
<ApplicationChecklist jobId={jobId} onChanged={load} />
|
||||
)}
|
||||
{["cv", "cover-letter", "portfolio", "notes"].includes(section) && (
|
||||
{section === "cv" && jobId > 0 && <ApplicationCvSection jobId={jobId} />}
|
||||
{section === "cover-letter" && jobId > 0 && (
|
||||
<>
|
||||
<ApplicationCoverLetterSection jobId={jobId} />
|
||||
{/* Generation stays an explicit user action, below the editor the user owns. */}
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}><AiWorkspacePanel jobId={jobId} /></Paper>
|
||||
</>
|
||||
)}
|
||||
{["portfolio", "notes"].includes(section) && (
|
||||
<ComingInMilestone section={section} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user