First Commit
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import { Company, JobImportResult } from "../types";
|
||||
import { invalidateCompaniesCache, useCompanies } from "../hooks/useCompanies";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import TagsInput from "./TagsInput";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
|
||||
export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
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(() => new Date().toISOString().slice(0, 10));
|
||||
const [jobTitle, setJobTitle] = useState("");
|
||||
const [status, setStatus] = useState<(typeof STATUS_OPTIONS)[number]>("Applied");
|
||||
const [location, setLocation] = useState("");
|
||||
const [salary, setSalary] = useState("");
|
||||
const [nextAction, setNextAction] = useState("");
|
||||
const [followUpAt, setFollowUpAt] = 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 [coverLetter, setCoverLetter] = useState("");
|
||||
|
||||
const [hasResume, setHasResume] = useState(false);
|
||||
const [hasCoverLetter, setHasCoverLetter] = useState(false);
|
||||
const [hasPortfolio, setHasPortfolio] = useState(false);
|
||||
const [hasOtherAttachment, setHasOtherAttachment] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setCompanies(cachedCompanies);
|
||||
}, [cachedCompanies]);
|
||||
|
||||
const normalizedCompanyName = companyInput.trim();
|
||||
const matchingCompany = useMemo(() => {
|
||||
if (!normalizedCompanyName) return null;
|
||||
return companies.find((c) => c.name.toLowerCase() === normalizedCompanyName.toLowerCase()) ?? null;
|
||||
}, [companies, normalizedCompanyName]);
|
||||
|
||||
const showNewCompanyFields = !company && !!normalizedCompanyName && !matchingCompany;
|
||||
|
||||
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 {
|
||||
toast("Failed to create company.", "error");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const importFromUrl = async () => {
|
||||
if (importing) return;
|
||||
if (!jobUrl.trim()) {
|
||||
toast("Paste a job URL first.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const res = await api.post<JobImportResult>("/jobimport/preview", { url: jobUrl.trim() });
|
||||
const r = res.data;
|
||||
if (!r?.success) throw new Error(r?.error || "Import failed");
|
||||
|
||||
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("Imported.", "success");
|
||||
} catch (e: any) {
|
||||
toast(e?.message || "Import failed.", "error");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createJob = async () => {
|
||||
if (saving) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
let selectedCompany = company ?? matchingCompany;
|
||||
if (!selectedCompany && normalizedCompanyName) {
|
||||
selectedCompany = await createCompany();
|
||||
}
|
||||
if (!selectedCompany) {
|
||||
toast("Select or create a company.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
await api.post("/jobapplications", {
|
||||
jobTitle,
|
||||
companyId: selectedCompany.id,
|
||||
status,
|
||||
location,
|
||||
salary,
|
||||
nextAction,
|
||||
followUpAt: followUpAt || null,
|
||||
jobUrl,
|
||||
description: description || null,
|
||||
translatedDescription: translatedDescription || null,
|
||||
descriptionLanguage: descriptionLanguage || null,
|
||||
tags: tags.length ? JSON.stringify(tags) : null,
|
||||
deadline: deadline || null,
|
||||
notes,
|
||||
coverLetterText: coverLetter,
|
||||
dateApplied,
|
||||
hasResume,
|
||||
hasCoverLetter,
|
||||
hasPortfolio,
|
||||
hasOtherAttachment,
|
||||
});
|
||||
|
||||
onCreated();
|
||||
onClose();
|
||||
toast("Job added.", "success");
|
||||
} catch {
|
||||
toast("Failed to add job.", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canSave = normalizedCompanyName.length > 0 && jobTitle.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||
<DialogTitle>{t("addJob")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="overline" sx={{ display: "block", mt: 1 }}>
|
||||
Company
|
||||
</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: "1fr 1fr", gap: 2 }}>
|
||||
<TextField
|
||||
label="Company location"
|
||||
value={newCompanyLocation}
|
||||
onChange={(e) => setNewCompanyLocation(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Company source"
|
||||
value={newCompanySource}
|
||||
onChange={(e) => setNewCompanySource(e.target.value)}
|
||||
/>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Button variant="outlined" onClick={() => void createCompany()}>
|
||||
Create "{normalizedCompanyName}"
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="overline" sx={{ display: "block" }}>
|
||||
Job application
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
label="Job URL"
|
||||
value={jobUrl}
|
||||
onChange={(e) => setJobUrl(e.target.value)}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button onClick={() => void importFromUrl()} disabled={importing || !jobUrl.trim()}>
|
||||
{importing ? "Importing..." : "Import from URL"}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label="Date applied"
|
||||
type="date"
|
||||
value={dateApplied}
|
||||
onChange={(e) => setDateApplied(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
|
||||
<TextField select label="Status" value={status} onChange={(e) => setStatus(e.target.value as any)}>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{s}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField label="Job title" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
|
||||
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||
|
||||
<TextField label="Salary" value={salary} onChange={(e) => setSalary(e.target.value)} />
|
||||
<TextField label="Next action" value={nextAction} onChange={(e) => setNextAction(e.target.value)} />
|
||||
|
||||
<TextField
|
||||
label="Follow up"
|
||||
type="date"
|
||||
value={followUpAt}
|
||||
onChange={(e) => setFollowUpAt(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Deadline"
|
||||
type="date"
|
||||
value={deadline}
|
||||
onChange={(e) => setDeadline(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<TagsInput value={tags} onChange={setTags} />
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label="Description (original)"
|
||||
multiline
|
||||
rows={6}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Translated description"
|
||||
multiline
|
||||
rows={6}
|
||||
value={translatedDescription}
|
||||
onChange={(e) => setTranslatedDescription(e.target.value)}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Description language (optional)"
|
||||
value={descriptionLanguage}
|
||||
onChange={(e) => setDescriptionLanguage(e.target.value)}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Notes"
|
||||
multiline
|
||||
rows={3}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Cover letter"
|
||||
multiline
|
||||
rows={6}
|
||||
value={coverLetter}
|
||||
onChange={(e) => setCoverLetter(e.target.value)}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="overline" sx={{ display: "block", mt: 1 }}>
|
||||
Attachments checklist
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||
<FormControlLabel control={<Checkbox checked={hasResume} onChange={(e) => setHasResume(e.target.checked)} />} label="Resume" />
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={hasCoverLetter} onChange={(e) => setHasCoverLetter(e.target.checked)} />}
|
||||
label="Cover letter"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={hasPortfolio} onChange={(e) => setHasPortfolio(e.target.checked)} />}
|
||||
label="Portfolio"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={hasOtherAttachment} onChange={(e) => setHasOtherAttachment(e.target.checked)} />}
|
||||
label="Other"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", mt: 1 }}>
|
||||
<Button variant="contained" onClick={() => void createJob()} disabled={saving || !canSave}>
|
||||
{saving ? "Adding..." : "Add job"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user