42ba306362
Repo-wide sweep for the same flat 1px-border "fake card" pattern already fixed in Dashboard/Kanban/JobDetailsDialog/auth pages this session -- AddJobModal, Attachments, CompaniesTable, Correspondence, EditJobDialog, and the admin audit/system/users pages all had a table container or content box using border+divider instead of the floating-shadow treatment used everywhere else now. Left AppShell.tsx/App.tsx alone -- their border:1px+divider instances are icon-button and badge outlines, not card containers; that's a different, correct use of the pattern.
576 lines
22 KiB
TypeScript
576 lines
22 KiB
TypeScript
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<AttachmentBucketKey, File[]>;
|
|
|
|
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<DuplicateCheckResult | null>(null);
|
|
|
|
const { companies: cachedCompanies } = useCompanies();
|
|
const [companies, setCompanies] = useState<Company[]>([]);
|
|
|
|
const [company, setCompany] = useState<Company | null>(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<string[]>([]);
|
|
const [notes, setNotes] = useState("");
|
|
const [attachments, setAttachments] = useState<AttachmentBuckets>(() => emptyAttachmentBuckets());
|
|
|
|
useEffect(() => {
|
|
setCompanies(cachedCompanies);
|
|
}, [cachedCompanies]);
|
|
|
|
// Quick-capture: when opened with a URL (from the bookmarklet), prefill and auto-import once.
|
|
const autoImportedUrlRef = useRef<string | null>(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<DuplicateCheckResult>("/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<Company | null> => {
|
|
if (!normalizedCompanyName) return null;
|
|
|
|
const payload: Partial<Company> = { name: normalizedCompanyName };
|
|
if (newCompanyLocation.trim()) payload.location = newCompanyLocation.trim();
|
|
if (newCompanySource.trim()) payload.source = newCompanySource.trim();
|
|
|
|
try {
|
|
const res = await api.post<Company>("/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<JobImportResult>("/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<CreatedJobResponse>("/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,
|
|
) => (
|
|
<Box sx={{ p: 1.5, borderRadius: 2, 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" }}>
|
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
|
<Box>
|
|
<Typography sx={{ fontWeight: 800 }}>{label}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{helperText}</Typography>
|
|
</Box>
|
|
<Button component="label" variant="outlined" size="small" startIcon={<UploadFileOutlinedIcon />}>
|
|
{t("addJobModalChooseFiles")}
|
|
<input hidden type="file" multiple accept={ACCEPTED_DOCUMENT_TYPES} onChange={(e) => setFilesForBucket(bucket, e.target.files)} />
|
|
</Button>
|
|
</Box>
|
|
<Typography variant="caption" sx={{ display: "block", mt: 1, color: "text.secondary" }}>
|
|
{filesLabel(attachments[bucket])}
|
|
</Typography>
|
|
</Box>
|
|
);
|
|
|
|
return (
|
|
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
|
<DialogTitle sx={{ pr: 6 }}>
|
|
{t("addJob")}
|
|
<IconButton aria-label={t("close")} onClick={onClose} sx={{ position: "absolute", right: 12, top: 12 }}>
|
|
<CloseIcon />
|
|
</IconButton>
|
|
</DialogTitle>
|
|
<DialogContent dividers>
|
|
<Typography variant="overline" sx={{ display: "block", mt: 0.5 }}>
|
|
{t("addJobModalCompanySection")}
|
|
</Typography>
|
|
|
|
<Autocomplete<Company, false, false, true>
|
|
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) => <TextField {...params} label={t("company")} />}
|
|
/>
|
|
|
|
{showNewCompanyFields ? (
|
|
<Box sx={{ mt: 1, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
|
<TextField label={t("addJobModalCompanyLocation")} value={newCompanyLocation} onChange={(e) => setNewCompanyLocation(e.target.value)} />
|
|
<TextField label={t("addJobModalCompanySource")} value={newCompanySource} onChange={(e) => setNewCompanySource(e.target.value)} />
|
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
|
<Button variant="outlined" onClick={() => void createCompany()}>
|
|
{t("addJobModalCreateCompany", { name: normalizedCompanyName })}
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
) : null}
|
|
|
|
{duplicateCheck?.hasDuplicates ? (
|
|
<Alert severity="warning" sx={{ mt: 2 }}>
|
|
<Typography sx={{ fontWeight: 800, mb: 0.75 }}>{t("addJobModalPossibleDuplicates")}</Typography>
|
|
<List dense sx={{ py: 0 }}>
|
|
{duplicateCheck.matches.map((match) => (
|
|
<ListItem key={match.id} sx={{ px: 0 }}>
|
|
<ListItemText
|
|
primary={`${match.company} - ${match.jobTitle}`}
|
|
secondary={`${match.reason} • ${match.status} • ${new Date(match.dateApplied).toLocaleDateString()}`}
|
|
/>
|
|
</ListItem>
|
|
))}
|
|
</List>
|
|
</Alert>
|
|
) : null}
|
|
|
|
<Divider sx={{ my: 2 }} />
|
|
|
|
<Typography variant="overline" sx={{ display: "block" }}>
|
|
{t("addJobModalJobApplicationSection")}
|
|
</Typography>
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
|
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} sx={{ ...FIELD_SX, gridColumn: "1 / -1" }} />
|
|
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
|
|
<Button onClick={() => void importFromUrl()} disabled={importing || !jobUrl.trim()}>
|
|
{importing ? t("addJobModalImporting") : t("addJobModalImportFromUrl")}
|
|
</Button>
|
|
</Box>
|
|
|
|
<DatePicker
|
|
label={t("addJobModalDateApplied")}
|
|
value={parsePickerDate(dateApplied)}
|
|
onChange={(value) => setDateApplied(toPickerIso(value))}
|
|
slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }}
|
|
/>
|
|
|
|
<TextField select label={t("addJobModalStatus")} value={status} onChange={(e) => setStatus(e.target.value as any)} sx={FIELD_SX}>
|
|
{PIPELINE_STATUSES.map((s) => (
|
|
<MenuItem key={s} value={s}>
|
|
{pipelineStatusLabel(t, s)}
|
|
</MenuItem>
|
|
))}
|
|
</TextField>
|
|
|
|
<TextField label={t("addJobModalJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} sx={FIELD_SX} />
|
|
<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("addJobModalDeadline")}
|
|
value={parsePickerDate(deadline)}
|
|
onChange={(value) => setDeadline(toPickerIso(value))}
|
|
slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }}
|
|
/>
|
|
|
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
|
<TagsInput value={tags} onChange={setTags} />
|
|
</Box>
|
|
|
|
<TextField
|
|
label={t("addJobModalDescriptionOriginal")}
|
|
multiline
|
|
rows={6}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
helperText={`${description.length} characters`}
|
|
sx={{ gridColumn: "1 / -1" }}
|
|
/>
|
|
|
|
{shouldShowTranslatedDescription ? (
|
|
<TextField
|
|
label={t("addJobModalTranslatedDescription", { language: preferredLanguage.toUpperCase() })}
|
|
multiline
|
|
rows={6}
|
|
value={translatedDescription}
|
|
onChange={(e) => setTranslatedDescription(e.target.value)}
|
|
helperText={`${translatedDescription.length} characters`}
|
|
sx={{ gridColumn: "1 / -1" }}
|
|
/>
|
|
) : null}
|
|
|
|
<TextField
|
|
label={t("addJobModalDescriptionLanguage")}
|
|
value={descriptionLanguage}
|
|
onChange={(e) => setDescriptionLanguage(e.target.value)}
|
|
helperText={shouldShowTranslatedDescription ? t("addJobModalTranslatedShown", { language: preferredLanguage.toUpperCase() }) : t("addJobModalTranslatedHidden")}
|
|
sx={{ gridColumn: "1 / -1" }}
|
|
/>
|
|
|
|
<TextField label={t("addJobModalNotes")} multiline rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} helperText={`${notes.length} characters`} sx={{ gridColumn: "1 / -1" }} />
|
|
|
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
|
<Typography variant="overline" sx={{ display: "block", mb: 1 }}>{t("addJobModalDocuments")}</Typography>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
|
{uploadField("resume", t("addJobModalResume"), t("addJobModalResumeHelp"))}
|
|
{uploadField("coverLetter", t("addJobModalCoverLetter"), t("addJobModalCoverLetterHelp"))}
|
|
{uploadField("portfolio", t("addJobModalPortfolio"), t("addJobModalPortfolioHelp"))}
|
|
{uploadField("other", t("addJobModalOtherFiles"), t("addJobModalOtherFilesHelp"))}
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1.25 }}>
|
|
<Chip size="small" variant="outlined" label={attachmentCount === 1 ? t("addJobModalFileReady", { count: attachmentCount }) : t("addJobModalFilesReady", { count: attachmentCount })} />
|
|
<Chip size="small" variant="outlined" label={t("addJobModalPreferredFiles")} />
|
|
<Chip size="small" variant="outlined" label={t("addJobModalTextImageAllowed")} />
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</DialogContent>
|
|
<DialogActions sx={{ px: 3, py: 2, justifyContent: "space-between", flexWrap: "wrap", gap: 1.5 }}>
|
|
<Button variant="outlined" onClick={onClose}>{t("close")}</Button>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
<Button variant="outlined" onClick={() => void createJob(true)} disabled={saving || !canSave}>
|
|
{saving ? t("rulesSaving") : t("createAndAddAnother")}
|
|
</Button>
|
|
<Button variant="contained" onClick={() => void createJob(false)} disabled={saving || !canSave}>
|
|
{saving ? t("rulesSaving") : t("createJob")}
|
|
</Button>
|
|
</Box>
|
|
</DialogActions>
|
|
</Dialog>
|
|
);
|
|
}
|