import React, { useEffect, useMemo, useRef, useState } from "react"; import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import { Alert, Autocomplete, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, IconButton, List, ListItem, ListItemText, MenuItem, TextField, Typography, } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined"; import { api, getApiErrorMessage } from "../api"; import { Company, JobImportResult } from "../types"; import { invalidateCompaniesCache, useCompanies } from "../hooks/useCompanies"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; import { PIPELINE_STATUSES, statusLabel as pipelineStatusLabel } from "../pipeline"; import TagsInput from "./TagsInput"; interface Props { open: boolean; onClose: () => void; onCreated: () => void; initialUrl?: string; } type DuplicateCandidate = { id: number; jobTitle: string; company: string; jobUrl?: string | null; status: string; dateApplied: string; reason: string; }; type DuplicateCheckResult = { hasDuplicates: boolean; matches: DuplicateCandidate[]; }; type CreatedJobResponse = { id?: number; }; type AttachmentBucketKey = "resume" | "coverLetter" | "portfolio" | "other"; type AttachmentBuckets = Record; const ACCEPTED_DOCUMENT_TYPES = ".pdf,.doc,.docx,.txt,.md,image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown"; const FIELD_SX = { "& .MuiInputBase-root": { minHeight: 56 } }; const PICKER_TEXT_FIELD_PROPS = { fullWidth: true, sx: FIELD_SX }; function getTodayIso() { return new Date().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) { if (!value || Number.isNaN(+value)) return ""; return value.toISOString().slice(0, 10); } function emptyAttachmentBuckets(): AttachmentBuckets { return { resume: [], coverLetter: [], portfolio: [], other: [], }; } function normalizeLanguage(value?: string | null) { const raw = (value || "").trim().toLowerCase(); if (!raw) return ""; if (["en", "eng", "english"].includes(raw)) return "en"; if (["no", "nb", "nn", "norwegian", "norwegian bokmål", "bokmal", "bokmål"].includes(raw)) return "no"; return raw; } export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Props) { const { toast } = useToast(); const { t, language } = useI18n(); const [saving, setSaving] = useState(false); const [importing, setImporting] = useState(false); const [duplicateCheck, setDuplicateCheck] = useState(null); const { companies: cachedCompanies } = useCompanies(); const [companies, setCompanies] = useState([]); const [company, setCompany] = useState(null); const [companyInput, setCompanyInput] = useState(""); const [newCompanyLocation, setNewCompanyLocation] = useState(""); const [newCompanySource, setNewCompanySource] = useState(""); const [dateApplied, setDateApplied] = useState(() => getTodayIso()); const [jobTitle, setJobTitle] = useState(""); const [status, setStatus] = useState<(typeof PIPELINE_STATUSES)[number]>("Applied"); 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 [jobUrl, setJobUrl] = useState(""); const [deadline, setDeadline] = useState(""); const [description, setDescription] = useState(""); const [translatedDescription, setTranslatedDescription] = useState(""); const [descriptionLanguage, setDescriptionLanguage] = useState(""); const [tags, setTags] = useState([]); const [notes, setNotes] = useState(""); const [attachments, setAttachments] = useState(() => emptyAttachmentBuckets()); useEffect(() => { setCompanies(cachedCompanies); }, [cachedCompanies]); // Quick-capture: when opened with a URL (from the bookmarklet), prefill and auto-import once. const autoImportedUrlRef = useRef(null); useEffect(() => { if (!open) { autoImportedUrlRef.current = null; return; } const url = initialUrl?.trim(); if (!url || autoImportedUrlRef.current === url) return; autoImportedUrlRef.current = url; setJobUrl(url); void importFromUrl(url); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, initialUrl]); const resetForm = () => { setCompany(null); setCompanyInput(""); setNewCompanyLocation(""); setNewCompanySource(""); setDateApplied(getTodayIso()); setJobTitle(""); setStatus("Applied"); setLocation(""); setSalary(""); setJobUrl(""); setDeadline(""); setDescription(""); setTranslatedDescription(""); setDescriptionLanguage(""); setTags([]); setNotes(""); setAttachments(emptyAttachmentBuckets()); setDuplicateCheck(null); }; const normalizedCompanyName = companyInput.trim(); const matchingCompany = useMemo(() => { if (!normalizedCompanyName) return null; return companies.find((c) => c.name.toLowerCase() === normalizedCompanyName.toLowerCase()) ?? null; }, [companies, normalizedCompanyName]); const selectedCompanyId = company?.id ?? matchingCompany?.id ?? 0; const showNewCompanyFields = !company && !!normalizedCompanyName && !matchingCompany; const preferredLanguage = normalizeLanguage(language); const sourceLanguage = normalizeLanguage(descriptionLanguage); const shouldShowTranslatedDescription = Boolean(sourceLanguage && preferredLanguage && sourceLanguage !== preferredLanguage); const attachmentCount = Object.values(attachments).reduce((sum, files) => sum + files.length, 0); useEffect(() => { if (!open) return; const title = jobTitle.trim(); const url = jobUrl.trim(); if (!selectedCompanyId && !url) { setDuplicateCheck(null); return; } if (!title && !url) { setDuplicateCheck(null); return; } const timeout = window.setTimeout(() => { api .get("/jobapplications/duplicate-check", { params: { companyId: selectedCompanyId || undefined, jobTitle: title || undefined, jobUrl: url || undefined, }, }) .then((r) => setDuplicateCheck(r.data)) .catch(() => setDuplicateCheck(null)); }, 350); return () => window.clearTimeout(timeout); }, [open, selectedCompanyId, jobTitle, jobUrl]); const createCompany = async (): Promise => { if (!normalizedCompanyName) return null; const payload: Partial = { name: normalizedCompanyName }; if (newCompanyLocation.trim()) payload.location = newCompanyLocation.trim(); if (newCompanySource.trim()) payload.source = newCompanySource.trim(); try { const res = await api.post("/companies", payload); setCompany(res.data); setCompanyInput(res.data.name); setCompanies((prev) => [...prev, res.data]); invalidateCompaniesCache(); setNewCompanyLocation(""); setNewCompanySource(""); return res.data; } catch (error: any) { toast(getApiErrorMessage(error, t("addJobModalFailedCreateCompany")), "error"); return null; } }; const importFromUrl = async (urlArg?: string) => { if (importing) return; const url = (urlArg ?? jobUrl).trim(); if (!url) { toast(t("addJobModalPasteUrlFirst"), "warning"); return; } setImporting(true); try { const res = await api.post("/jobimport/preview", { url }); const r = res.data; if (!r?.success) throw new Error(r?.error || t("addJobModalImportFailed")); if (r.title) setJobTitle(r.title); if (r.location) setLocation(r.location); if (r.company) { setCompany(null); setCompanyInput(r.company); try { if (r.sourceUrl) setNewCompanySource(new URL(r.sourceUrl).hostname); } catch { // ignore } } setDescription(r.description || ""); setTranslatedDescription(r.translatedDescription || ""); setDescriptionLanguage(r.language || ""); setTags(r.tags || []); setDeadline(r.deadline ? r.deadline.slice(0, 10) : ""); toast(t("addJobModalImported"), "success"); } catch (e: any) { toast(e?.message || t("addJobModalImportFailed"), "error"); } finally { setImporting(false); } }; const uploadAttachments = async (jobId: number) => { const files = Object.values(attachments).flat(); if (!files.length) return; const data = new FormData(); files.forEach((file) => data.append("files", file)); data.append("jobId", String(jobId)); await api.post("/attachments", data, { headers: { "Content-Type": "multipart/form-data" }, }); }; const createJob = async (addAnother = false) => { if (saving) return; setSaving(true); try { let selectedCompany = company ?? matchingCompany; if (!selectedCompany && normalizedCompanyName) { selectedCompany = await createCompany(); } if (!selectedCompany) { toast(t("addJobModalSelectCompany"), "warning"); return; } const response = await api.post("/jobapplications", { jobTitle, companyId: selectedCompany.id, status, location, salary, salaryMin: salaryMin.trim() ? Number(salaryMin) : null, salaryMax: salaryMax.trim() ? Number(salaryMax) : null, salaryCurrency: salaryCurrency.trim() || null, salaryPeriod: salaryPeriod || null, nextAction: null, followUpAt: null, jobUrl, description: description || null, translatedDescription: shouldShowTranslatedDescription ? translatedDescription || null : null, descriptionLanguage: descriptionLanguage || null, tags: tags.length ? JSON.stringify(tags) : null, deadline: deadline || null, notes, coverLetterText: null, dateApplied, }); if (response.data?.id && attachmentCount > 0) { try { await uploadAttachments(response.data.id); toast(t("addJobModalJobAndFilesAdded"), "success"); } catch { toast(t("addJobModalJobCreatedUploadFailed"), "warning"); } } else if (attachmentCount > 0) { toast(t("addJobModalJobCreatedFilesNotAttached"), "warning"); } else { toast(t("addJobModalJobAdded"), "success"); } onCreated(); resetForm(); if (!addAnother) { onClose(); } } catch { toast(t("addJobModalFailedAddJob"), "error"); } finally { setSaving(false); } }; const canSave = normalizedCompanyName.length > 0 && jobTitle.trim().length > 0; const setFilesForBucket = (bucket: AttachmentBucketKey, files: FileList | null) => { setAttachments((prev) => ({ ...prev, [bucket]: files ? Array.from(files) : [], })); }; const filesLabel = (files: File[]) => { if (files.length === 0) return t("addJobModalNoFilesSelected"); if (files.length === 1) return files[0].name; return t("addJobModalFilesSelected", { count: files.length }); }; const uploadField = ( bucket: AttachmentBucketKey, label: string, helperText: string, ) => ( {label} {helperText} {filesLabel(attachments[bucket])} ); return ( {t("addJob")} {t("addJobModalCompanySection")} options={companies} getOptionLabel={(c) => (typeof c === "string" ? c : c.name)} value={company} freeSolo inputValue={companyInput} onInputChange={(_, v) => { setCompanyInput(v); if (!v) setCompany(null); }} onChange={(_, v) => { if (typeof v === "string") { setCompany(null); setCompanyInput(v); return; } setCompany(v); setCompanyInput(v?.name ?? ""); if (v) { setNewCompanyLocation(""); setNewCompanySource(""); } }} renderInput={(params) => } /> {showNewCompanyFields ? ( setNewCompanyLocation(e.target.value)} /> setNewCompanySource(e.target.value)} /> ) : null} {duplicateCheck?.hasDuplicates ? ( {t("addJobModalPossibleDuplicates")} {duplicateCheck.matches.map((match) => ( ))} ) : null} {t("addJobModalJobApplicationSection")} setJobUrl(e.target.value)} sx={{ ...FIELD_SX, gridColumn: "1 / -1" }} /> setDateApplied(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} /> setStatus(e.target.value as any)} sx={FIELD_SX}> {PIPELINE_STATUSES.map((s) => ( {pipelineStatusLabel(t, s)} ))} setJobTitle(e.target.value)} sx={FIELD_SX} /> setLocation(e.target.value)} sx={FIELD_SX} /> setSalary(e.target.value)} sx={FIELD_SX} /> setSalaryMin(e.target.value)} sx={FIELD_SX} /> setSalaryMax(e.target.value)} sx={FIELD_SX} /> setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} /> setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}> setDeadline(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} /> setDescription(e.target.value)} helperText={`${description.length} characters`} sx={{ gridColumn: "1 / -1" }} /> {shouldShowTranslatedDescription ? ( setTranslatedDescription(e.target.value)} helperText={`${translatedDescription.length} characters`} sx={{ gridColumn: "1 / -1" }} /> ) : null} setDescriptionLanguage(e.target.value)} helperText={shouldShowTranslatedDescription ? t("addJobModalTranslatedShown", { language: preferredLanguage.toUpperCase() }) : t("addJobModalTranslatedHidden")} sx={{ gridColumn: "1 / -1" }} /> setNotes(e.target.value)} helperText={`${notes.length} characters`} sx={{ gridColumn: "1 / -1" }} /> {t("addJobModalDocuments")} {uploadField("resume", t("addJobModalResume"), t("addJobModalResumeHelp"))} {uploadField("coverLetter", t("addJobModalCoverLetter"), t("addJobModalCoverLetterHelp"))} {uploadField("portfolio", t("addJobModalPortfolio"), t("addJobModalPortfolioHelp"))} {uploadField("other", t("addJobModalOtherFiles"), t("addJobModalOtherFilesHelp"))} ); }