b4fd5e2f96
Backlog item 4 (Wave 3, first sub-item). HasResume/HasCoverLetter/HasPortfolio/ HasOtherAttachment were manually-editable checkboxes in EditJobDialog, completely independent of whether a file was actually attached -- classic drift: mark 'resume ready' by hand, later delete the resume attachment, flag stays stuck true forever. User confirmed (asked directly, since removing the manual-override capability is a product decision, not purely technical): make them fully computed from Attachments, no manual override. - AttachmentsController.RecomputeAttachmentFlagsAsync: the single place these four fields get written now, called after every attachment mutation (upload, delete, Purpose change) that could affect them. Deliberately kept as persisted columns (not [NotMapped] computed properties reading the Attachments navigation collection) -- ~15 query sites build JobApplication DTOs without .Include(Attachments), so a live-computed property would silently return false everywhere instead of throwing, the worst kind of bug. Recomputing at the one write funnel avoids touching any read path. - Removed HasResume/etc from CreateJobApplicationRequest/ UpdateJobApplicationRequest -- no longer client-settable. - EditJobDialog: removed the manual checkboxes, kept the (now genuinely accurate) read-only status chips. - AddJobModal: stopped sending has*-flags at job-creation time; the follow-up attachment upload call now sets them correctly via the same recompute path. Caught a real bug while testing this: the Purpose-change path recomputed before saving the Purpose change, so a fresh query missed the pending edit and the flags never updated. Fixed by committing the mutation before recomputing. 3 new backend tests (purpose-change sets flag, delete clears flag, non-primary purpose counts as "other"). 172/172 backend, 25/25 frontend suites (57 tests) green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
260 lines
14 KiB
TypeScript
260 lines
14 KiB
TypeScript
import React, { useEffect, useMemo, useState } from "react";
|
|
|
|
import {
|
|
Autocomplete,
|
|
Box,
|
|
Button,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogTitle,
|
|
FormControlLabel,
|
|
Checkbox,
|
|
MenuItem,
|
|
Paper,
|
|
TextField,
|
|
Typography,
|
|
Chip,
|
|
} from "@mui/material";
|
|
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
|
|
|
import { api } from "../api";
|
|
import { Company, JobApplication } from "../types";
|
|
import { useToast } from "../toast";
|
|
import { useCompanies } from "../hooks/useCompanies";
|
|
import TagsInput from "./TagsInput";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
import { PIPELINE_STATUSES, statusLabel } from "../pipeline";
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
jobId: number | null;
|
|
onClose: () => void;
|
|
onSaved: () => void;
|
|
}
|
|
|
|
const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } };
|
|
const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX };
|
|
|
|
function toDateInputValue(isoLike?: string): string {
|
|
if (!isoLike) return new Date().toISOString().slice(0, 10);
|
|
const d = new Date(isoLike);
|
|
if (Number.isNaN(+d)) return new Date().toISOString().slice(0, 10);
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function parsePickerDate(value?: string | null): Date | null {
|
|
if (!value) return null;
|
|
const parsed = new Date(value);
|
|
return Number.isNaN(+parsed) ? null : parsed;
|
|
}
|
|
|
|
function toPickerIso(value: Date | null): string {
|
|
if (!value || Number.isNaN(+value)) return "";
|
|
return value.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function parseTags(raw: any): string[] {
|
|
if (!raw) return [];
|
|
if (Array.isArray(raw)) return raw.filter((x) => typeof x === "string");
|
|
if (typeof raw !== "string") return [];
|
|
try {
|
|
const p = JSON.parse(raw);
|
|
return Array.isArray(p) ? p.filter((x) => typeof x === "string") : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) {
|
|
const { toast } = useToast();
|
|
const { t } = useI18n();
|
|
const [loading, setLoading] = useState(false);
|
|
const { companies } = useCompanies();
|
|
|
|
const [company, setCompany] = useState<Company | null>(null);
|
|
const [jobTitle, setJobTitle] = useState("");
|
|
const [status, setStatus] = useState("Applied");
|
|
const [initialStatus, setInitialStatus] = useState("Applied");
|
|
const [statusChangedAt, setStatusChangedAt] = useState(() => new Date().toISOString().slice(0, 10));
|
|
const [dateApplied, setDateApplied] = useState(() => new Date().toISOString().slice(0, 10));
|
|
const [location, setLocation] = useState("");
|
|
const [salary, setSalary] = useState("");
|
|
const [salaryMin, setSalaryMin] = useState("");
|
|
const [salaryMax, setSalaryMax] = useState("");
|
|
const [salaryCurrency, setSalaryCurrency] = useState("");
|
|
const [salaryPeriod, setSalaryPeriod] = useState("");
|
|
const [nextAction, setNextAction] = useState("");
|
|
const [followUpAt, setFollowUpAt] = useState<string>("");
|
|
const [jobUrl, setJobUrl] = useState("");
|
|
const [notes, setNotes] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [translatedDescription, setTranslatedDescription] = useState("");
|
|
const [descriptionLanguage, setDescriptionLanguage] = useState("");
|
|
const [tags, setTags] = useState<string[]>([]);
|
|
const [deadline, setDeadline] = useState<string>("");
|
|
const [coverLetterText, setCoverLetterText] = useState("");
|
|
const [responseReceived, setResponseReceived] = useState(false);
|
|
const [responseDate, setResponseDate] = useState<string>("");
|
|
const [hasResume, setHasResume] = useState(false);
|
|
const [hasCoverLetter, setHasCoverLetter] = useState(false);
|
|
const [hasPortfolio, setHasPortfolio] = useState(false);
|
|
const [hasOtherAttachment, setHasOtherAttachment] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!open || !jobId) return;
|
|
setLoading(true);
|
|
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
|
|
const j = r.data;
|
|
setCompany(j.company ?? null);
|
|
setJobTitle(j.jobTitle ?? "");
|
|
setStatus(j.status ?? "Applied");
|
|
setInitialStatus(j.status ?? "Applied");
|
|
setStatusChangedAt(new Date().toISOString().slice(0, 10));
|
|
setDateApplied(toDateInputValue(j.dateApplied));
|
|
setLocation(j.location ?? "");
|
|
setSalary(j.salary ?? "");
|
|
setSalaryMin(j.salaryMin != null ? String(j.salaryMin) : "");
|
|
setSalaryMax(j.salaryMax != null ? String(j.salaryMax) : "");
|
|
setSalaryCurrency(j.salaryCurrency ?? "");
|
|
setSalaryPeriod(j.salaryPeriod ?? "");
|
|
setNextAction((j as any).nextAction ?? "");
|
|
setFollowUpAt((j as any).followUpAt ? toDateInputValue((j as any).followUpAt) : "");
|
|
setJobUrl(j.jobUrl ?? "");
|
|
setNotes(j.notes ?? "");
|
|
setDescription((j as any).description ?? "");
|
|
setTranslatedDescription((j as any).translatedDescription ?? "");
|
|
setDescriptionLanguage((j as any).descriptionLanguage ?? "");
|
|
setTags(parseTags((j as any).tags));
|
|
setDeadline((j as any).deadline ? toDateInputValue((j as any).deadline) : "");
|
|
setCoverLetterText(j.coverLetterText ?? "");
|
|
setResponseReceived(Boolean(j.responseReceived));
|
|
setResponseDate(j.responseDate ? toDateInputValue(j.responseDate) : "");
|
|
setHasResume(Boolean((j as any).hasResume));
|
|
setHasCoverLetter(Boolean((j as any).hasCoverLetter));
|
|
setHasPortfolio(Boolean((j as any).hasPortfolio));
|
|
setHasOtherAttachment(Boolean((j as any).hasOtherAttachment));
|
|
}).finally(() => setLoading(false));
|
|
}, [open, jobId]);
|
|
|
|
const canSave = useMemo(() => !!company?.id && jobTitle.trim().length > 0 && !loading, [company, jobTitle, loading]);
|
|
|
|
const save = async () => {
|
|
if (!jobId || !company?.id) return;
|
|
setLoading(true);
|
|
try {
|
|
await api.put(`/jobapplications/${jobId}`, {
|
|
jobTitle: jobTitle.trim(),
|
|
companyId: company.id,
|
|
status,
|
|
statusChangedAt: status !== initialStatus ? statusChangedAt || null : null,
|
|
responseReceived,
|
|
responseDate: responseReceived && responseDate ? responseDate : null,
|
|
location: location.trim() || null,
|
|
salary: salary.trim() || null,
|
|
salaryMin: salaryMin.trim() ? Number(salaryMin) : null,
|
|
salaryMax: salaryMax.trim() ? Number(salaryMax) : null,
|
|
salaryCurrency: salaryCurrency.trim() || null,
|
|
salaryPeriod: salaryPeriod || null,
|
|
nextAction: nextAction.trim() || null,
|
|
followUpAt: followUpAt || null,
|
|
notes: notes || null,
|
|
description: description || null,
|
|
translatedDescription: translatedDescription || null,
|
|
descriptionLanguage: descriptionLanguage || null,
|
|
tags: tags.length ? JSON.stringify(tags) : null,
|
|
deadline: deadline || null,
|
|
coverLetterText: coverLetterText || null,
|
|
dateApplied: dateApplied || null,
|
|
jobUrl: jobUrl.trim() || null,
|
|
});
|
|
toast(t("save"), "success");
|
|
onSaved();
|
|
onClose();
|
|
} catch {
|
|
toast(t("editJobSaveFailed"), "error");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
|
<DialogTitle>{t("editJobTitle")}</DialogTitle>
|
|
<DialogContent>
|
|
<Box sx={{ mt: 1, mb: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
|
{t("editJobIntro")}
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
|
|
<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} />
|
|
</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}>
|
|
{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 }} />
|
|
</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 }}>
|
|
<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" }} />
|
|
</Box>
|
|
</Paper>
|
|
|
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobAttachmentsChecklist")}</Typography>
|
|
{/* Derived from actual uploaded attachments (see the Attachments panel) -- not
|
|
manually editable, so this can never drift from what's really attached. */}
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
|
|
<Chip size="small" label={hasResume ? t("editJobResumeReady") : t("editJobResumeMissing")} color={hasResume ? "success" : "default"} variant={hasResume ? "filled" : "outlined"} />
|
|
<Chip size="small" label={hasCoverLetter ? t("editJobCoverLetterReady") : t("editJobCoverLetterMissing")} color={hasCoverLetter ? "success" : "default"} variant={hasCoverLetter ? "filled" : "outlined"} />
|
|
<Chip size="small" label={hasPortfolio ? t("editJobPortfolioReady") : t("editJobPortfolioOptional")} color={hasPortfolio ? "success" : "default"} variant={hasPortfolio ? "filled" : "outlined"} />
|
|
{hasOtherAttachment && <Chip size="small" label={t("editJobOtherAttachment")} color="success" variant="filled" />}
|
|
</Box>
|
|
</Paper>
|
|
</Box>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={onClose}>{t("cancel")}</Button>
|
|
<Button variant="contained" onClick={save} disabled={!canSave}>{t("save")}</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
);
|
|
}
|