Polish UI, harden company creation, and add error pages
This commit is contained in:
@@ -5,12 +5,13 @@ import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
@@ -19,7 +20,10 @@ import {
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
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";
|
||||
@@ -47,19 +51,43 @@ type DuplicateCheckResult = {
|
||||
matches: DuplicateCandidate[];
|
||||
};
|
||||
|
||||
type CreatedJobResponse = {
|
||||
id?: number;
|
||||
};
|
||||
|
||||
type AttachmentBucketKey = "resume" | "coverLetter" | "portfolio" | "other";
|
||||
type AttachmentBuckets = Record<AttachmentBucketKey, File[]>;
|
||||
|
||||
const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
const ACCEPTED_DOCUMENT_TYPES = ".pdf,.doc,.docx,.txt,.md,image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
|
||||
|
||||
function getTodayIso() {
|
||||
return new Date().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 }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { t, language } = useI18n();
|
||||
|
||||
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();
|
||||
@@ -75,8 +103,6 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
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("");
|
||||
|
||||
@@ -85,14 +111,8 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
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);
|
||||
const [attachments, setAttachments] = useState<AttachmentBuckets>(() => emptyAttachmentBuckets());
|
||||
|
||||
useEffect(() => {
|
||||
setCompanies(cachedCompanies);
|
||||
@@ -108,8 +128,6 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
setStatus("Applied");
|
||||
setLocation("");
|
||||
setSalary("");
|
||||
setNextAction("");
|
||||
setFollowUpAt("");
|
||||
setJobUrl("");
|
||||
setDeadline("");
|
||||
setDescription("");
|
||||
@@ -117,11 +135,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
setDescriptionLanguage("");
|
||||
setTags([]);
|
||||
setNotes("");
|
||||
setCoverLetter("");
|
||||
setHasResume(false);
|
||||
setHasCoverLetter(false);
|
||||
setHasPortfolio(false);
|
||||
setHasOtherAttachment(false);
|
||||
setAttachments(emptyAttachmentBuckets());
|
||||
setDuplicateCheck(null);
|
||||
};
|
||||
|
||||
@@ -133,6 +147,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
|
||||
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;
|
||||
@@ -180,8 +198,8 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
setNewCompanyLocation("");
|
||||
setNewCompanySource("");
|
||||
return res.data;
|
||||
} catch {
|
||||
toast("Failed to create company.", "error");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, t("addJobModalFailedCreateCompany")), "error");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -189,7 +207,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
const importFromUrl = async () => {
|
||||
if (importing) return;
|
||||
if (!jobUrl.trim()) {
|
||||
toast("Paste a job URL first.", "warning");
|
||||
toast(t("addJobModalPasteUrlFirst"), "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -197,7 +215,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
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?.success) throw new Error(r?.error || t("addJobModalImportFailed"));
|
||||
|
||||
if (r.title) setJobTitle(r.title);
|
||||
if (r.location) setLocation(r.location);
|
||||
@@ -217,15 +235,28 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
setTags(r.tags || []);
|
||||
setDeadline(r.deadline ? r.deadline.slice(0, 10) : "");
|
||||
|
||||
toast("Imported.", "success");
|
||||
toast(t("addJobModalImported"), "success");
|
||||
} catch (e: any) {
|
||||
toast(e?.message || "Import failed.", "error");
|
||||
toast(e?.message || t("addJobModalImportFailed"), "error");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createJob = async () => {
|
||||
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);
|
||||
@@ -235,41 +266,53 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
selectedCompany = await createCompany();
|
||||
}
|
||||
if (!selectedCompany) {
|
||||
toast("Select or create a company.", "warning");
|
||||
toast(t("addJobModalSelectCompany"), "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
await api.post("/jobapplications", {
|
||||
const response = await api.post<CreatedJobResponse>("/jobapplications", {
|
||||
jobTitle,
|
||||
companyId: selectedCompany.id,
|
||||
status,
|
||||
location,
|
||||
salary,
|
||||
nextAction,
|
||||
followUpAt: followUpAt || null,
|
||||
nextAction: null,
|
||||
followUpAt: null,
|
||||
jobUrl,
|
||||
description: description || null,
|
||||
translatedDescription: translatedDescription || null,
|
||||
translatedDescription: shouldShowTranslatedDescription ? translatedDescription || null : null,
|
||||
descriptionLanguage: descriptionLanguage || null,
|
||||
tags: tags.length ? JSON.stringify(tags) : null,
|
||||
deadline: deadline || null,
|
||||
notes,
|
||||
coverLetterText: coverLetter,
|
||||
coverLetterText: null,
|
||||
dateApplied,
|
||||
hasResume,
|
||||
hasCoverLetter,
|
||||
hasPortfolio,
|
||||
hasOtherAttachment,
|
||||
hasResume: attachments.resume.length > 0,
|
||||
hasCoverLetter: attachments.coverLetter.length > 0,
|
||||
hasPortfolio: attachments.portfolio.length > 0,
|
||||
hasOtherAttachment: attachments.other.length > 0,
|
||||
});
|
||||
|
||||
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();
|
||||
toast("Job added.", "success");
|
||||
resetForm();
|
||||
if (!saveAndAddAnother) {
|
||||
if (!addAnother) {
|
||||
onClose();
|
||||
}
|
||||
} catch {
|
||||
toast("Failed to add job.", "error");
|
||||
toast(t("addJobModalFailedAddJob"), "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -277,12 +320,64 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
|
||||
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 statusLabel = (value: typeof STATUS_OPTIONS[number]) => {
|
||||
const map = {
|
||||
Applied: t("statusApplied"),
|
||||
Waiting: t("statusWaiting"),
|
||||
Interview: t("statusInterview"),
|
||||
Offer: t("statusOffer"),
|
||||
Rejected: t("statusRejected"),
|
||||
Ghosted: t("statusGhosted"),
|
||||
} as const;
|
||||
return map[value];
|
||||
};
|
||||
|
||||
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: "1px solid", borderColor: "divider", 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>{t("addJob")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="overline" sx={{ display: "block", mt: 1 }}>
|
||||
Company
|
||||
<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>
|
||||
@@ -312,12 +407,12 @@ 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)} />
|
||||
<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()}>
|
||||
Create "{normalizedCompanyName}"
|
||||
{t("addJobModalCreateCompany", { name: normalizedCompanyName })}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -325,7 +420,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
|
||||
{duplicateCheck?.hasDuplicates ? (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
<Typography sx={{ fontWeight: 800, mb: 0.75 }}>Possible duplicates found</Typography>
|
||||
<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 }}>
|
||||
@@ -342,68 +437,96 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="overline" sx={{ display: "block" }}>
|
||||
Job application
|
||||
{t("addJobModalJobApplicationSection")}
|
||||
</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={{ 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={{ 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"}
|
||||
{importing ? t("addJobModalImporting") : t("addJobModalImportFromUrl")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TextField label="Date applied" type="date" value={dateApplied} onChange={(e) => setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} />
|
||||
<TextField label={t("addJobModalDateApplied")} 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)}>
|
||||
<TextField select label={t("addJobModalStatus")} value={status} onChange={(e) => setStatus(e.target.value as any)}>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{s}
|
||||
{statusLabel(s)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField label="Job title" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
|
||||
<TextField label={t("addJobModalJobTitle")} 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 }} />
|
||||
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} />
|
||||
<TextField label={t("addJobModalDeadline")} 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)} helperText={`${description.length} characters`} sx={{ gridColumn: "1 / -1" }} />
|
||||
<TextField label="Translated description" multiline rows={6} value={translatedDescription} onChange={(e) => setTranslatedDescription(e.target.value)} helperText={`${translatedDescription.length} characters`} 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)} helperText={`${notes.length} characters`} sx={{ gridColumn: "1 / -1" }} />
|
||||
<TextField label="Cover letter" multiline rows={6} value={coverLetter} onChange={(e) => setCoverLetter(e.target.value)} helperText={`${coverLetter.length} characters`} sx={{ gridColumn: "1 / -1" }} />
|
||||
<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", 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" />
|
||||
<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>
|
||||
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,13 +19,15 @@ import {
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { Company } from "../types";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
export default function CompaniesTable() {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
@@ -40,8 +42,8 @@ export default function CompaniesTable() {
|
||||
const [nextContactAt, setNextContactAt] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Company[]>("/companies").then((r) => setCompanies(r.data));
|
||||
}, []);
|
||||
api.get<Company[]>("/companies").then((r) => setCompanies(r.data)).catch((error) => toast(getApiErrorMessage(error, t("companiesUpdateFailed")), "error"));
|
||||
}, [t, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
@@ -83,11 +85,11 @@ export default function CompaniesTable() {
|
||||
});
|
||||
|
||||
setCompanies((prev) => prev.map((x) => (x.id === res.data.id ? res.data : x)));
|
||||
toast("Company updated.", "success");
|
||||
toast(t("companiesUpdated"), "success");
|
||||
setEditOpen(false);
|
||||
setEditing(null);
|
||||
} catch {
|
||||
toast("Failed to update company.", "error");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("companiesUpdateFailed")), "error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,12 +98,12 @@ export default function CompaniesTable() {
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Location</TableCell>
|
||||
<TableCell>Source</TableCell>
|
||||
<TableCell>Pipeline</TableCell>
|
||||
<TableCell>Recruiter</TableCell>
|
||||
<TableCell>Next Contact</TableCell>
|
||||
<TableCell>{t("companiesName")}</TableCell>
|
||||
<TableCell>{t("companiesLocation")}</TableCell>
|
||||
<TableCell>{t("companiesSource")}</TableCell>
|
||||
<TableCell>{t("companiesPipeline")}</TableCell>
|
||||
<TableCell>{t("companiesRecruiter")}</TableCell>
|
||||
<TableCell>{t("companiesNextContact")}</TableCell>
|
||||
<TableCell width={1} align="right" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@@ -128,7 +130,7 @@ export default function CompaniesTable() {
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<Typography sx={{ py: 2, textAlign: "center" }}>
|
||||
No companies yet.
|
||||
{t("companiesEmpty")}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -137,57 +139,57 @@ export default function CompaniesTable() {
|
||||
</Table>
|
||||
|
||||
<Dialog open={editOpen} onClose={() => setEditOpen(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Edit Company</DialogTitle>
|
||||
<DialogTitle>{t("companiesEdit")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
label="Name"
|
||||
label={t("companiesName")}
|
||||
value={editing?.name ?? ""}
|
||||
onChange={(e) => setEditing((p) => (p ? { ...p, name: e.target.value } : p))}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
<TextField
|
||||
label="Location"
|
||||
label={t("companiesLocation")}
|
||||
value={editing?.location ?? ""}
|
||||
onChange={(e) => setEditing((p) => (p ? { ...p, location: e.target.value } : p))}
|
||||
/>
|
||||
<TextField
|
||||
label="Source"
|
||||
label={t("companiesSource")}
|
||||
value={editing?.source ?? ""}
|
||||
onChange={(e) => setEditing((p) => (p ? { ...p, source: e.target.value } : p))}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Pipeline stage"
|
||||
label={t("companiesPipelineStage")}
|
||||
value={pipelineStage}
|
||||
onChange={(e) => setPipelineStage(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Recruiter name"
|
||||
label={t("companiesRecruiterName")}
|
||||
value={recruiterName}
|
||||
onChange={(e) => setRecruiterName(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Recruiter email"
|
||||
label={t("companiesRecruiterEmail")}
|
||||
value={recruiterEmail}
|
||||
onChange={(e) => setRecruiterEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Recruiter LinkedIn"
|
||||
label={t("companiesRecruiterLinkedIn")}
|
||||
value={recruiterLinkedIn}
|
||||
onChange={(e) => setRecruiterLinkedIn(e.target.value)}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Last contacted"
|
||||
label={t("companiesLastContacted")}
|
||||
type="date"
|
||||
value={lastContactedAt}
|
||||
onChange={(e) => setLastContactedAt(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
label="Next contact"
|
||||
label={t("companiesNextContactField")}
|
||||
type="date"
|
||||
value={nextContactAt}
|
||||
onChange={(e) => setNextContactAt(e.target.value)}
|
||||
@@ -196,9 +198,9 @@ export default function CompaniesTable() {
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setEditOpen(false)}>Cancel</Button>
|
||||
<Button onClick={() => setEditOpen(false)}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={save} disabled={!canSave}>
|
||||
Save
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Slider,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
const CROPPER_SIZE = 280;
|
||||
const OUTPUT_SIZE = 512;
|
||||
|
||||
type DragState = {
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export default function CropImageDialog({
|
||||
open,
|
||||
file,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
file: File | null;
|
||||
onClose: () => void;
|
||||
onSave: (blob: Blob) => Promise<void> | void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file || !open) {
|
||||
setImageUrl(null);
|
||||
setImageSize(null);
|
||||
setZoom(1);
|
||||
setPosition({ x: 0, y: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
setImageUrl(url);
|
||||
setImageSize(null);
|
||||
setZoom(1);
|
||||
setPosition({ x: 0, y: 0 });
|
||||
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file, open]);
|
||||
|
||||
const rendered = useMemo(() => {
|
||||
if (!imageSize) return null;
|
||||
const scale = Math.max(CROPPER_SIZE / imageSize.width, CROPPER_SIZE / imageSize.height) * zoom;
|
||||
const width = imageSize.width * scale;
|
||||
const height = imageSize.height * scale;
|
||||
return { scale, width, height };
|
||||
}, [imageSize, zoom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rendered) return;
|
||||
const minX = Math.min(0, CROPPER_SIZE - rendered.width);
|
||||
const minY = Math.min(0, CROPPER_SIZE - rendered.height);
|
||||
setPosition((prev) => ({
|
||||
x: clamp(prev.x, minX, 0),
|
||||
y: clamp(prev.y, minY, 0),
|
||||
}));
|
||||
}, [rendered]);
|
||||
|
||||
const beginDrag = (clientX: number, clientY: number) => {
|
||||
dragRef.current = {
|
||||
startX: clientX,
|
||||
startY: clientY,
|
||||
originX: position.x,
|
||||
originY: position.y,
|
||||
};
|
||||
};
|
||||
|
||||
const moveDrag = useCallback((clientX: number, clientY: number) => {
|
||||
if (!dragRef.current || !rendered) return;
|
||||
const minX = Math.min(0, CROPPER_SIZE - rendered.width);
|
||||
const minY = Math.min(0, CROPPER_SIZE - rendered.height);
|
||||
const nextX = dragRef.current.originX + (clientX - dragRef.current.startX);
|
||||
const nextY = dragRef.current.originY + (clientY - dragRef.current.startY);
|
||||
setPosition({ x: clamp(nextX, minX, 0), y: clamp(nextY, minY, 0) });
|
||||
}, [rendered]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseMove = (event: MouseEvent) => moveDrag(event.clientX, event.clientY);
|
||||
const onTouchMove = (event: TouchEvent) => {
|
||||
const touch = event.touches[0];
|
||||
if (touch) moveDrag(touch.clientX, touch.clientY);
|
||||
};
|
||||
const stop = () => {
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", stop);
|
||||
window.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
window.addEventListener("touchend", stop);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", stop);
|
||||
window.removeEventListener("touchmove", onTouchMove);
|
||||
window.removeEventListener("touchend", stop);
|
||||
};
|
||||
}, [moveDrag]);
|
||||
|
||||
const exportCrop = async () => {
|
||||
if (!file || !rendered) return;
|
||||
const image = imgRef.current;
|
||||
if (!image) return;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = OUTPUT_SIZE;
|
||||
canvas.height = OUTPUT_SIZE;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const cropX = -position.x / rendered.scale;
|
||||
const cropY = -position.y / rendered.scale;
|
||||
const cropWidth = CROPPER_SIZE / rendered.scale;
|
||||
const cropHeight = CROPPER_SIZE / rendered.scale;
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
ctx.drawImage(image, cropX, cropY, cropWidth, cropHeight, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE);
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png", 0.95));
|
||||
if (!blob) return;
|
||||
await onSave(blob);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={saving ? undefined : onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{t("cropDialogTitle")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
|
||||
{t("cropDialogBody")}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: CROPPER_SIZE,
|
||||
height: CROPPER_SIZE,
|
||||
borderRadius: "50%",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
bgcolor: "grey.100",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
cursor: "grab",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
boxShadow: "inset 0 0 0 999px rgba(0,0,0,0.03)",
|
||||
}}
|
||||
onMouseDown={(e) => beginDrag(e.clientX, e.clientY)}
|
||||
onTouchStart={(e) => {
|
||||
const touch = e.touches[0];
|
||||
if (touch) beginDrag(touch.clientX, touch.clientY);
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={imageUrl}
|
||||
alt="Crop preview"
|
||||
onLoad={(e) => {
|
||||
const target = e.currentTarget;
|
||||
setImageSize({ width: target.naturalWidth, height: target.naturalHeight });
|
||||
}}
|
||||
draggable={false}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: rendered?.width ?? "auto",
|
||||
height: rendered?.height ?? "auto",
|
||||
maxWidth: "none",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontWeight: 700 }}>{t("cropDialogZoom")}</Typography>
|
||||
<Slider min={1} max={3} step={0.01} value={zoom} onChange={(_, value) => setZoom(value as number)} />
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={saving}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={() => void exportCrop()} disabled={!file || saving || !rendered}>
|
||||
{saving ? t("profileUploading") : t("cropDialogSave")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Checkbox,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { alpha, useTheme } from "@mui/material/styles";
|
||||
@@ -18,6 +14,7 @@ import TuneIcon from "@mui/icons-material/Tune";
|
||||
|
||||
import { api } from "../api";
|
||||
import { getUserKeyFromToken } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
interface JobStats {
|
||||
total: number;
|
||||
@@ -36,24 +33,6 @@ type ReminderJob = {
|
||||
|
||||
type AnalyticsPoint = { month: string; applied: number; responses: number };
|
||||
type TagPoint = { tag: string; count: number };
|
||||
type SummarizerMetrics = {
|
||||
healthy: boolean;
|
||||
model?: string | null;
|
||||
healthLatencyMs?: number | null;
|
||||
probeLatencyMs?: number | null;
|
||||
lastProbeAt?: string | null;
|
||||
lastProbeSuccessAt?: string | null;
|
||||
lastProbeFailureAt?: string | null;
|
||||
probeFailures: number;
|
||||
requests: number;
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
failures: number;
|
||||
averageLatencyMs?: number | null;
|
||||
lastSuccessAt?: string | null;
|
||||
lastFailureAt?: string | null;
|
||||
lastError?: string | null;
|
||||
};
|
||||
type OverviewAnalytics = {
|
||||
funnel: { label: string; count: number }[];
|
||||
responseRateBySource: { label: string; total: number; responses: number; rate: number }[];
|
||||
@@ -106,32 +85,15 @@ function toPath(values: number[], w: number, h: number) {
|
||||
return values.map((v, i) => `${i === 0 ? "M" : "L"} ${Math.round(i * dx)} ${Math.round(norm(v))}`).join(" ");
|
||||
}
|
||||
|
||||
function formatRelative(ts?: string | null) {
|
||||
if (!ts) return "Never";
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) return "Unknown";
|
||||
const mins = Math.round((Date.now() - d.getTime()) / 60000);
|
||||
if (mins < 1) return "Just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.round(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
export default function DashboardView() {
|
||||
const theme = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [stats, setStats] = useState<JobStats | null>(null);
|
||||
const [overview, setOverview] = useState<OverviewAnalytics | null>(null);
|
||||
const [tagTrends, setTagTrends] = useState<TagTrendResponse | null>(null);
|
||||
const [tab, setTab] = useState(0);
|
||||
const [rangeMode, setRangeMode] = useState<"preset" | "custom">("preset");
|
||||
const [months, setMonths] = useState<6 | 12 | 24>(12);
|
||||
const [fromMonth, setFromMonth] = useState(() => new Date(new Date().getFullYear(), new Date().getMonth() - 11, 1).toISOString().slice(0, 7));
|
||||
const [toMonth, setToMonth] = useState(() => new Date().toISOString().slice(0, 7));
|
||||
const [appliedCustom, setAppliedCustom] = useState<{ from: string; to: string } | null>(null);
|
||||
const [analytics, setAnalytics] = useState<AnalyticsPoint[]>([]);
|
||||
const [tags, setTags] = useState<TagPoint[]>([]);
|
||||
const [summarizerMetrics, setSummarizerMetrics] = useState<SummarizerMetrics | null>(null);
|
||||
const [months, setMonths] = useState<6 | 12 | 24>(12);
|
||||
const [reminderJobs, setReminderJobs] = useState<ReminderJob[]>([]);
|
||||
const [prefs, setPrefs] = useState<Prefs>(() => loadPrefs());
|
||||
const [prefsAnchor, setPrefsAnchor] = useState<HTMLElement | null>(null);
|
||||
@@ -143,34 +105,12 @@ export default function DashboardView() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const params = rangeMode === "custom" && appliedCustom ? { from: `${appliedCustom.from}-01`, to: `${appliedCustom.to}-01` } : { months };
|
||||
|
||||
const params = { months };
|
||||
api.get<AnalyticsPoint[]>("/jobapplications/analytics", { params }).then((r) => setAnalytics(r.data ?? [])).catch(() => setAnalytics([]));
|
||||
api.get<TagPoint[]>("/jobapplications/tags", { params: { limit: 10, ...params } }).then((r) => setTags(r.data ?? [])).catch(() => setTags([]));
|
||||
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months: rangeMode === "custom" ? 6 : months, limit: 5 } }).then((r) => setTagTrends(r.data)).catch(() => setTagTrends(null));
|
||||
}, [months, rangeMode, appliedCustom]);
|
||||
api.get<TagTrendResponse>("/jobapplications/tag-trends", { params: { months, limit: 5 } }).then((r) => setTagTrends(r.data)).catch(() => setTagTrends(null));
|
||||
}, [months]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 2) return;
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await api.get<SummarizerMetrics>("/jobapplications/summarizer-metrics");
|
||||
if (!cancelled) setSummarizerMetrics(res.data);
|
||||
} catch {
|
||||
if (!cancelled) setSummarizerMetrics(null);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
const id = window.setInterval(() => void load(), 30000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [tab]);
|
||||
|
||||
const statusRows = useMemo(() => Object.entries(stats?.byStatus ?? {}).sort((a, b) => b[1] - a[1]), [stats]);
|
||||
const maxStatus = statusRows.length ? Math.max(...statusRows.map(([, v]) => v)) : 0;
|
||||
const chartW = 860;
|
||||
const chartH = 260;
|
||||
const appliedSeries = analytics.map((x) => x.applied);
|
||||
@@ -182,11 +122,11 @@ export default function DashboardView() {
|
||||
const funnelMax = overview?.funnel?.length ? Math.max(...overview.funnel.map((x) => x.count)) : 0;
|
||||
|
||||
const metricCards = [
|
||||
{ label: "Active applications", value: stats?.active ?? "-", sub: "Currently in progress" },
|
||||
{ label: "Applied (30 days)", value: stats?.appliedLast30Days ?? "-", sub: "New applications" },
|
||||
{ label: "Median first response", value: overview?.medianDaysToFirstResponse ?? "-", sub: "Days until first reply" },
|
||||
{ label: "Responses logged", value: overview?.totalResponses ?? 0, sub: "Across active jobs" },
|
||||
{ label: "Low readiness", value: reminderJobs.filter((job) => !job.tailoredCvText).length, sub: "Reminder jobs missing tailored CV" },
|
||||
{ label: t("dashboardActiveApplications"), value: stats?.active ?? "-", sub: t("dashboardCurrentlyInProgress") },
|
||||
{ label: t("dashboardApplied30Days"), value: stats?.appliedLast30Days ?? "-", sub: t("dashboardNewApplications") },
|
||||
{ label: t("dashboardMedianFirstResponse"), value: overview?.medianDaysToFirstResponse ?? "-", sub: t("dashboardDaysUntilFirstReply") },
|
||||
{ label: t("dashboardResponsesLogged"), value: overview?.totalResponses ?? 0, sub: t("dashboardAcrossActiveJobs") },
|
||||
{ label: t("dashboardLowReadiness"), value: reminderJobs.filter((job) => !job.tailoredCvText).length, sub: t("dashboardMissingTailoredCv") },
|
||||
];
|
||||
|
||||
const togglePref = (key: keyof Prefs) => {
|
||||
@@ -205,24 +145,29 @@ export default function DashboardView() {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab label="Overview" />
|
||||
<Tab label="Pipeline" />
|
||||
<Tab label="Summarizer" />
|
||||
</Tabs>
|
||||
|
||||
{tab !== 2 ? (
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 2, mb: 2, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950 }}>{t("dashboardOverviewTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("dashboardOverviewBody")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{([6, 12, 24] as const).map((m) => (
|
||||
<Button key={m} size="small" variant={months === m ? "contained" : "outlined"} onClick={() => setMonths(m)}>
|
||||
{m} mo
|
||||
</Button>
|
||||
))}
|
||||
<Button variant="outlined" startIcon={<TuneIcon />} onClick={(e) => setPrefsAnchor(e.currentTarget)}>
|
||||
Customize dashboard
|
||||
{t("dashboardCustomize")}
|
||||
</Button>
|
||||
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
|
||||
{[
|
||||
["cards", "Summary cards"],
|
||||
["activity", "Activity chart"],
|
||||
["funnel", "Conversion funnel"],
|
||||
["companies", "Top companies"],
|
||||
["skills", "Skills insights"],
|
||||
["cards", t("dashboardSummaryCards")],
|
||||
["activity", t("dashboardActivityChart")],
|
||||
["funnel", t("dashboardConversionFunnel")],
|
||||
["companies", t("dashboardTopCompanies")],
|
||||
["skills", t("dashboardSkillsInsights")],
|
||||
].map(([key, label]) => (
|
||||
<MenuItem key={key} onClick={() => togglePref(key as keyof Prefs)}>
|
||||
<Checkbox checked={prefs[key as keyof Prefs]} />
|
||||
@@ -231,208 +176,136 @@ export default function DashboardView() {
|
||||
))}
|
||||
</Menu>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{tab === 0 ? (
|
||||
<>
|
||||
{prefs.cards ? (
|
||||
<Paper sx={{ p: 0.5 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", xl: "repeat(5, 1fr)" } }}>
|
||||
{metricCards.map((m, idx) => (
|
||||
<Box key={m.label} sx={{ borderLeft: { xs: "none", xl: idx === 0 ? "none" : `1px solid ${theme.palette.divider}` }, borderTop: { xs: idx === 0 ? "none" : `1px solid ${theme.palette.divider}`, sm: idx < 2 ? "none" : `1px solid ${theme.palette.divider}`, xl: "none" } }}>
|
||||
<StatCard label={m.label} value={m.value} sub={m.sub} />
|
||||
</Box>
|
||||
))}
|
||||
{prefs.cards ? (
|
||||
<Paper sx={{ p: 0.5 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", xl: "repeat(5, 1fr)" } }}>
|
||||
{metricCards.map((m, idx) => (
|
||||
<Box key={m.label} sx={{ borderLeft: { xs: "none", xl: idx === 0 ? "none" : `1px solid ${theme.palette.divider}` }, borderTop: { xs: idx === 0 ? "none" : `1px solid ${theme.palette.divider}`, sm: idx < 2 ? "none" : `1px solid ${theme.palette.divider}`, xl: "none" } }}>
|
||||
<StatCard label={m.label} value={m.value} sub={m.sub} />
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{prefs.activity ? (
|
||||
<Paper sx={{ mt: 2, p: 2.25 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950 }}>Application activity</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Monthly applications versus responses.</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
<ButtonGroup size="small">
|
||||
{([6, 12, 24] as const).map((m) => (
|
||||
<Button key={m} variant={rangeMode === "preset" && months === m ? "contained" : "outlined"} onClick={() => { setRangeMode("preset"); setMonths(m); }}>{m} mo</Button>
|
||||
))}
|
||||
<Button variant={rangeMode === "custom" ? "contained" : "outlined"} onClick={() => { setRangeMode("custom"); setAppliedCustom({ from: fromMonth, to: toMonth }); }}>Custom</Button>
|
||||
</ButtonGroup>
|
||||
{rangeMode === "custom" ? (
|
||||
<>
|
||||
<TextField size="small" label="From" type="month" value={fromMonth} onChange={(e) => setFromMonth(e.target.value)} />
|
||||
<TextField size="small" label="To" type="month" value={toMonth} onChange={(e) => setToMonth(e.target.value)} />
|
||||
<Button size="small" variant="contained" onClick={() => setAppliedCustom({ from: fromMonth, to: toMonth })}>Apply</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2, overflowX: "auto" }}>
|
||||
<Box sx={{ minWidth: chartW }}>
|
||||
<svg width={chartW} height={chartH} viewBox={`0 0 ${chartW} ${chartH}`}>
|
||||
{[0.25, 0.5, 0.75].map((tick) => <line key={tick} x1="0" x2={chartW} y1={Math.round(chartH * tick)} y2={Math.round(chartH * tick)} stroke={alpha(theme.palette.text.primary, 0.08)} strokeDasharray="5 5" />)}
|
||||
{responsePath ? <path d={responsePath} fill="none" stroke={alpha(theme.palette.info.main, 0.95)} strokeWidth="2.5" /> : null}
|
||||
{appliedPath ? <path d={appliedPath} fill="none" stroke={alpha(theme.palette.success.main, 0.95)} strokeWidth="2.5" /> : null}
|
||||
</svg>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1 }}>{analytics.map((p) => <Typography key={p.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center" }}>{p.month.slice(5)}</Typography>)}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2 }}>
|
||||
{prefs.funnel ? (
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Conversion funnel</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{(overview?.funnel ?? []).map((item) => (
|
||||
<Box key={item.label} sx={{ display: "grid", gridTemplateColumns: "140px 1fr 50px", gap: 1, alignItems: "center" }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{item.label}</Typography>
|
||||
<Box sx={{ height: 10, borderRadius: 999, bgcolor: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}>
|
||||
<Box sx={{ width: `${funnelMax ? clamp((item.count / funnelMax) * 100, 0, 100) : 0}%`, height: "100%", background: `linear-gradient(90deg, ${alpha(theme.palette.primary.main, 0.9)}, ${alpha(theme.palette.primary.main, 0.3)})` }} />
|
||||
</Box>
|
||||
<Typography sx={{ textAlign: "right", fontWeight: 900 }}>{item.count}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 2 }}>Response sources</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1, mt: 1 }}>
|
||||
{(overview?.responseRateBySource ?? []).map((item) => (
|
||||
<Box key={item.label} sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
|
||||
<Typography variant="body2">{item.label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{item.rate}%</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{prefs.companies ? (
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Top companies by activity</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{(overview?.topCompanies ?? []).map((item) => (
|
||||
<Box key={item.companyId} sx={{ display: "grid", gridTemplateColumns: "1fr auto auto", gap: 2, alignItems: "center" }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{item.company}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count} jobs</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 900 }}>{item.responseRate}%</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{prefs.skills ? (
|
||||
<Paper sx={{ mt: 2, p: 2.25 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Top skills</Typography>
|
||||
{tags.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No tags yet.</Typography> : (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "132px 1fr", gap: 2, alignItems: "center" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "center" }}>
|
||||
<svg width="132" height="132" viewBox="0 0 132 132">
|
||||
<circle cx="66" cy="66" r="52" stroke={alpha(theme.palette.text.primary, 0.1)} strokeWidth="14" fill="none" />
|
||||
{(() => {
|
||||
const r = 52;
|
||||
const circ = 2 * Math.PI * r;
|
||||
let offset = 0;
|
||||
return tags.map((t, i) => {
|
||||
const len = circ * (tagTotal ? t.count / tagTotal : 0);
|
||||
const el = <circle key={t.tag} cx="66" cy="66" r={r} fill="none" stroke={tagColors[i % tagColors.length]} strokeWidth="14" strokeDasharray={`${len} ${circ}`} strokeDashoffset={-offset} transform="rotate(-90 66 66)" />;
|
||||
offset += len;
|
||||
return el;
|
||||
});
|
||||
})()}
|
||||
<circle cx="66" cy="66" r="39" fill={theme.palette.background.paper} />
|
||||
<text x="66" y="62" textAnchor="middle" fontSize="16" fontWeight="900" fill={theme.palette.text.primary}>{tagTotal}</text>
|
||||
<text x="66" y="80" textAnchor="middle" fontSize="11" fill={alpha(theme.palette.text.primary, 0.65)}>skill tags</text>
|
||||
</svg>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
||||
{tags.slice(0, 8).map((t, i) => <Box key={t.tag} sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Box sx={{ display: "flex", alignItems: "center", gap: 1 }}><Box sx={{ width: 10, height: 10, borderRadius: 999, bgcolor: tagColors[i % tagColors.length] }} /><Typography variant="body2" sx={{ fontWeight: 700 }}>{t.tag}</Typography></Box><Typography variant="body2" sx={{ fontWeight: 900 }}>{t.count}</Typography></Box>)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Skill trends</Typography>
|
||||
{!tagTrends || tagTrends.series.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No tag trend data yet.</Typography> : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{tagTrends.series.map((series, idx) => (
|
||||
<Box key={series.tag}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5 }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{series.tag}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>{series.counts.reduce((a, b) => a + b, 0)} total</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${series.counts.length}, 1fr)`, gap: 0.5 }}>
|
||||
{series.counts.map((count, i) => (
|
||||
<Box key={`${series.tag}-${i}`} sx={{ height: 14, borderRadius: 1, bgcolor: count > 0 ? alpha(tagColors[idx % tagColors.length], 0.25 + Math.min(0.6, count / 10)) : alpha(theme.palette.text.primary, 0.06) }} title={`${tagTrends.months[i]}: ${count}`} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${tagTrends.months.length}, 1fr)`, gap: 0.5 }}>
|
||||
{tagTrends.months.map((month) => <Typography key={month} variant="caption" sx={{ textAlign: "center", color: "text.secondary" }}>{month.slice(5)}</Typography>)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
</>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{tab === 1 ? (
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.2fr 0.8fr" }, gap: 2.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Status breakdown</Typography>
|
||||
{statusRows.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No data yet.</Typography> : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{statusRows.map(([status, value]) => {
|
||||
const tone = status === "Rejected" ? theme.palette.error.main : status === "Waiting" || status === "Ghosted" ? theme.palette.warning.main : status === "Offer" ? theme.palette.success.main : status === "Interview" ? theme.palette.info.main : theme.palette.primary.main;
|
||||
const w = maxStatus ? clamp(Math.round((value / maxStatus) * 100), 0, 100) : 0;
|
||||
return <Box key={status} sx={{ display: "grid", gridTemplateColumns: "160px 1fr 60px", gap: 1, alignItems: "center" }}><Typography sx={{ fontWeight: 850 }}>{status}</Typography><Box sx={{ height: 10, borderRadius: 999, background: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}><Box sx={{ width: `${w}%`, height: "100%", background: `linear-gradient(90deg, ${alpha(tone, 0.85)}, ${alpha(tone, 0.32)})` }} /></Box><Typography sx={{ textAlign: "right", fontWeight: 900 }}>{value}</Typography></Box>;
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>Response rate by source</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{(overview?.responseRateBySource ?? []).map((item) => (
|
||||
<Box key={item.label} sx={{ p: 1.25, border: "1px solid", borderColor: "divider", borderRadius: 2 }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{item.label}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.responses} responses from {item.total} jobs</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mt: 0.5 }}>{item.rate}%</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{prefs.activity ? (
|
||||
<Paper sx={{ mt: 2, p: 2.25 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950 }}>{t("dashboardApplicationActivity")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardMonthlyApplicationsResponses")}</Typography>
|
||||
<Box sx={{ mt: 2, overflowX: "auto" }}>
|
||||
<Box sx={{ minWidth: chartW }}>
|
||||
<svg width={chartW} height={chartH} viewBox={`0 0 ${chartW} ${chartH}`}>
|
||||
{[0.25, 0.5, 0.75].map((tick) => <line key={tick} x1="0" x2={chartW} y1={Math.round(chartH * tick)} y2={Math.round(chartH * tick)} stroke={alpha(theme.palette.text.primary, 0.08)} strokeDasharray="5 5" />)}
|
||||
{responsePath ? <path d={responsePath} fill="none" stroke={alpha(theme.palette.info.main, 0.95)} strokeWidth="2.5" /> : null}
|
||||
{appliedPath ? <path d={appliedPath} fill="none" stroke={alpha(theme.palette.success.main, 0.95)} strokeWidth="2.5" /> : null}
|
||||
</svg>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mt: 1 }}>{analytics.map((p) => <Typography key={p.month} variant="caption" sx={{ width: `${100 / Math.max(1, analytics.length)}%`, textAlign: "center" }}>{p.month.slice(5)}</Typography>)}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{tab === 2 ? (
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
|
||||
{[{ label: "Service status", value: summarizerMetrics?.healthy ? "Healthy" : "Offline", sub: summarizerMetrics?.model || "Summarizer health check" }, { label: "Health latency", value: summarizerMetrics?.healthLatencyMs != null ? `${summarizerMetrics.healthLatencyMs} ms` : "-", sub: "Latest /health round-trip" }, { label: "Probe latency", value: summarizerMetrics?.probeLatencyMs != null ? `${summarizerMetrics.probeLatencyMs} ms` : "-", sub: "Periodic small summarize request" }, { label: "Last success", value: formatRelative(summarizerMetrics?.lastProbeSuccessAt || summarizerMetrics?.lastSuccessAt), sub: "Recent successful latency sample" }].map((m) => <Paper key={m.label} variant="outlined" sx={{ p: 2 }}><Typography variant="overline" sx={{ color: "text.secondary" }}>{m.label}</Typography><Typography variant="h5" sx={{ fontWeight: 950 }}>{m.value}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{m.sub}</Typography></Paper>)}
|
||||
</Box>
|
||||
<Paper variant="outlined" sx={{ p: 2, mt: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Telemetry</Typography>
|
||||
<Typography variant="body2"><strong>Requests:</strong> {summarizerMetrics?.requests ?? 0}</Typography>
|
||||
<Typography variant="body2"><strong>Cache hits:</strong> {summarizerMetrics?.cacheHits ?? 0}</Typography>
|
||||
<Typography variant="body2"><strong>Cache misses:</strong> {summarizerMetrics?.cacheMisses ?? 0}</Typography>
|
||||
<Typography variant="body2"><strong>Failures:</strong> {summarizerMetrics?.failures ?? 0}</Typography>
|
||||
<Typography variant="body2"><strong>Probe failures:</strong> {summarizerMetrics?.probeFailures ?? 0}</Typography>
|
||||
<Typography variant="body2"><strong>Last failure:</strong> {formatRelative(summarizerMetrics?.lastFailureAt)}</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1, color: summarizerMetrics?.lastError ? "warning.main" : "text.secondary" }}>{summarizerMetrics?.lastError || "No recent summarizer errors recorded."}</Typography>
|
||||
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2 }}>
|
||||
{prefs.funnel ? (
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardConversionFunnelTitle")}</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{(overview?.funnel ?? []).map((item) => (
|
||||
<Box key={item.label} sx={{ display: "grid", gridTemplateColumns: "140px 1fr 50px", gap: 1, alignItems: "center" }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{item.label}</Typography>
|
||||
<Box sx={{ height: 10, borderRadius: 999, bgcolor: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}>
|
||||
<Box sx={{ width: `${funnelMax ? clamp((item.count / funnelMax) * 100, 0, 100) : 0}%`, height: "100%", background: `linear-gradient(90deg, ${alpha(theme.palette.primary.main, 0.9)}, ${alpha(theme.palette.primary.main, 0.3)})` }} />
|
||||
</Box>
|
||||
<Typography sx={{ textAlign: "right", fontWeight: 900 }}>{item.count}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 2 }}>{t("dashboardResponseSources")}</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1, mt: 1 }}>
|
||||
{(overview?.responseRateBySource ?? []).map((item) => (
|
||||
<Box key={item.label} sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
|
||||
<Typography variant="body2">{item.label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{item.rate}%</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{prefs.companies ? (
|
||||
<Paper sx={{ p: 2.25 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopCompaniesByActivity")}</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{(overview?.topCompanies ?? []).map((item) => (
|
||||
<Box key={item.companyId} sx={{ display: "grid", gridTemplateColumns: "1fr auto auto", gap: 2, alignItems: "center" }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{item.company}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count} jobs</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 900 }}>{item.responseRate}%</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{prefs.skills ? (
|
||||
<Paper sx={{ mt: 2, p: 2.25 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardTopSkills")}</Typography>
|
||||
{tags.length === 0 ? <Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagsYet")}</Typography> : (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "132px 1fr", gap: 2, alignItems: "center" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "center" }}>
|
||||
<svg width="132" height="132" viewBox="0 0 132 132">
|
||||
<circle cx="66" cy="66" r="52" stroke={alpha(theme.palette.text.primary, 0.1)} strokeWidth="14" fill="none" />
|
||||
{(() => {
|
||||
const r = 52;
|
||||
const circ = 2 * Math.PI * r;
|
||||
let offset = 0;
|
||||
return tags.map((tItem, i) => {
|
||||
const len = circ * (tagTotal ? tItem.count / tagTotal : 0);
|
||||
const el = <circle key={tItem.tag} cx="66" cy="66" r={r} fill="none" stroke={tagColors[i % tagColors.length]} strokeWidth="14" strokeDasharray={`${len} ${circ}`} strokeDashoffset={-offset} transform="rotate(-90 66 66)" />;
|
||||
offset += len;
|
||||
return el;
|
||||
});
|
||||
})()}
|
||||
<circle cx="66" cy="66" r="39" fill={theme.palette.background.paper} />
|
||||
<text x="66" y="62" textAnchor="middle" fontSize="16" fontWeight="900" fill={theme.palette.text.primary}>{tagTotal}</text>
|
||||
<text x="66" y="80" textAnchor="middle" fontSize="11" fill={alpha(theme.palette.text.primary, 0.65)}>{t("dashboardSkillTags")}</text>
|
||||
</svg>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
||||
{tags.slice(0, 8).map((tItem, i) => <Box key={tItem.tag} sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Box sx={{ display: "flex", alignItems: "center", gap: 1 }}><Box sx={{ width: 10, height: 10, borderRadius: 999, bgcolor: tagColors[i % tagColors.length] }} /><Typography variant="body2" sx={{ fontWeight: 700 }}>{tItem.tag}</Typography></Box><Typography variant="body2" sx={{ fontWeight: 900 }}>{tItem.count}</Typography></Box>)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 1 }}>{t("dashboardSkillTrends")}</Typography>
|
||||
{!tagTrends || tagTrends.series.length === 0 ? <Typography sx={{ color: "text.secondary" }}>{t("dashboardNoTagTrendData")}</Typography> : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
{tagTrends.series.map((series, idx) => (
|
||||
<Box key={series.tag}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5 }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{series.tag}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>{series.counts.reduce((a, b) => a + b, 0)} total</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${series.counts.length}, 1fr)`, gap: 0.5 }}>
|
||||
{series.counts.map((count, i) => (
|
||||
<Box key={`${series.tag}-${i}`} sx={{ height: 14, borderRadius: 1, bgcolor: count > 0 ? alpha(tagColors[idx % tagColors.length], 0.25 + Math.min(0.6, count / 10)) : alpha(theme.palette.text.primary, 0.06) }} title={`${tagTrends.months[i]}: ${count}`} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${tagTrends.months.length}, 1fr)`, gap: 0.5 }}>
|
||||
{tagTrends.months.map((month) => <Typography key={month} variant="caption" sx={{ textAlign: "center", color: "text.secondary" }}>{month.slice(5)}</Typography>)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Company, JobApplication } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useCompanies } from "../hooks/useCompanies";
|
||||
import TagsInput from "./TagsInput";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -53,6 +54,7 @@ function parseTags(raw: any): string[] {
|
||||
|
||||
export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { companies } = useCompanies();
|
||||
|
||||
@@ -144,7 +146,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
dateApplied: dateApplied || null,
|
||||
jobUrl: jobUrl.trim() || null,
|
||||
});
|
||||
toast("Saved.", "success");
|
||||
toast(t("save"), "success");
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {
|
||||
@@ -156,7 +158,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||
<DialogTitle>Edit job</DialogTitle>
|
||||
<DialogTitle>{t("editJobTitle")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 1, mb: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
@@ -221,8 +223,8 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={save} disabled={!canSave}>Save</Button>
|
||||
<Button onClick={onClose}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={save} disabled={!canSave}>{t("save")}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Box, Button, Chip, Paper, Typography } from "@mui/material";
|
||||
import { api } from "../api";
|
||||
import { clearAuthToken, decodeJwtPayload, getAuthToken, setAuthToken } from "../auth";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -46,6 +47,7 @@ function loadGoogleScript(): Promise<void> {
|
||||
|
||||
export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void }) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [token, setToken] = useState<string | null>(() => getAuthToken());
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
@@ -81,20 +83,20 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
if (cancelled) return;
|
||||
setAuthToken(res.data.accessToken);
|
||||
setToken(res.data.accessToken);
|
||||
toast("Signed in with Google.", "success");
|
||||
toast(t("googleSignedIn"), "success");
|
||||
onSignedIn?.();
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
clearAuthToken();
|
||||
setToken(null);
|
||||
toast("This Google account is not linked yet. Sign in locally first to bind it.", "info");
|
||||
toast(t("googleNotLinkedYet"), "info");
|
||||
}
|
||||
};
|
||||
void exchange();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, isRawGoogleToken, onSignedIn, toast]);
|
||||
}, [token, isRawGoogleToken, onSignedIn, toast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
@@ -102,9 +104,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
|
||||
const shouldRenderButton = !token || isRawGoogleToken || (me?.provider === "local" && !me?.googleLink?.linked);
|
||||
host.replaceChildren();
|
||||
if (!shouldRenderButton) {
|
||||
return;
|
||||
}
|
||||
if (!shouldRenderButton) return;
|
||||
|
||||
let active = true;
|
||||
void loadGoogleScript()
|
||||
@@ -120,17 +120,17 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
try {
|
||||
if (me?.provider === "local") {
|
||||
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/google/link", { token: credential });
|
||||
toast(res.data?.email ? `Linked Google account ${res.data.email}.` : "Google account linked.", "success");
|
||||
toast(res.data?.email ? t("googleLinkedSuccessWithEmail", { email: res.data.email }) : t("googleLinkedSuccess"), "success");
|
||||
await refreshMe();
|
||||
} else {
|
||||
const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token: credential });
|
||||
setAuthToken(res.data.accessToken);
|
||||
setToken(res.data.accessToken);
|
||||
toast("Signed in with Google.", "success");
|
||||
toast(t("googleSignedIn"), "success");
|
||||
onSignedIn?.();
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data || e?.message || "Google authentication failed.";
|
||||
const msg = e?.response?.data || e?.message || t("googleAuthFailed");
|
||||
toast(String(msg), "error");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
@@ -145,46 +145,48 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
text: me?.provider === "local" ? "continue_with" : "signin_with",
|
||||
});
|
||||
})
|
||||
.catch(() => toast("Google auth script failed to load.", "error"));
|
||||
.catch(() => toast(t("googleScriptLoadFailed"), "error"));
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
host.replaceChildren();
|
||||
};
|
||||
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast]);
|
||||
}, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast, t]);
|
||||
|
||||
const signedInName = me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || "";
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Google account
|
||||
{t("googleAccountTitle")}
|
||||
</Typography>
|
||||
|
||||
{!clientId && (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
Set `REACT_APP_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking.
|
||||
{t("googleSetupHint")}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{clientId && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Chip size="small" label={me?.googleLink?.linked ? "Linked" : "Available to link"} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
|
||||
{me?.googleLink?.linkedAt ? <Chip size="small" variant="outlined" label={`Linked ${new Date(me.googleLink.linkedAt).toLocaleDateString()}`} /> : null}
|
||||
<Chip size="small" label={me?.googleLink?.linked ? t("googleLinked") : t("googleAvailableToLink")} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
|
||||
{me?.googleLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("googleLinkedDate", { date: new Date(me.googleLink.linkedAt).toLocaleDateString() })} /> : null}
|
||||
</Box>
|
||||
|
||||
{!token ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
Sign in with a Google account that has already been linked to your Job Tracker user.
|
||||
{t("googleSignInHint")}
|
||||
</Typography>
|
||||
) : me?.provider === "local" ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
{me.googleLink?.linked
|
||||
? `Linked to ${me.googleLink.email || "your Google account"}.`
|
||||
: "Bind a Google account to this user so you can sign in with Google and still keep your normal app roles and data."}
|
||||
? t("googleLinkedTo", { email: me.googleLink.email || t("googleLinkedToYourAccount") })
|
||||
: t("googleBindHint")}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
Exchange your Google sign-in for a normal Job Tracker session.
|
||||
{t("googleExchangeHint")}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -198,10 +200,10 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
clearAuthToken();
|
||||
setToken(null);
|
||||
setMe(null);
|
||||
toast("Signed out.", "info");
|
||||
toast(t("signedOut"), "info");
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
{t("signOut")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -213,22 +215,22 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.delete("/auth/google/link");
|
||||
toast("Google account unlinked.", "info");
|
||||
toast(t("googleUnlinked"), "info");
|
||||
await refreshMe();
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data || e?.message || "Failed to unlink Google account.";
|
||||
const msg = e?.response?.data || e?.message || t("googleUnlinkFailed");
|
||||
toast(String(msg), "error");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Unlink Google
|
||||
{t("unlinkGoogle")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{token && me?.email ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Signed in as {me.displayName || [me.firstName, me.lastName].filter(Boolean).join(" ") || me.email}.
|
||||
{t("signedInAs", { name: signedInName })}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Box, Button, Paper, Typography } from "@mui/material";
|
||||
import { api } from "../api";
|
||||
import { Company, JobApplication } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
type ImportJob = Omit<JobApplication, "id" | "company"> & {
|
||||
company: Pick<Company, "name" | "location" | "source">;
|
||||
@@ -32,7 +33,6 @@ function parseCsv(text: string): string[][] {
|
||||
cur = "";
|
||||
};
|
||||
const pushRow = () => {
|
||||
// ignore trailing empty rows
|
||||
if (row.length === 1 && row[0] === "" && rows.length > 0) {
|
||||
row = [];
|
||||
return;
|
||||
@@ -91,15 +91,13 @@ function parseCsv(text: string): string[][] {
|
||||
function parseDateDMY(s: string): string | null {
|
||||
const v = (s || "").trim();
|
||||
if (!v) return null;
|
||||
// expects dd/MM/yyyy
|
||||
const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(v);
|
||||
if (!m) return null;
|
||||
const dd = Number(m[1]);
|
||||
const mm = Number(m[2]);
|
||||
const yyyy = Number(m[3]);
|
||||
if (!yyyy || mm < 1 || mm > 12 || dd < 1 || dd > 31) return null;
|
||||
const iso = `${String(yyyy).padStart(4, "0")}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`;
|
||||
return iso;
|
||||
return `${String(yyyy).padStart(4, "0")}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function csvToImportJobs(csvText: string): ImportJob[] {
|
||||
@@ -122,7 +120,6 @@ function csvToImportJobs(csvText: string): ImportJob[] {
|
||||
const iInterviewDate = idx("Interview Date");
|
||||
|
||||
const out: ImportJob[] = [];
|
||||
|
||||
for (let r = 1; r < rows.length; r++) {
|
||||
const row = rows[r];
|
||||
const get = (i: number) => (i >= 0 ? (row[i] ?? "").trim() : "");
|
||||
@@ -132,9 +129,7 @@ function csvToImportJobs(csvText: string): ImportJob[] {
|
||||
if (!jobTitle && !companyName) continue;
|
||||
|
||||
const rawStatus = get(iStatus);
|
||||
const status =
|
||||
rawStatus === "Follow-up Needed" ? "Waiting" : rawStatus || "Applied";
|
||||
|
||||
const status = rawStatus === "Follow-up Needed" ? "Waiting" : rawStatus || "Applied";
|
||||
const dateApplied = parseDateDMY(get(iDateApplied)) ?? new Date().toISOString().slice(0, 10);
|
||||
const responseReceived = /^yes$/i.test(get(iResp));
|
||||
const responseDate = parseDateDMY(get(iRespDate)) ?? undefined;
|
||||
@@ -179,6 +174,7 @@ function csvToImportJobs(csvText: string): ImportJob[] {
|
||||
|
||||
export default function ImportExportJobs() {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [lastImportCount, setLastImportCount] = useState<number | null>(null);
|
||||
|
||||
@@ -188,17 +184,11 @@ export default function ImportExportJobs() {
|
||||
const url = `/export/jobs?format=${format}&includeDeleted=false`;
|
||||
const res = await api.get(url, { responseType: "blob" });
|
||||
|
||||
if (format === "json") {
|
||||
const text = await res.data.text();
|
||||
downloadText(`job-tracker-export-${stamp}.json`, text, "application/json");
|
||||
} else {
|
||||
const text = await res.data.text();
|
||||
downloadText(`job-tracker-export-${stamp}.csv`, text, "text/csv");
|
||||
}
|
||||
|
||||
toast(`Exported jobs (${format.toUpperCase()}).`, "success");
|
||||
const text = await res.data.text();
|
||||
downloadText(`job-tracker-export-${stamp}.${format}`, text, format === "json" ? "application/json" : "text/csv");
|
||||
toast(t("exportedJobs", { format: format.toUpperCase() }), "success");
|
||||
} catch {
|
||||
toast("Export failed.", "error");
|
||||
toast(t("exportFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -215,7 +205,6 @@ export default function ImportExportJobs() {
|
||||
for (const j of parsed) {
|
||||
const companyRes = await api.post<Company>("/companies", j.company);
|
||||
const company = companyRes.data;
|
||||
|
||||
await api.post("/jobapplications", {
|
||||
jobTitle: j.jobTitle,
|
||||
companyId: company.id,
|
||||
@@ -233,9 +222,9 @@ export default function ImportExportJobs() {
|
||||
}
|
||||
|
||||
setLastImportCount(created);
|
||||
toast(`Imported ${created} jobs.`, "success");
|
||||
toast(t("importedJobs", { count: created }), "success");
|
||||
} catch {
|
||||
toast("Import failed (expecting exported JSON array).", "error");
|
||||
toast(t("importFailedJson"), "error");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
@@ -247,63 +236,41 @@ export default function ImportExportJobs() {
|
||||
const text = await file.text();
|
||||
const jobs = csvToImportJobs(text);
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
downloadText(
|
||||
`job-tracker-import-${stamp}.json`,
|
||||
JSON.stringify(jobs, null, 2),
|
||||
"application/json",
|
||||
);
|
||||
toast(`Converted ${jobs.length} rows to import JSON.`, "success");
|
||||
downloadText(`job-tracker-import-${stamp}.json`, JSON.stringify(jobs, null, 2), "application/json");
|
||||
toast(t("convertedRows", { count: jobs.length }), "success");
|
||||
} catch {
|
||||
toast("CSV conversion failed.", "error");
|
||||
toast(t("csvConversionFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const helper = useMemo(
|
||||
() =>
|
||||
"Import expects the JSON exported by this app (an array of job objects with embedded company).",
|
||||
[],
|
||||
);
|
||||
const helper = useMemo(() => t("importExportBody"), [t]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Import / Export
|
||||
{t("importExportTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
|
||||
{helper}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Button variant="outlined" onClick={() => void exportAll("json")}>
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => void exportAll("csv")}>
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => void exportAll("json")}>{t("exportJson")}</Button>
|
||||
<Button variant="outlined" onClick={() => void exportAll("csv")}>{t("exportCsv")}</Button>
|
||||
|
||||
<Button variant="contained" component="label" disabled={importing}>
|
||||
{importing ? "Importing..." : "Import JSON"}
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json"
|
||||
hidden
|
||||
onChange={(e) => void onImportFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{importing ? t("profileUploading") : t("importJson")}
|
||||
<input type="file" accept="application/json" hidden onChange={(e) => void onImportFile(e.target.files?.[0] ?? null)} />
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" component="label">
|
||||
Convert CSV to Import JSON
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
hidden
|
||||
onChange={(e) => void onConvertCsv(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{t("convertCsvToImportJson")}
|
||||
<input type="file" accept=".csv,text/csv" hidden onChange={(e) => void onConvertCsv(e.target.files?.[0] ?? null)} />
|
||||
</Button>
|
||||
|
||||
{lastImportCount !== null && (
|
||||
<Typography sx={{ ml: 1, color: "text.secondary" }}>
|
||||
Last import: {lastImportCount}
|
||||
{t("lastImport", { count: lastImportCount })}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useDialogActions } from "../dialogs";
|
||||
import Correspondence from "./Correspondence";
|
||||
import Attachments from "./Attachments";
|
||||
import JobFlowBar from "./JobFlowBar";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
type FollowUpDraft = {
|
||||
subject: string;
|
||||
@@ -70,6 +71,7 @@ function copyLines(items: string[]) {
|
||||
|
||||
export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { confirmAction } = useDialogActions();
|
||||
|
||||
const [job, setJob] = useState<JobApplication | null>(null);
|
||||
@@ -152,7 +154,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
}
|
||||
})();
|
||||
|
||||
const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : "Job Application";
|
||||
const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : t("addJobApplication");
|
||||
const checklist = [job?.hasResume ? "Resume" : null, job?.hasCoverLetter ? "Cover letter" : null, job?.hasPortfolio ? "Portfolio" : null, job?.hasOtherAttachment ? "Other" : null].filter(Boolean).join(", ") || "";
|
||||
const summaryFirstText = job?.fullSummary ?? job?.shortSummary ?? "No summary yet.";
|
||||
const translatedDescriptionText = job?.translatedDescription?.trim() || "";
|
||||
@@ -166,7 +168,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Job workspace</Typography>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableOpen")}</Typography>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
</Box>
|
||||
{job && <Chip label={job.status} color={statusChipColor(job.status)} size="small" />}
|
||||
@@ -179,14 +181,14 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{summaryFirstText}</Typography>
|
||||
</Box>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }} variant="scrollable" allowScrollButtonsMobile>
|
||||
<Tab label="Overview" />
|
||||
<Tab label={t("jobTableOverview")} />
|
||||
<Tab label="Correspondence" />
|
||||
<Tab label="Attachments" />
|
||||
<Tab label="Tailored CV" />
|
||||
<Tab label="Follow-up draft" />
|
||||
<Tab label={t("jobTableFollowUp")} />
|
||||
<Tab label="Candidate fit" />
|
||||
<Tab label="Interview prep" />
|
||||
<Tab label="Readiness" />
|
||||
<Tab label={t("jobTableReadiness")} />
|
||||
{isAdmin ? <Tab label="History" /> : null}
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import EditJobDialog from "./EditJobDialog";
|
||||
import { useToast } from "../toast";
|
||||
import SavedViewsMenu, { SavedViewParams } from "./SavedViewsMenu";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
interface JobApplication {
|
||||
id: number;
|
||||
@@ -125,6 +126,7 @@ function statusTone(status: string): string {
|
||||
export default function JobTable({ refreshToken, pageSize, onPageSizeChange, columns, onColumnsChange, mode = "jobs" }: Props) {
|
||||
const theme = useTheme();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { confirmAction } = useDialogActions();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -218,39 +220,39 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
if (jobsToDelete.length === 0) return false;
|
||||
if (jobsToDelete.length === 1) {
|
||||
const job = jobsToDelete[0];
|
||||
return confirmAction(`Move "${job.jobTitle}" at ${job.company?.name ?? "this company"} to trash?`, { title: "Move job to trash", confirmLabel: "Move", destructive: true });
|
||||
return confirmAction(t("jobTableMoveOneConfirm", { title: job.jobTitle, company: job.company?.name ?? t("jobTableCompany") }), { title: t("jobTableMoveToTrashTitle"), confirmLabel: t("jobTableMove"), destructive: true });
|
||||
}
|
||||
return confirmAction(`Move ${jobsToDelete.length} selected jobs to trash?`, { title: "Move jobs to trash", confirmLabel: "Move", destructive: true });
|
||||
return confirmAction(t("jobTableMoveManyConfirm", { count: jobsToDelete.length }), { title: t("jobTableMoveJobsToTrashTitle"), confirmLabel: t("jobTableMove"), destructive: true });
|
||||
};
|
||||
|
||||
const softDelete = async (job: JobApplication) => {
|
||||
if (!(await confirmDelete([job]))) return;
|
||||
try {
|
||||
await api.delete(`/jobapplications/${job.id}`);
|
||||
toast("Job moved to trash.", "success", { label: "Undo", onClick: () => { void restore(job.id); } });
|
||||
toast(t("jobTableMovedToTrash"), "success", { label: "Undo", onClick: () => { void restore(job.id); } });
|
||||
setReloadToken((t) => t + 1);
|
||||
} catch {
|
||||
toast("Failed to delete job.", "error");
|
||||
toast(t("jobTableDeleteFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const restore = async (id: number) => {
|
||||
try {
|
||||
await api.post(`/jobapplications/${id}/restore`);
|
||||
toast("Job restored.", "success");
|
||||
toast(t("jobTableRestored"), "success");
|
||||
setReloadToken((t) => t + 1);
|
||||
} catch {
|
||||
toast("Failed to restore job.", "error");
|
||||
toast(t("jobTableRestoreFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const setStatusQuick = async (id: number, status: string) => {
|
||||
try {
|
||||
await api.patch(`/jobapplications/${id}/status`, { status });
|
||||
toast(`Status set to ${status}.`, "success");
|
||||
toast(t("jobTableStatusSet", { status }), "success");
|
||||
setReloadToken((t) => t + 1);
|
||||
} catch {
|
||||
toast("Failed to update status.", "error");
|
||||
toast(t("jobTableStatusUpdateFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -264,11 +266,11 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
if (action === "restore") return api.post(`/jobapplications/${id}/restore`);
|
||||
return api.patch(`/jobapplications/${id}/status`, { status: value });
|
||||
}));
|
||||
toast(`Updated ${selectedIds.length} jobs.`, "success");
|
||||
toast(t("jobTableUpdatedJobs", { count: selectedIds.length }), "success");
|
||||
setReloadToken((t) => t + 1);
|
||||
setSelectedIds([]);
|
||||
} catch {
|
||||
toast("Bulk action failed.", "error");
|
||||
toast(t("jobTableBulkActionFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -282,55 +284,55 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", gap: 2, alignItems: "center", justifyContent: "space-between", mt: 2, flexWrap: "wrap" }}>
|
||||
<TextField label="Search" value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }} placeholder="Title, company, notes, messages" size="small" InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }} sx={{ minWidth: 320, flex: "1 1 320px" }} />
|
||||
<TextField label={t("jobTableSearch")} value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }} placeholder={t("jobTableSearchPlaceholder")} size="small" InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }} sx={{ minWidth: 320, flex: "1 1 320px" }} />
|
||||
|
||||
<FormControl sx={{ minWidth: 160 }} size="small">
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select value={statusFilter} label="Status" onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}>
|
||||
{["All", "Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"].map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||
<InputLabel>{t("jobTableStatus")}</InputLabel>
|
||||
<Select value={statusFilter} label={t("jobTableStatus")} onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}>
|
||||
{[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => <MenuItem key={s} value={s === t("jobTableAll") ? "All" : s === t("statusApplied") ? "Applied" : s === t("statusWaiting") ? "Waiting" : s === t("statusInterview") ? "Interview" : s === t("statusOffer") ? "Offer" : s === t("statusRejected") ? "Rejected" : "Ghosted"}>{s}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel>Company</InputLabel>
|
||||
<Select value={companyFilterId} label="Company" onChange={(e) => { setCompanyFilterId(e.target.value as any); setPage(0); }}>
|
||||
<MenuItem value="All">All</MenuItem>
|
||||
<InputLabel>{t("jobTableCompany")}</InputLabel>
|
||||
<Select value={companyFilterId} label={t("jobTableCompany")} onChange={(e) => { setCompanyFilterId(e.target.value as any); setPage(0); }}>
|
||||
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
|
||||
{companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField label="Location" value={locationFilter} onChange={(e) => { setLocationFilter(e.target.value); setPage(0); }} sx={{ minWidth: 200, flex: "1 1 200px" }} />
|
||||
<TextField label={t("jobTableLocation")} value={locationFilter} onChange={(e) => { setLocationFilter(e.target.value); setPage(0); }} sx={{ minWidth: 200, flex: "1 1 200px" }} />
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label="Needs follow-up" /> : null}
|
||||
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label={t("jobTableNeedsFollowUp")} /> : null}
|
||||
{mode === "jobs" ? (
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Readiness</InputLabel>
|
||||
<Select value={readinessFilter} label="Readiness" onChange={(e) => setReadinessFilter(e.target.value as any)}>
|
||||
<MenuItem value="all">All readiness</MenuItem>
|
||||
<MenuItem value="needs-work">Needs work</MenuItem>
|
||||
<MenuItem value="interview">Interview stage</MenuItem>
|
||||
<InputLabel>{t("jobTableReadiness")}</InputLabel>
|
||||
<Select value={readinessFilter} label={t("jobTableReadiness")} onChange={(e) => setReadinessFilter(e.target.value as any)}>
|
||||
<MenuItem value="all">{t("jobTableAllReadiness")}</MenuItem>
|
||||
<MenuItem value="needs-work">{t("jobTableNeedsWork")}</MenuItem>
|
||||
<MenuItem value="interview">{t("jobTableInterviewStage")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : null}
|
||||
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => { setIncludeDeleted(e.target.checked); setPage(0); }} />} label="Show deleted" /> : null}
|
||||
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => { setIncludeDeleted(e.target.checked); setPage(0); }} />} label={t("jobTableShowDeleted")} /> : null}
|
||||
<SavedViewsMenu current={{ q: search.trim() || undefined, status: statusFilter !== "All" ? statusFilter : undefined, companyId: companyFilterId === "All" ? undefined : (companyFilterId as number), location: locationFilter.trim() || undefined, needsFollowUp: needsFollowUpOnly ? true : undefined }} onApply={(p: SavedViewParams) => { setSearch(p.q ?? ""); setStatusFilter(p.status ?? "All"); setCompanyFilterId(p.companyId ?? "All"); setLocationFilter(p.location ?? ""); setNeedsFollowUpOnly(Boolean(p.needsFollowUp)); setPage(0); }} />
|
||||
<Tooltip title="Columns"><IconButton onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip>
|
||||
<Tooltip title={t("jobTableColumns")}><IconButton onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{selectedIds.length > 0 ? (
|
||||
<Paper sx={{ mt: 2, p: 1.5, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{selectedIds.length} selected</Typography>
|
||||
<Typography sx={{ fontWeight: 800 }}>{t("jobTableSelected", { count: selectedIds.length })}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{mode === "trash" ? <Button variant="outlined" onClick={() => void runBulkAction("restore")}>Restore selected</Button> : <Button variant="outlined" color="error" onClick={() => void runBulkAction("delete")}>Delete selected</Button>}
|
||||
{mode === "trash" ? <Button variant="outlined" onClick={() => void runBulkAction("restore")}>{t("jobTableRestoreSelected")}</Button> : <Button variant="outlined" color="error" onClick={() => void runBulkAction("delete")}>{t("jobTableDeleteSelected")}</Button>}
|
||||
{mode === "jobs" ? ["Waiting", "Interview", "Rejected", "Ghosted", "Offer"].map((s) => <Button key={s} variant="outlined" onClick={() => void runBulkAction("status", s)}>{s}</Button>) : null}
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Menu anchorEl={columnsAnchor} open={Boolean(columnsAnchor)} onClose={() => setColumnsAnchor(null)}>
|
||||
{([ ["status", "Status"], ["dateApplied", "Date applied"], ["daysSince", "Days"], ["jobUrl", "Job URL"] ] as const).map(([key, label]) => (
|
||||
{([ ["status", t("settingsColumnStatus")], ["dateApplied", t("settingsColumnDateApplied")], ["daysSince", t("settingsColumnDays")], ["jobUrl", t("settingsColumnJobUrl")] ] as const).map(([key, label]) => (
|
||||
<MenuItem key={key} onClick={() => onColumnsChange({ ...columns, [key]: !columns[key] })}>
|
||||
<Checkbox checked={columns[key]} />
|
||||
{label}
|
||||
@@ -344,13 +346,13 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox"><Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /></TableCell>
|
||||
<TableCell width={1} />
|
||||
<TableCell sortDirection={sortBy === "company" ? sortDir : false}><TableSortLabel active={sortBy === "company"} direction={sortBy === "company" ? sortDir : "asc"} onClick={() => requestSort("company")}>Company</TableSortLabel></TableCell>
|
||||
<TableCell sortDirection={sortBy === "jobTitle" ? sortDir : false}><TableSortLabel active={sortBy === "jobTitle"} direction={sortBy === "jobTitle" ? sortDir : "asc"} onClick={() => requestSort("jobTitle")}>Role</TableSortLabel></TableCell>
|
||||
{columns.status ? <TableCell sortDirection={sortBy === "status" ? sortDir : false}><TableSortLabel active={sortBy === "status"} direction={sortBy === "status" ? sortDir : "asc"} onClick={() => requestSort("status")}>Status</TableSortLabel></TableCell> : null}
|
||||
{columns.dateApplied ? <TableCell sortDirection={sortBy === "dateApplied" ? sortDir : false}><TableSortLabel active={sortBy === "dateApplied"} direction={sortBy === "dateApplied" ? sortDir : "asc"} onClick={() => requestSort("dateApplied")}>Date Applied</TableSortLabel></TableCell> : null}
|
||||
{columns.daysSince ? <TableCell sortDirection={sortBy === "daysSince" ? sortDir : false}><TableSortLabel active={sortBy === "daysSince"} direction={sortBy === "daysSince" ? sortDir : "asc"} onClick={() => requestSort("daysSince")}>Days</TableSortLabel></TableCell> : null}
|
||||
{columns.jobUrl ? <TableCell>Job URL</TableCell> : null}
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
<TableCell sortDirection={sortBy === "company" ? sortDir : false}><TableSortLabel active={sortBy === "company"} direction={sortBy === "company" ? sortDir : "asc"} onClick={() => requestSort("company")}>{t("jobTableCompany")}</TableSortLabel></TableCell>
|
||||
<TableCell sortDirection={sortBy === "jobTitle" ? sortDir : false}><TableSortLabel active={sortBy === "jobTitle"} direction={sortBy === "jobTitle" ? sortDir : "asc"} onClick={() => requestSort("jobTitle")}>{t("jobTableRole")}</TableSortLabel></TableCell>
|
||||
{columns.status ? <TableCell sortDirection={sortBy === "status" ? sortDir : false}><TableSortLabel active={sortBy === "status"} direction={sortBy === "status" ? sortDir : "asc"} onClick={() => requestSort("status")}>{t("jobTableStatus")}</TableSortLabel></TableCell> : null}
|
||||
{columns.dateApplied ? <TableCell sortDirection={sortBy === "dateApplied" ? sortDir : false}><TableSortLabel active={sortBy === "dateApplied"} direction={sortBy === "dateApplied" ? sortDir : "asc"} onClick={() => requestSort("dateApplied")}>{t("jobTableDateApplied")}</TableSortLabel></TableCell> : null}
|
||||
{columns.daysSince ? <TableCell sortDirection={sortBy === "daysSince" ? sortDir : false}><TableSortLabel active={sortBy === "daysSince"} direction={sortBy === "daysSince" ? sortDir : "asc"} onClick={() => requestSort("daysSince")}>{t("jobTableDays")}</TableSortLabel></TableCell> : null}
|
||||
{columns.jobUrl ? <TableCell>{t("settingsColumnJobUrl")}</TableCell> : null}
|
||||
<TableCell align="right">{t("jobTableActions")}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -367,21 +369,21 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<span>{job.jobTitle}</span>
|
||||
{job.needsFollowUp ? <Chip size="small" label="Follow up" title={job.followUpReason ?? undefined} sx={{ fontWeight: 800 }} /> : null}
|
||||
{!job.tailoredCvText && !job.isDeleted ? <Chip size="small" label="CV missing" color="warning" variant="outlined" /> : null}
|
||||
{job.tailoredCvText ? <Chip size="small" label="CV ready" color="success" variant="outlined" /> : null}
|
||||
{job.needsFollowUp ? <Chip size="small" label={t("jobTableFollowUp")} title={job.followUpReason ?? undefined} sx={{ fontWeight: 800 }} /> : null}
|
||||
{!job.tailoredCvText && !job.isDeleted ? <Chip size="small" label={t("jobTableCvMissing")} color="warning" variant="outlined" /> : null}
|
||||
{job.tailoredCvText ? <Chip size="small" label={t("jobTableCvReady")} color="success" variant="outlined" /> : null}
|
||||
</Box>
|
||||
</TableCell>
|
||||
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null}
|
||||
{columns.dateApplied ? <TableCell>{new Date(job.dateApplied).toLocaleDateString()}</TableCell> : null}
|
||||
{columns.daysSince ? <TableCell>{job.daysSince}</TableCell> : null}
|
||||
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">Link</a> : ""}</TableCell> : null}
|
||||
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null}
|
||||
<TableCell align="right">
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 0.5 }}>
|
||||
<Tooltip title="Edit"><IconButton size="small" onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Quick status"><IconButton size="small" onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Open"><IconButton size="small" onClick={() => setDetailsJobId(job.id)}><LaunchIcon fontSize="small" /></IconButton></Tooltip>
|
||||
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title="Restore"><IconButton size="small" onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title="Soft delete"><IconButton size="small" onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
|
||||
<Tooltip title={t("jobTableEdit")}><IconButton size="small" onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title={t("jobTableQuickStatus")}><IconButton size="small" onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title={t("jobTableOpen")}><IconButton size="small" onClick={() => setDetailsJobId(job.id)}><LaunchIcon fontSize="small" /></IconButton></Tooltip>
|
||||
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title={t("jobTableRestore")}><IconButton size="small" onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title={t("jobTableSoftDelete")}><IconButton size="small" onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -389,11 +391,11 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
<TableCell sx={{ py: 0 }} colSpan={columns.status && columns.dateApplied && columns.daysSince && columns.jobUrl ? 9 : 8}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ p: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||
<Box><Typography variant="overline">Location</Typography><Typography>{job.location ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">Salary</Typography><Typography>{job.salary ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">Job URL</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">Open listing</a> : "-"}</Typography></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Skills</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => <Chip key={tag} label={tag} size="small" />) : <Typography sx={{ color: "text.secondary" }}>No tags</Typography>}</Box></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Overview</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{generateOverview(job) || "No summary yet."}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job.location ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{job.salary ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("settingsColumnJobUrl")}</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a> : "-"}</Typography></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableSkills")}</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => <Chip key={tag} label={tag} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobTableNoTags")}</Typography>}</Box></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableOverview")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{generateOverview(job) || t("jobTableNoSummaryYet")}</Typography></Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
@@ -401,7 +403,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{filteredJobs.length === 0 ? <TableRow><TableCell colSpan={9}><Typography sx={{ py: 2, textAlign: "center" }}>No jobs found.</Typography></TableCell></TableRow> : null}
|
||||
{filteredJobs.length === 0 ? <TableRow><TableCell colSpan={9}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination component="div" count={total} page={page} onPageChange={(_, next) => setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} />
|
||||
@@ -410,7 +412,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} onClose={() => setDetailsJobId(null)} />
|
||||
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((t) => t + 1)} />
|
||||
<Menu anchorEl={statusAnchor} open={Boolean(statusAnchor)} onClose={() => { setStatusAnchor(null); setStatusJobId(null); }}>
|
||||
{["Waiting", "Interview", "Offer", "Rejected", "Ghosted"].map((s) => <MenuItem key={s} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>Set {s}</MenuItem>)}
|
||||
{(["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const).map((s) => <MenuItem key={s} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status: s })}</MenuItem>)}
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
||||
|
||||
import { api } from "../api";
|
||||
import { JobApplication } from "../types";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
const STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
type Status = (typeof STATUSES)[number];
|
||||
@@ -36,6 +37,7 @@ function toneColor(theme: any, status: Status | "Other"): string {
|
||||
|
||||
export default function KanbanBoard() {
|
||||
const theme = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [jobs, setJobs] = useState<JobApplication[]>([]);
|
||||
const [dragJobId, setDragJobId] = useState<number | null>(null);
|
||||
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
@@ -64,9 +66,7 @@ export default function KanbanBoard() {
|
||||
if (!dragJobId) return;
|
||||
setDragJobId(null);
|
||||
await api.patch(`/jobapplications/${dragJobId}/status`, { status });
|
||||
setJobs((prev) =>
|
||||
prev.map((j) => (j.id === dragJobId ? { ...j, status } : j)),
|
||||
);
|
||||
setJobs((prev) => prev.map((j) => (j.id === dragJobId ? { ...j, status } : j)));
|
||||
};
|
||||
|
||||
const setStatus = async (id: number, status: Status) => {
|
||||
@@ -74,9 +74,7 @@ export default function KanbanBoard() {
|
||||
setJobs((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
|
||||
};
|
||||
|
||||
const currentMenuStatus = menuJobId == null
|
||||
? null
|
||||
: normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? "");
|
||||
const currentMenuStatus = menuJobId == null ? null : normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? "");
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
@@ -84,14 +82,7 @@ export default function KanbanBoard() {
|
||||
Drag cards between columns to update status.
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" },
|
||||
gap: 2,
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}>
|
||||
{STATUSES.map((status) => {
|
||||
const c = toneColor(theme, status);
|
||||
const list = groups.get(status) ?? [];
|
||||
@@ -178,28 +169,14 @@ export default function KanbanBoard() {
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Menu
|
||||
anchorEl={menuAnchor}
|
||||
open={Boolean(menuAnchor)}
|
||||
onClose={() => {
|
||||
setMenuAnchor(null);
|
||||
setMenuJobId(null);
|
||||
}}
|
||||
>
|
||||
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={() => { setMenuAnchor(null); setMenuJobId(null); }}>
|
||||
{(["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const)
|
||||
.filter((s) => s !== currentMenuStatus)
|
||||
.map((s) => (
|
||||
<MenuItem
|
||||
key={s}
|
||||
onClick={() => {
|
||||
if (menuJobId) void setStatus(menuJobId, s);
|
||||
setMenuAnchor(null);
|
||||
setMenuJobId(null);
|
||||
}}
|
||||
>
|
||||
Set {s}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem key={s} onClick={() => { if (menuJobId) void setStatus(menuJobId, s); setMenuAnchor(null); setMenuJobId(null); }}>
|
||||
{t("jobTableSetStatus", { status: s })}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
|
||||
import { api } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
type CommandItem = {
|
||||
id: string;
|
||||
@@ -41,6 +42,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAddJob }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
const [jobs, setJobs] = useState<JobSearchItem[]>([]);
|
||||
const [companies, setCompanies] = useState<CompanySearchItem[]>([]);
|
||||
@@ -82,31 +84,31 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
|
||||
const commands = useMemo<CommandItem[]>(() => {
|
||||
const base: CommandItem[] = [
|
||||
{ id: "go-dashboard", label: "Go to dashboard", hint: "Analytics overview", action: () => onNavigate("/dashboard") },
|
||||
{ id: "go-jobs", label: "Go to jobs", hint: "Main applications table", action: () => onNavigate("/jobs") },
|
||||
{ id: "go-reminders", label: "Go to reminders", hint: "Follow-up queue", action: () => onNavigate("/reminders") },
|
||||
{ id: "go-companies", label: "Go to companies", hint: "CRM and source tracking", action: () => onNavigate("/companies") },
|
||||
{ id: "go-settings", label: "Go to settings", hint: "Preferences and admin tools", action: () => onNavigate("/settings") },
|
||||
{ id: "add-job", label: "Add new job", hint: "Open the add-job modal", action: onOpenAddJob },
|
||||
{ id: "go-dashboard", label: t("goToDashboard"), hint: t("analyticsOverview"), action: () => onNavigate("/dashboard") },
|
||||
{ id: "go-jobs", label: t("goToJobs"), hint: t("mainApplicationsTable"), action: () => onNavigate("/jobs") },
|
||||
{ id: "go-reminders", label: t("goToReminders"), hint: t("followUpQueue"), action: () => onNavigate("/reminders") },
|
||||
{ id: "go-companies", label: t("goToCompanies"), hint: t("crmAndSourceTracking"), action: () => onNavigate("/companies") },
|
||||
{ id: "go-settings", label: t("goToSettings"), hint: t("preferencesAndAdminTools"), action: () => onNavigate("/settings") },
|
||||
{ id: "add-job", label: t("addNewJob"), hint: t("openAddJobModal"), action: onOpenAddJob },
|
||||
];
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return base;
|
||||
return base.filter((item) => item.label.toLowerCase().includes(q) || item.hint?.toLowerCase().includes(q));
|
||||
}, [onNavigate, onOpenAddJob, query]);
|
||||
}, [onNavigate, onOpenAddJob, query, t]);
|
||||
|
||||
const allItems: CommandItem[] = [
|
||||
...commands,
|
||||
...jobs.slice(0, 6).map((job) => ({
|
||||
id: `job-${job.id}`,
|
||||
label: `${job.company?.name ?? "Company"} - ${job.jobTitle}`,
|
||||
hint: "Open job list and search result",
|
||||
label: `${job.company?.name ?? t("company")} - ${job.jobTitle}`,
|
||||
hint: t("openJobListAndSearchResult"),
|
||||
action: () => onNavigate(`/jobs?open=${job.id}`),
|
||||
})),
|
||||
...companies.slice(0, 6).map((company) => ({
|
||||
id: `company-${company.id}`,
|
||||
label: company.name,
|
||||
hint: "Open companies",
|
||||
hint: t("openCompanies"),
|
||||
action: () => onNavigate(`/companies?edit=${company.id}`),
|
||||
})),
|
||||
];
|
||||
@@ -120,7 +122,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
autoFocus
|
||||
variant="standard"
|
||||
fullWidth
|
||||
placeholder="Search jobs, companies, or actions"
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
InputProps={{ disableUnderline: true }}
|
||||
@@ -133,7 +135,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
|
||||
<List sx={{ mt: 1 }}>
|
||||
{allItems.length === 0 ? (
|
||||
<Box sx={{ px: 2, py: 3 }}>
|
||||
<Typography sx={{ color: "text.secondary" }}>No matching commands or records.</Typography>
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("noMatchingCommands")}</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
allItems.map((item) => (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material";
|
||||
import { api } from "../api";
|
||||
import { JobApplication } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
import JobDetailsDialog from "./JobDetailsDialog";
|
||||
|
||||
@@ -28,6 +29,7 @@ function groupItems(items: JobApplication[]): ReminderGroups {
|
||||
}
|
||||
|
||||
function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: string; items: JobApplication[]; onOpen: (id: number) => void; onSetFollowUp: (id: number, days: number | null) => void }) {
|
||||
const { t } = useI18n();
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -40,17 +42,17 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin
|
||||
{j.company?.name ?? ""} <span style={{ fontWeight: 700, opacity: 0.7 }}>•</span> {j.jobTitle}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
|
||||
{j.needsFollowUp ? <Chip size="small" color="warning" label="Follow up" /> : null}
|
||||
{j.needsFollowUp ? <Chip size="small" color="warning" label={t("remindersFollowUpLabel")} /> : null}
|
||||
{j.followUpReason ? <Chip size="small" label={j.followUpReason} variant="outlined" /> : null}
|
||||
{j.followUpAt ? <Chip size="small" label={`Follow-up: ${new Date(j.followUpAt).toLocaleDateString()}`} variant="outlined" /> : null}
|
||||
{j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString() })} variant="outlined" /> : null}
|
||||
<Chip size="small" label={j.status} variant="outlined" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
<Button size="small" variant="outlined" onClick={() => onOpen(j.id)}>Open</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onOpen(j.id)}>{t("remindersOpen")}</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 3)}>+3d</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onSetFollowUp(j.id, 7)}>+7d</Button>
|
||||
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>Clear</Button>
|
||||
<Button size="small" onClick={() => onSetFollowUp(j.id, null)}>{t("remindersClear")}</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
@@ -60,6 +62,7 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin
|
||||
|
||||
export default function RemindersView() {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [items, setItems] = useState<JobApplication[]>([]);
|
||||
const [openJobId, setOpenJobId] = useState<number | null>(null);
|
||||
|
||||
@@ -78,32 +81,32 @@ export default function RemindersView() {
|
||||
try {
|
||||
const d = daysFromNow === null ? null : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
await api.patch(`/jobapplications/${id}/followup`, { followUpAt: d });
|
||||
toast(daysFromNow === null ? "Follow-up cleared." : "Follow-up set.", "success");
|
||||
toast(daysFromNow === null ? t("remindersFollowUpCleared") : t("remindersFollowUpSet"), "success");
|
||||
await load();
|
||||
} catch {
|
||||
toast("Failed to set follow-up.", "error");
|
||||
toast(t("remindersFollowUpFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>Needs Follow-up</Typography>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>{t("remindersTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
|
||||
Grouped by the most useful next action so you can fix gaps faster.
|
||||
{t("remindersSubtitle")}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<ReminderSection title="Missing tailored CV" items={grouped.missingCv} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title="Missing interview prep" items={grouped.missingInterviewNotes} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title="Follow-up due" items={grouped.overdueFollowUp} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title="Other reminders" items={grouped.other} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersMissingTailoredCv")} items={grouped.missingCv} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersMissingInterviewPrep")} items={grouped.missingInterviewNotes} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersFollowUpDue")} items={grouped.overdueFollowUp} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
<ReminderSection title={t("remindersOther")} items={grouped.other} onOpen={setOpenJobId} onSetFollowUp={setFollowUp} />
|
||||
|
||||
{items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>Nothing to follow up right now.</Typography> : null}
|
||||
{items.length === 0 ? <Typography sx={{ color: "text.secondary", textAlign: "center", py: 3 }}>{t("remindersNothing")}</Typography> : null}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
Tip: focus on tailored CV and interview prep first for the highest-value roles.
|
||||
{t("remindersTip")}
|
||||
</Typography>
|
||||
|
||||
<JobDetailsDialog open={openJobId !== null} jobId={openJobId} onClose={() => setOpenJobId(null)} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Box, Button, Paper, TextField, Typography } from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
type RuleSettings = {
|
||||
id: number;
|
||||
@@ -17,6 +18,7 @@ type RuleSettings = {
|
||||
|
||||
export default function RulesSettingsCard() {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [s, setS] = useState<RuleSettings | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -29,7 +31,7 @@ export default function RulesSettingsCard() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put("/rules", s);
|
||||
toast("Rules saved.", "success");
|
||||
toast(t("rulesSave"), "success");
|
||||
} catch {
|
||||
toast("Failed to save rules.", "error");
|
||||
} finally {
|
||||
@@ -50,30 +52,29 @@ export default function RulesSettingsCard() {
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Follow-up + Ghosting Rules
|
||||
{t("rulesTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
|
||||
Jobs get a “Follow up” flag based on these thresholds. Ghosting is automatic.
|
||||
{t("rulesBody")}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||
<TextField label="Applied: follow-up days" {...num("appliedFollowUpDays")} />
|
||||
<TextField label="Applied: ghost days" {...num("appliedGhostDays")} />
|
||||
<TextField label={t("rulesAppliedFollowUpDays")} {...num("appliedFollowUpDays")} />
|
||||
<TextField label={t("rulesAppliedGhostDays")} {...num("appliedGhostDays")} />
|
||||
<Box />
|
||||
<TextField label="Offer: follow-up days" {...num("offerFollowUpDays")} />
|
||||
<TextField label="Offer: ghost days" {...num("offerGhostDays")} />
|
||||
<TextField label={t("rulesOfferFollowUpDays")} {...num("offerFollowUpDays")} />
|
||||
<TextField label={t("rulesOfferGhostDays")} {...num("offerGhostDays")} />
|
||||
<Box />
|
||||
<TextField label="Feedback: follow-up days" {...num("feedbackFollowUpDays")} />
|
||||
<TextField label="Feedback: ghost days" {...num("feedbackGhostDays")} />
|
||||
<TextField label={t("rulesFeedbackFollowUpDays")} {...num("feedbackFollowUpDays")} />
|
||||
<TextField label={t("rulesFeedbackGhostDays")} {...num("feedbackGhostDays")} />
|
||||
<Box />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2 }}>
|
||||
<Button variant="contained" onClick={save} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save Rules"}
|
||||
{saving ? t("rulesSaving") : t("rulesSave")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Select,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
@@ -22,8 +21,8 @@ import GoogleAuthCard from "./GoogleAuthCard";
|
||||
import RulesSettingsCard from "./RulesSettingsCard";
|
||||
import BackupCard from "./BackupCard";
|
||||
import AuthStatusCard from "./AuthStatusCard";
|
||||
|
||||
export type ThemeModePref = "system" | "light" | "dark";
|
||||
import { ThemeModePref } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
interface Props {
|
||||
pageSize: 15 | 20 | 25;
|
||||
@@ -42,7 +41,7 @@ function TabPanel({ value, index, children }: { value: number; index: number; ch
|
||||
return <Box sx={{ mt: 2 }}>{children}</Box>;
|
||||
}
|
||||
|
||||
const ACCENTS = ["#7c4dff", "#4f8cff", "#16a34a", "#f59e0b", "#e11d48", "#06b6d4"]; // violet, blue, green, amber, rose, cyan
|
||||
const ACCENTS = ["#15803d", "#16a34a", "#22c55e", "#0f766e", "#0f766e", "#65a30d"];
|
||||
|
||||
export default function SettingsView({
|
||||
pageSize,
|
||||
@@ -56,56 +55,60 @@ export default function SettingsView({
|
||||
onResetAccentColor,
|
||||
}: Props) {
|
||||
const [tab, setTab] = useState(0);
|
||||
const { language, setLanguage, t } = useI18n();
|
||||
|
||||
const accentOk = useMemo(() => /^#[0-9a-fA-F]{6}$/.test(accentColor), [accentColor]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2 }}>
|
||||
<Typography variant="h5" sx={{ mb: 1, fontWeight: 900 }}>
|
||||
Settings
|
||||
{t("settingsTitle")}
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>
|
||||
Preferences and admin tools.
|
||||
{t("settingsSubtitle")}
|
||||
</Typography>
|
||||
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 1 }}>
|
||||
<Tab label="General" />
|
||||
<Tab label="Follow-ups" />
|
||||
<Tab label="Notifications" />
|
||||
<Tab label="Account" />
|
||||
<Tab label="Backup" />
|
||||
<Tab label={t("settingsTabGeneral")} />
|
||||
<Tab label={t("settingsTabFollowUps")} />
|
||||
<Tab label={t("settingsTabNotifications")} />
|
||||
<Tab label={t("settingsTabAccount")} />
|
||||
<Tab label={t("settingsTabBackup")} />
|
||||
</Tabs>
|
||||
|
||||
<TabPanel value={tab} index={0}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 1 }}>Appearance</Typography>
|
||||
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsAppearance")}</Typography>
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel id="theme-mode-label">Theme</InputLabel>
|
||||
<InputLabel id="theme-mode-label">{t("settingsTheme")}</InputLabel>
|
||||
<Select
|
||||
labelId="theme-mode-label"
|
||||
value={themeMode}
|
||||
label="Theme"
|
||||
label={t("settingsTheme")}
|
||||
onChange={(e) => onThemeModeChange(e.target.value as ThemeModePref)}
|
||||
>
|
||||
<MenuItem value="system">System</MenuItem>
|
||||
<MenuItem value="dark">Dark</MenuItem>
|
||||
<MenuItem value="light">Light</MenuItem>
|
||||
<MenuItem value="system">{t("settingsThemeSystem")}</MenuItem>
|
||||
<MenuItem value="dark">{t("settingsThemeDark")}</MenuItem>
|
||||
<MenuItem value="light">{t("settingsThemeLight")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<TextField
|
||||
label="Accent"
|
||||
type="color"
|
||||
value={accentOk ? accentColor : "#7c4dff"}
|
||||
onChange={(e) => onAccentColorChange(e.target.value)}
|
||||
sx={{ width: 160 }}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<input type="hidden" />
|
||||
<FormControl sx={{ width: 160 }}>
|
||||
<Typography variant="caption" sx={{ mb: 0.5 }}>{t("settingsAccent")}</Typography>
|
||||
<input
|
||||
aria-label={t("settingsAccent")}
|
||||
type="color"
|
||||
value={accentOk ? accentColor : "#15803d"}
|
||||
onChange={(e) => onAccentColorChange(e.target.value)}
|
||||
style={{ width: 160, height: 40, border: "none", background: "transparent", padding: 0 }}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button variant="outlined" onClick={onResetAccentColor}>
|
||||
Reset
|
||||
{t("settingsReset")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -129,24 +132,48 @@ export default function SettingsView({
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>
|
||||
Saved per user on this browser.
|
||||
{t("settingsSavedPerUser")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 1 }}>Jobs</Typography>
|
||||
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsLanguageTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
|
||||
{t("settingsLanguageBody")}
|
||||
</Typography>
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel id="language-label">{t("settingsPreferredLanguage")}</InputLabel>
|
||||
<Select
|
||||
labelId="language-label"
|
||||
value={language}
|
||||
label={t("settingsPreferredLanguage")}
|
||||
onChange={(e) => setLanguage(e.target.value as "en" | "no")}
|
||||
>
|
||||
<MenuItem value="en">{t("settingsEnglish")}</MenuItem>
|
||||
<MenuItem value="no">{t("settingsNorwegian")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{t("settingsMorePagesSoon")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsJobs")}</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 3, flexWrap: "wrap" }}>
|
||||
<Box sx={{ minWidth: 240 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Pagination
|
||||
{t("settingsPagination")}
|
||||
</Typography>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="page-size-label">Rows per page</InputLabel>
|
||||
<InputLabel id="page-size-label">{t("settingsRowsPerPage")}</InputLabel>
|
||||
<Select
|
||||
labelId="page-size-label"
|
||||
value={pageSize}
|
||||
label="Rows per page"
|
||||
label={t("settingsRowsPerPage")}
|
||||
onChange={(e) => onPageSizeChange(e.target.value as 15 | 20 | 25)}
|
||||
>
|
||||
<MenuItem value={15}>15</MenuItem>
|
||||
@@ -158,14 +185,14 @@ export default function SettingsView({
|
||||
|
||||
<Box sx={{ minWidth: 240 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Columns
|
||||
{t("settingsColumns")}
|
||||
</Typography>
|
||||
{(
|
||||
[
|
||||
["status", "Status"],
|
||||
["dateApplied", "Date applied"],
|
||||
["daysSince", "Days"],
|
||||
["jobUrl", "Job URL"],
|
||||
["status", t("settingsColumnStatus")],
|
||||
["dateApplied", t("settingsColumnDateApplied")],
|
||||
["daysSince", t("settingsColumnDays")],
|
||||
["jobUrl", t("settingsColumnJobUrl")],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<FormControlLabel
|
||||
@@ -193,9 +220,9 @@ export default function SettingsView({
|
||||
|
||||
<TabPanel value={tab} index={2}>
|
||||
<Paper sx={{ p: 2, mt: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>Email notifications</Typography>
|
||||
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsNotificationsTitle")}</Typography>
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
Notifications are sent via SMTP (Gmail works). Configure SMTP in the API (`Email:*` settings or env vars like `EMAIL_SMTP_HOST`).
|
||||
{t("settingsNotificationsBody")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</TabPanel>
|
||||
|
||||
Reference in New Issue
Block a user