fix(rd21): retain draft revisions and qualify conflict recovery

This commit is contained in:
cesnimda
2026-10-23 12:34:34 +02:00
parent 12f342c09c
commit 05a738693e
53 changed files with 813 additions and 192 deletions
@@ -38,7 +38,7 @@ type AssetError = ViewResourceError & { messageKey: TranslationKey; retry?: () =
function assetError(error: unknown, messageKey: TranslationKey, retryable = false): AssetError {
const status = (error as { response?: { status?: number } })?.response?.status;
const unauthorized = status === 401 || status === 403;
return { kind: unauthorized ? "unauthorized" : "error", message: getApiErrorMessage(error, ""), messageKey, retryable: retryable && !unauthorized, status };
return { kind: unauthorized ? "unauthorized" : "error", message: status === 409 ? "" : getApiErrorMessage(error, ""), messageKey: status === 409 ? "revisionConflict" : messageKey, retryable: retryable && !unauthorized, status };
}
function useAsset<T>(load: () => Promise<T>, deps: React.DependencyList) {
@@ -538,7 +538,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
contentRef.current?.focus();
setBusy(true);
try {
const next = await applicationAssetsApi.saveCoverLetter(jobId, value, source, aiAction);
const next = await applicationAssetsApi.saveCoverLetter(jobId, value, data.revision, source, aiAction);
if (!isCurrent()) return;
setData(next);
setDraft(null);
@@ -568,7 +568,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
if (!isCurrent()) return;
contentRef.current?.focus();
setBusy(true);
const next = await applicationAssetsApi.restoreCoverLetter(jobId, version);
const next = await applicationAssetsApi.restoreCoverLetter(jobId, version, data.revision);
if (!isCurrent()) return;
setData(next);
setDraft(null);
@@ -832,18 +832,21 @@ export function ApplicationPackageDraftsSection({
jobId,
initialApplicationAnswer,
initialRecruiterMessage,
initialRevision,
onSaved,
onDirtyChange,
}: {
jobId: number;
initialApplicationAnswer: string;
initialRecruiterMessage: string;
initialRevision: number;
onSaved?: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const { t } = useI18n();
const initial = { applicationAnswer: initialApplicationAnswer, recruiterMessage: initialRecruiterMessage };
const [saved, setSaved] = useState<PackageDrafts>(initial);
const [revision, setRevision] = useState(initialRevision);
const [draft, setDraft] = useState<PackageDrafts | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<AssetError | null>(null);
@@ -853,10 +856,10 @@ export function ApplicationPackageDraftsSection({
useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
useEffect(() => {
if (draft === null) setSaved(initial);
if (draft === null) { setSaved(initial); setRevision(initialRevision); }
// `draft` is deliberately excluded: a parent refresh must never overwrite in-progress edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jobId, initialApplicationAnswer, initialRecruiterMessage]);
}, [jobId, initialApplicationAnswer, initialRecruiterMessage, initialRevision]);
const value = draft ?? saved;
const dirty = draft !== null && (
@@ -876,8 +879,9 @@ export function ApplicationPackageDraftsSection({
contentRef.current?.focus();
setBusy(true);
try {
await applicationAssetsApi.saveApplicationDrafts(jobId, value.applicationAnswer, value.recruiterMessage);
const nextRevision = await applicationAssetsApi.saveApplicationDrafts(jobId, value.applicationAnswer, value.recruiterMessage, revision);
if (!active.current) return;
setRevision(nextRevision);
setSaved(value);
setDraft(null);
setError(null);
+49 -32
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
Autocomplete,
@@ -71,6 +71,9 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
const { toast } = useToast();
const { t } = useI18n();
const [loading, setLoading] = useState(false);
const [version, setVersion] = useState<number | null>(null);
const session = useRef(0);
const saving = useRef(false);
const { companies } = useCompanies();
const [company, setCompany] = useState<Company | null>(null);
@@ -103,10 +106,15 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
const [hasOtherAttachment, setHasOtherAttachment] = useState(false);
useEffect(() => {
const current = ++session.current;
saving.current = false;
setVersion(null);
if (!open || !jobId) return;
setLoading(true);
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
if (current !== session.current) return;
const j = r.data;
setVersion(j.version);
setCompany(j.company ?? null);
setJobTitle(j.jobTitle ?? "");
setStatus(j.status ?? "Applied");
@@ -135,16 +143,24 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
setHasCoverLetter(Boolean((j as any).hasCoverLetter));
setHasPortfolio(Boolean((j as any).hasPortfolio));
setHasOtherAttachment(Boolean((j as any).hasOtherAttachment));
}).finally(() => setLoading(false));
}).catch(() => {
if (current === session.current) toast(t("editJobSaveFailed"), "error");
}).finally(() => { if (current === session.current) setLoading(false); });
return () => { session.current = current + 1; };
// Locale updates must retain the loaded draft.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, jobId]);
const canSave = useMemo(() => !!company?.id && jobTitle.trim().length > 0 && !loading, [company, jobTitle, loading]);
const canSave = useMemo(() => !!company?.id && jobTitle.trim().length > 0 && version != null && !loading, [company, jobTitle, loading, version]);
const save = async () => {
if (!jobId || !company?.id) return;
if (!jobId || !company?.id || !canSave || saving.current) return;
const current = session.current;
saving.current = true;
setLoading(true);
try {
await api.put(`/jobapplications/${jobId}`, {
expectedVersion: version,
jobTitle: jobTitle.trim(),
companyId: company.id,
status,
@@ -169,18 +185,19 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
dateApplied: dateApplied || null,
jobUrl: jobUrl.trim() || null,
});
if (current !== session.current) return;
toast(t("save"), "success");
onSaved();
onClose();
} catch {
toast(t("editJobSaveFailed"), "error");
} catch (error) {
if (current === session.current) toast(t((error as { response?: { status?: number } }).response?.status === 409 ? "revisionConflict" : "editJobSaveFailed"), "error");
} finally {
setLoading(false);
if (current === session.current) { saving.current = false; setLoading(false); }
}
};
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<Dialog open={open} onClose={() => { if (!saving.current) onClose(); }} fullWidth maxWidth="md">
<DialogTitle>{t("editJobTitle")}</DialogTitle>
<DialogContent>
<Box sx={{ mt: 1, mb: 2, p: 1.5, borderRadius: 3, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.paper" }}>
@@ -193,48 +210,48 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobApplicationDetails")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<Autocomplete options={companies} getOptionLabel={(c) => c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => <TextField {...params} label={t("company")} sx={FIELD_SX} />} />
<TextField label={t("editJobJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} sx={FIELD_SX} />
<DatePicker label={t("editJobAppliedOn")} value={parsePickerDate(dateApplied)} onChange={(value) => setDateApplied(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} sx={FIELD_SX} />
<Autocomplete disabled={loading} options={companies} getOptionLabel={(c) => c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => <TextField {...params} label={t("company")} sx={FIELD_SX} />} />
<TextField disabled={loading} label={t("editJobJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} sx={FIELD_SX} />
<DatePicker disabled={loading} label={t("editJobAppliedOn")} value={parsePickerDate(dateApplied)} onChange={(value) => setDateApplied(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField disabled={loading} label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} sx={FIELD_SX} />
</Box>
</Paper>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobStatusUpdate")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)} sx={FIELD_SX}>
<TextField disabled={loading} select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)} sx={FIELD_SX}>
{PIPELINE_STATUSES.map((s) => <MenuItem key={s} value={s}>{statusLabel(t, s)}</MenuItem>)}
</TextField>
<DatePicker label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { ...PICKER_TEXT_FIELD_PROPS, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
<DatePicker label={t("editJobReplyReceivedOn")} disabled={!responseReceived} value={parsePickerDate(responseDate)} onChange={(value) => setResponseDate(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField label={t("editJobNextAction")} value={nextAction} onChange={(e) => setNextAction(e.target.value)} sx={FIELD_SX} />
<DatePicker label={t("editJobFollowUpOn")} value={parsePickerDate(followUpAt)} onChange={(value) => setFollowUpAt(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<DatePicker disabled={loading} label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { ...PICKER_TEXT_FIELD_PROPS, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox disabled={loading} checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
<DatePicker disabled={loading || !responseReceived} label={t("editJobReplyReceivedOn")} value={parsePickerDate(responseDate)} onChange={(value) => setResponseDate(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField disabled={loading} label={t("editJobNextAction")} value={nextAction} onChange={(e) => setNextAction(e.target.value)} sx={FIELD_SX} />
<DatePicker disabled={loading} label={t("editJobFollowUpOn")} value={parsePickerDate(followUpAt)} onChange={(value) => setFollowUpAt(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
</Box>
</Paper>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobRoleDetails")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryMinLabel")} type="number" value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryMaxLabel")} type="number" value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryCurrencyLabel")} value={salaryCurrency} onChange={(e) => setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} />
<TextField select SelectProps={{ native: true }} label={t("salaryPeriodLabel")} value={salaryPeriod} onChange={(e) => setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}>
<TextField disabled={loading} label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
<TextField disabled={loading} label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
<TextField disabled={loading} label={t("salaryMinLabel")} type="number" value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} sx={FIELD_SX} />
<TextField disabled={loading} label={t("salaryMaxLabel")} type="number" value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} sx={FIELD_SX} />
<TextField disabled={loading} label={t("salaryCurrencyLabel")} value={salaryCurrency} onChange={(e) => setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} />
<TextField disabled={loading} select SelectProps={{ native: true }} label={t("salaryPeriodLabel")} value={salaryPeriod} onChange={(e) => setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}>
<option value=""></option>
<option value="year">{t("salaryPeriodYear")}</option>
<option value="month">{t("salaryPeriodMonth")}</option>
<option value="hour">{t("salaryPeriodHour")}</option>
</TextField>
<DatePicker label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} sx={FIELD_SX} />
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput value={tags} onChange={setTags} /></Box>
<TextField label={t("editJobNotes")} value={notes} onChange={(e) => setNotes(e.target.value)} multiline rows={4} helperText={t("correspondenceCharacters", { count: notes.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField label={t("editJobDescriptionOriginal")} value={description} onChange={(e) => setDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: description.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField label={t("editJobTranslatedDescription")} value={translatedDescription} onChange={(e) => setTranslatedDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: translatedDescription.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField label={t("editJobCoverLetter")} value={coverLetterText} onChange={(e) => setCoverLetterText(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: coverLetterText.length })} sx={{ gridColumn: "1 / -1" }} />
<DatePicker disabled={loading} label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField disabled={loading} label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} sx={FIELD_SX} />
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput disabled={loading} value={tags} onChange={setTags} /></Box>
<TextField disabled={loading} label={t("editJobNotes")} value={notes} onChange={(e) => setNotes(e.target.value)} multiline rows={4} helperText={t("correspondenceCharacters", { count: notes.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField disabled={loading} label={t("editJobDescriptionOriginal")} value={description} onChange={(e) => setDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: description.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField disabled={loading} label={t("editJobTranslatedDescription")} value={translatedDescription} onChange={(e) => setTranslatedDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: translatedDescription.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField disabled={loading} label={t("editJobCoverLetter")} value={coverLetterText} onChange={(e) => setCoverLetterText(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: coverLetterText.length })} sx={{ gridColumn: "1 / -1" }} />
</Box>
</Paper>
@@ -252,7 +269,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t("cancel")}</Button>
<Button onClick={onClose} disabled={loading}>{t("cancel")}</Button>
<Button variant="contained" onClick={save} disabled={!canSave}>{t("save")}</Button>
</DialogActions>
</Dialog>
@@ -11,7 +11,7 @@ import { checkSessionCookie, getSessionContext, isSessionInvalidated, SESSION_IN
// savedAt is omitted alongside id: both are assigned by the server on create, so an imported
// row has no business supplying one.
type ImportJob = Omit<JobApplication, "id" | "company" | "savedAt"> & {
type ImportJob = Omit<JobApplication, "id" | "company" | "savedAt" | "version"> & {
company: Pick<Company, "name" | "location" | "source">;
};
@@ -7,14 +7,17 @@ export default function TagsInput({
label,
value,
onChange,
disabled = false,
}: {
label?: string;
value: string[];
onChange: (next: string[]) => void;
disabled?: boolean;
}) {
const { t } = useI18n();
return (
<Autocomplete
disabled={disabled}
multiple
freeSolo
options={[]}