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
@@ -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>