Polish UI, harden company creation, and add error pages
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user