Polish UI, harden company creation, and add error pages

This commit is contained in:
cesnimda
2026-03-23 19:34:29 +01:00
parent 8f5eab2fe4
commit fcafda6f52
38 changed files with 2293 additions and 1269 deletions
+207 -84
View File
@@ -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>
);
}
+150 -277
View File
@@ -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>
+53 -51
View File
@@ -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>
);
+10 -33
View File
@@ -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) => (
+17 -14
View File
@@ -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>
);
}
+65 -38
View File
@@ -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>