import React, { useEffect, useMemo, useRef, useState } from "react"; import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import { Alert, Autocomplete, Box, Button, Checkbox, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControlLabel, IconButton, List, ListItem, ListItemText, MenuItem, Step, StepLabel, Stepper, TextField, Typography, } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import { useAccountPlan } from "../accountPlan"; 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", "nb-no", "nn", "norwegian", "norwegian bokmål", "bokmal", "bokmål"].includes(raw)) return "nb"; return raw; } export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Props) { const { canUseAi } = useAccountPlan(); const { toast } = useToast(); const { t, language } = useI18n(); const [saving, setSaving] = useState(false); const [activeStep, setActiveStep] = useState(0); 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]>("Saved"); 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 [jobSource, setJobSource] = useState(""); const [countryCode, setCountryCode] = 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 [generateTailoredCv, setGenerateTailoredCv] = useState(false); 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 = () => { setActiveStep(0); setCompany(null); setCompanyInput(""); setNewCompanyLocation(""); setNewCompanySource(""); setDateApplied(getTodayIso()); setJobTitle(""); setStatus("Saved"); setLocation(""); setSalary(""); setSalaryMin(""); setSalaryMax(""); setSalaryCurrency(""); setSalaryPeriod(""); setJobUrl(""); setJobSource(""); setCountryCode(""); setDeadline(""); setDescription(""); setTranslatedDescription(""); setDescriptionLanguage(""); setTags([]); setNotes(""); setGenerateTailoredCv(false); 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 } } setJobSource(r.source || ""); setCountryCode(r.countryCode || ""); setDescription(r.description || ""); setTranslatedDescription(r.translatedDescription || ""); setDescriptionLanguage(r.language || ""); setTags(r.tags || []); setDeadline(r.deadline ? r.deadline.slice(0, 10) : ""); toast(t("addJobModalImported"), "success"); setActiveStep(1); } 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, source: jobSource || null, countryCode: countryCode || null, 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 && generateTailoredCv && canUseAi) { try { await api.post(`/jobapplications/${response.data.id}/generate-tailored-cv-draft`); } catch (error: any) { toast(getApiErrorMessage(error, "Job created, but the tailored CV could not be generated."), "warning"); } } 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 steps = ["Add job", "Review details", "CV", "Cover letter", "Portfolio", "Additional files"]; const optionalStep = activeStep >= 2 && activeStep <= 5; 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")} {steps.map((label) => {label})} Step {activeStep + 1} of {steps.length}: {steps[activeStep]} {activeStep === 0 ? Start with the job advert Paste a job URL and Jobbjakt will try to fill the details. You can continue with manual entry whenever extraction is unavailable. setJobUrl(e.target.value)} fullWidth sx={FIELD_SX} /> : null} {activeStep === 1 ? <> {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" }} /> : null} {activeStep >= 2 ? {steps[activeStep]} {activeStep === 2 ? "Use your master CV later in Career Workspace, or attach the version prepared for this application now." : null} {activeStep === 3 ? "Attach a drafted cover letter now, or add/generate one from the application workspace later." : null} {activeStep === 4 ? "Add supporting portfolio material now, or select lightweight profile-linked projects later." : null} {activeStep === 5 ? "Certificates, references, and other supporting documents are optional." : null} {activeStep === 2 ? <> setGenerateTailoredCv(event.target.checked)} />} label={canUseAi ? "Generate a tailored CV draft after creating this job" : "Tailored CV generation requires Pro"} /> {canUseAi ? "Uses your reviewed Career Profile and keeps the result as an editable suggestion." : "Create and track the job normally; no AI operation will be started."} {uploadField("resume", t("addJobModalResume"), t("addJobModalResumeHelp"))} : null} {activeStep === 3 ? uploadField("coverLetter", t("addJobModalCoverLetter"), t("addJobModalCoverLetterHelp")) : null} {activeStep === 4 ? uploadField("portfolio", t("addJobModalPortfolio"), t("addJobModalPortfolioHelp")) : null} {activeStep === 5 ? uploadField("other", t("addJobModalOtherFiles"), t("addJobModalOtherFilesHelp")) : null} : null} {activeStep > 0 ? : null} {activeStep < steps.length - 1 ? : <> } ); }