Dashboard upgrades, workflows added and assitant emailer
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
@@ -10,6 +11,9 @@ import {
|
||||
DialogTitle,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Typography,
|
||||
@@ -28,6 +32,21 @@ interface Props {
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
type DuplicateCandidate = {
|
||||
id: number;
|
||||
jobTitle: string;
|
||||
company: string;
|
||||
jobUrl?: string | null;
|
||||
status: string;
|
||||
dateApplied: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type DuplicateCheckResult = {
|
||||
hasDuplicates: boolean;
|
||||
matches: DuplicateCandidate[];
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
|
||||
function getTodayIso() {
|
||||
@@ -40,6 +59,8 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [saveAndAddAnother, setSaveAndAddAnother] = useState(false);
|
||||
const [duplicateCheck, setDuplicateCheck] = useState<DuplicateCheckResult | null>(null);
|
||||
|
||||
const { companies: cachedCompanies } = useCompanies();
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
@@ -101,6 +122,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
setHasCoverLetter(false);
|
||||
setHasPortfolio(false);
|
||||
setHasOtherAttachment(false);
|
||||
setDuplicateCheck(null);
|
||||
};
|
||||
|
||||
const normalizedCompanyName = companyInput.trim();
|
||||
@@ -109,8 +131,39 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
return companies.find((c) => c.name.toLowerCase() === normalizedCompanyName.toLowerCase()) ?? null;
|
||||
}, [companies, normalizedCompanyName]);
|
||||
|
||||
const selectedCompanyId = company?.id ?? matchingCompany?.id ?? 0;
|
||||
const showNewCompanyFields = !company && !!normalizedCompanyName && !matchingCompany;
|
||||
|
||||
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;
|
||||
|
||||
@@ -209,10 +262,12 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
hasOtherAttachment,
|
||||
});
|
||||
|
||||
resetForm();
|
||||
onCreated();
|
||||
onClose();
|
||||
toast("Job added.", "success");
|
||||
resetForm();
|
||||
if (!saveAndAddAnother) {
|
||||
onClose();
|
||||
}
|
||||
} catch {
|
||||
toast("Failed to add job.", "error");
|
||||
} finally {
|
||||
@@ -258,16 +313,8 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
|
||||
{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)}
|
||||
/>
|
||||
<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}"
|
||||
@@ -276,6 +323,22 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{duplicateCheck?.hasDuplicates ? (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
<Typography sx={{ fontWeight: 800, mb: 0.75 }}>Possible duplicates found</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" }}>
|
||||
@@ -283,25 +346,14 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
</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" }}
|
||||
/>
|
||||
<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 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) => (
|
||||
@@ -317,94 +369,41 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
<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="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 }}
|
||||
/>
|
||||
<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" }}
|
||||
/>
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
<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 sx={{ gridColumn: "1 / -1", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, mt: 1, flexWrap: "wrap" }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={saveAndAddAnother} onChange={(e) => setSaveAndAddAnother(e.target.checked)} />}
|
||||
label="Save and add another"
|
||||
/>
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button variant="outlined" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={() => void createJob()} disabled={saving || !canSave}>
|
||||
{saving ? "Adding..." : saveAndAddAnother ? "Save and continue" : "Add job"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user