Files
jobtrackingapp/job-tracker-ui/src/components/ImportExportJobs.tsx
T

280 lines
8.5 KiB
TypeScript

import React, { useMemo, useState } from "react";
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">;
};
function downloadText(filename: string, text: string, mime: string) {
const blob = new Blob([text], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let cur = "";
let i = 0;
let inQuotes = false;
const pushCell = () => {
row.push(cur);
cur = "";
};
const pushRow = () => {
if (row.length === 1 && row[0] === "" && rows.length > 0) {
row = [];
return;
}
rows.push(row);
row = [];
};
while (i < text.length) {
const ch = text[i];
if (inQuotes) {
if (ch === '"') {
const next = text[i + 1];
if (next === '"') {
cur += '"';
i += 2;
continue;
}
inQuotes = false;
i += 1;
continue;
}
cur += ch;
i += 1;
continue;
}
if (ch === '"') {
inQuotes = true;
i += 1;
continue;
}
if (ch === ",") {
pushCell();
i += 1;
continue;
}
if (ch === "\r") {
i += 1;
continue;
}
if (ch === "\n") {
pushCell();
pushRow();
i += 1;
continue;
}
cur += ch;
i += 1;
}
pushCell();
pushRow();
return rows;
}
function parseDateDMY(s: string): string | null {
const v = (s || "").trim();
if (!v) return null;
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;
return `${String(yyyy).padStart(4, "0")}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`;
}
function csvToImportJobs(csvText: string): ImportJob[] {
const rows = parseCsv(csvText);
if (!rows.length) return [];
const header = rows[0].map((h) => h.trim());
const idx = (name: string) => header.findIndex((h) => h.toLowerCase() === name.toLowerCase());
const iJobTitle = idx("Job Title");
const iCompany = idx("Company");
const iLocation = idx("Location");
const iSource = idx("Source");
const iDateApplied = idx("Date Applied");
const iMethod = idx("Application Method");
const iCover = idx("Cover Letter / Resume");
const iResp = idx("Response Received");
const iRespDate = idx("Response Date");
const iStatus = idx("Status");
const iNotes = idx("Notes");
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() : "");
const jobTitle = get(iJobTitle);
const companyName = get(iCompany) || jobTitle;
if (!jobTitle && !companyName) continue;
const rawStatus = get(iStatus);
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;
const interviewDate = parseDateDMY(get(iInterviewDate)) ?? "";
const notesParts: string[] = [];
const baseNotes = get(iNotes);
if (baseNotes) notesParts.push(baseNotes);
const method = get(iMethod);
if (method) notesParts.push(`Application Method: ${method}`);
const cover = get(iCover);
if (cover) notesParts.push(`Cover Letter / Resume: ${cover}`);
if (interviewDate) notesParts.push(`Interview Date: ${interviewDate}`);
if (responseDate) notesParts.push(`Response Date: ${responseDate}`);
out.push({
jobTitle: jobTitle || "(missing title)",
status,
dateApplied,
responseReceived,
responseDate,
notes: notesParts.join("\n") || undefined,
coverLetterText: undefined,
jobUrl: undefined,
daysSince: 0,
company: {
name: companyName || "(missing company)",
location: get(iLocation) || undefined,
source: get(iSource) || undefined,
},
location: get(iLocation) || undefined,
salary: undefined,
nextAction: status === "Waiting" ? "Follow up" : undefined,
followUpAt: undefined,
isDeleted: false,
deletedAt: undefined,
});
}
return out;
}
export default function ImportExportJobs() {
const { toast } = useToast();
const { t } = useI18n();
const [importing, setImporting] = useState(false);
const [lastImportCount, setLastImportCount] = useState<number | null>(null);
const exportAll = async (format: "json" | "csv") => {
try {
const stamp = new Date().toISOString().slice(0, 10);
const url = `/export/jobs?format=${format}&includeDeleted=false`;
const res = await api.get(url, { responseType: "blob" });
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(t("exportFailed"), "error");
}
};
const onImportFile = async (file: File | null) => {
if (!file) return;
setImporting(true);
setLastImportCount(null);
try {
const text = await file.text();
const parsed = JSON.parse(text) as ImportJob[];
if (!Array.isArray(parsed)) throw new Error("Invalid format");
let created = 0;
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,
status: j.status,
dateApplied: j.dateApplied,
location: j.location,
salary: j.salary,
nextAction: j.nextAction,
followUpAt: j.followUpAt,
jobUrl: j.jobUrl,
notes: j.notes,
coverLetterText: j.coverLetterText,
});
created += 1;
}
setLastImportCount(created);
toast(t("importedJobs", { count: created }), "success");
} catch {
toast(t("importFailedJson"), "error");
} finally {
setImporting(false);
}
};
const onConvertCsv = async (file: File | null) => {
if (!file) return;
try {
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(t("convertedRows", { count: jobs.length }), "success");
} catch {
toast(t("csvConversionFailed"), "error");
}
};
const helper = useMemo(() => t("importExportBody"), [t]);
return (
<Paper sx={{ mt: 2, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
{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")}>{t("exportJson")}</Button>
<Button variant="outlined" onClick={() => void exportAll("csv")}>{t("exportCsv")}</Button>
<Button variant="contained" component="label" disabled={importing}>
{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">
{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" }}>
{t("lastImport", { count: lastImportCount })}
</Typography>
)}
</Box>
</Paper>
);
}