First Commit
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
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";
|
||||
|
||||
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 = () => {
|
||||
// ignore trailing empty rows
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 [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" });
|
||||
|
||||
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");
|
||||
} catch {
|
||||
toast("Export failed.", "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(`Imported ${created} jobs.`, "success");
|
||||
} catch {
|
||||
toast("Import failed (expecting exported JSON array).", "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(`Converted ${jobs.length} rows to import JSON.`, "success");
|
||||
} catch {
|
||||
toast("CSV conversion failed.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const helper = useMemo(
|
||||
() =>
|
||||
"Import expects the JSON exported by this app (an array of job objects with embedded company).",
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Import / Export
|
||||
</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="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)}
|
||||
/>
|
||||
</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)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{lastImportCount !== null && (
|
||||
<Typography sx={{ ml: 1, color: "text.secondary" }}>
|
||||
Last import: {lastImportCount}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user