feat(jobs): complete workspace draft parity
CI and Deploy / test (pull_request) Successful in 5m4s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-15 17:34:32 +02:00
parent 3b86ea2da0
commit deed948183
28 changed files with 676 additions and 131 deletions
@@ -1,5 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
BlockerFunction, useBeforeUnload, useBlocker, useLocation, useNavigate, useParams, useSearchParams,
} from "react-router-dom";
import {
Alert, Box, Button, Chip, Divider, IconButton, List, ListItemButton, ListItemText, Paper,
@@ -24,10 +26,11 @@ import {
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
} from "../components/ApplicationIntelligence";
import {
ApplicationCoverLetterSection, ApplicationCvSection,
ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection,
} from "../components/ApplicationAssets";
import { ApplicationInterviewPrep } from "../components/InterviewPrep";
import EditJobDialog from "../components/EditJobDialog";
import { useConfirm } from "../confirm";
import {
WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, workspaceSection,
} from "../applicationWorkspace";
@@ -60,12 +63,48 @@ export function ApplicationWorkspace({
const jobId = jobIdOverride ?? Number(id);
const location = useLocation();
const navigate = useNavigate();
const { confirm } = useConfirm();
const [params, setParams] = useSearchParams();
const section = sectionOverride ?? workspaceSection(params.get("section"));
const [overview, setOverview] = useState<WorkspaceOverview | null>(null);
const [error, setError] = useState<string | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [coverLetterDirty, setCoverLetterDirty] = useState(false);
const [packageDraftsDirty, setPackageDraftsDirty] = useState(false);
const hasUnsavedChanges = coverLetterDirty || packageDraftsDirty;
const shouldBlock = useCallback<BlockerFunction>(
({ currentLocation, nextLocation }) => hasUnsavedChanges && (
currentLocation.pathname !== nextLocation.pathname || currentLocation.search !== nextLocation.search
),
[hasUnsavedChanges],
);
const blocker = useBlocker(shouldBlock);
useBeforeUnload(useCallback((event) => {
if (!hasUnsavedChanges) return;
event.preventDefault();
event.returnValue = "";
}, [hasUnsavedChanges]));
useEffect(() => {
if (blocker.state !== "blocked") return;
const blockedNavigation = blocker;
let active = true;
void confirm({
title: "Unsaved application changes",
message: "Leaving this section will discard changes that have not been saved.",
confirmLabel: "Discard and leave",
cancelLabel: "Keep editing",
destructive: true,
}).then((approved) => {
if (!active) return;
if (approved) blockedNavigation.proceed();
else blockedNavigation.reset();
});
return () => { active = false; };
}, [blocker, confirm]);
const load = useCallback(async () => {
if (!Number.isInteger(jobId) || jobId <= 0) {
@@ -90,8 +129,13 @@ export function ApplicationWorkspace({
else setParams({ section: next }, { replace: true, state: location.state });
};
const close = onClose ?? (() => {
const from = (location.state as { from?: unknown } | null)?.from;
navigate(typeof from === "string" && from.startsWith("/") && !from.startsWith("//") ? from : "/jobs", { replace: true });
const state = location.state as { from?: unknown; focusJobId?: unknown } | null;
const from = state?.from;
const focusJobId = typeof state?.focusJobId === "number" ? state.focusJobId : undefined;
navigate(
typeof from === "string" && from.startsWith("/") && !from.startsWith("//") ? from : "/jobs",
{ replace: true, state: focusJobId ? { focusJobId } : undefined },
);
});
if (error) {
@@ -171,7 +215,14 @@ export function ApplicationWorkspace({
{section === "cv" && jobId > 0 && <ApplicationCvSection jobId={jobId} />}
{section === "cover-letter" && jobId > 0 && (
<>
<ApplicationCoverLetterSection jobId={jobId} />
<ApplicationCoverLetterSection jobId={jobId} onDirtyChange={setCoverLetterDirty} />
<ApplicationPackageDraftsSection
jobId={jobId}
initialApplicationAnswer={overview?.applicationAnswerDraft ?? ""}
initialRecruiterMessage={overview?.recruiterMessageDraft ?? ""}
onSaved={load}
onDirtyChange={setPackageDraftsDirty}
/>
{/* Generation stays an explicit user action, below the editor the user owns. */}
<Paper sx={{ p: 2, borderRadius: 3 }}><AiWorkspacePanel jobId={jobId} /></Paper>
</>