First Commit

This commit is contained in:
cesnimda
2026-03-21 11:55:27 +01:00
commit 2e8a29b4d0
1757 changed files with 166084 additions and 0 deletions
@@ -0,0 +1,248 @@
import React, { useEffect, useState } from "react";
import { Box, Chip, Dialog, DialogContent, DialogTitle, Tab, Tabs, Typography } from "@mui/material";
import { api } from "../api";
import { JobApplication } from "../types";
import Correspondence from "./Correspondence";
import Attachments from "./Attachments";
import JobFlowBar from "./JobFlowBar";
interface Props {
open: boolean;
jobId: number | null;
onClose: () => void;
}
function statusChipColor(status: string): "default" | "primary" | "warning" | "error" | "success" {
switch (status) {
case "Rejected":
return "error";
case "Waiting":
case "Ghosted":
return "warning";
case "Offer":
return "success";
case "Applied":
default:
return "primary";
}
}
export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
const [job, setJob] = useState<JobApplication | null>(null);
const [tab, setTab] = useState(0);
const [history, setHistory] = useState<
{ id: number; type: string; oldValue?: string; newValue?: string; note?: string; at: string }[]
>([]);
const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
if (!open || !jobId) return;
setTab(0);
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => setJob(r.data));
api
.get(`/auth/me`)
.then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin"))))
.catch(() => setIsAdmin(false));
api
.get(`/jobapplications/${jobId}/history`)
.then((r) => setHistory(r.data))
.catch(() => setHistory([]));
}, [open, jobId]);
const tags: string[] = (() => {
const raw = job?.tags;
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : [];
} catch {
return [];
}
})();
const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : "Job Application";
const checklist =
[job?.hasResume ? "Resume" : null, job?.hasCoverLetter ? "Cover letter" : null, job?.hasPortfolio ? "Portfolio" : null, job?.hasOtherAttachment ? "Other" : null]
.filter(Boolean)
.join(", ") || "";
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<DialogTitle>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
flexWrap: "wrap",
}}
>
<Typography variant="h6">{title}</Typography>
{job && <Chip label={job.status} color={statusChipColor(job.status)} size="small" />}
</Box>
</DialogTitle>
<DialogContent>
<JobFlowBar job={job} />
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label="Overview" />
<Tab label="Correspondence" />
<Tab label="Attachments" />
<Tab label="Cover Letter" />
{isAdmin ? <Tab label="History" /> : null}
</Tabs>
{tab === 0 && (
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
<Box>
<Typography variant="overline">Date Applied</Typography>
<Typography>{job ? new Date(job.dateApplied).toLocaleDateString() : ""}</Typography>
</Box>
<Box>
<Typography variant="overline">Days Since</Typography>
<Typography>{job?.daysSince ?? ""}</Typography>
</Box>
<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">Next Action</Typography>
<Typography>{job?.nextAction ?? ""}</Typography>
</Box>
<Box>
<Typography variant="overline">Follow Up</Typography>
<Typography>{job?.followUpAt ? new Date(job.followUpAt).toLocaleDateString() : ""}</Typography>
</Box>
<Box>
<Typography variant="overline">Deadline</Typography>
<Typography>{job?.deadline ? new Date(job.deadline).toLocaleDateString() : ""}</Typography>
</Box>
<Box>
<Typography variant="overline">Tags</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
{tags.length === 0 ? (
<Typography sx={{ color: "text.secondary" }}>-</Typography>
) : (
tags.map((t) => <Chip key={t} label={t} size="small" />)
)}
</Box>
</Box>
<Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="overline">Attachment Types</Typography>
<Typography>{checklist}</Typography>
</Box>
<Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="overline">Job URL</Typography>
<Typography>
{job?.jobUrl ? (
<a href={job.jobUrl} target="_blank" rel="noreferrer">
{job.jobUrl}
</a>
) : (
""
)}
</Typography>
</Box>
<Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="overline">Description (original)</Typography>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.description ?? ""}</Typography>
</Box>
{job?.translatedDescription ? (
<Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="overline">Translated description</Typography>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job.translatedDescription}</Typography>
</Box>
) : null}
{job?.fullSummary || job?.shortSummary ? (
<Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
<Typography variant="overline">Summary</Typography>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.fullSummary ?? job?.shortSummary}</Typography>
</Box>
) : null}
<Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="overline">Notes</Typography>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.notes ?? ""}</Typography>
</Box>
</Box>
)}
{tab === 1 && jobId && <Correspondence jobId={jobId} />}
{tab === 2 && jobId && <Attachments jobId={jobId} />}
{tab === 3 && (
<Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}>
<button onClick={() => navigator.clipboard.writeText(job?.coverLetterText || "")}>Copy</button>
</Box>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.coverLetterText ?? ""}</Typography>
</Box>
)}
{tab === 4 && isAdmin && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{history.length === 0 ? (
<Typography sx={{ color: "text.secondary" }}>No history yet.</Typography>
) : (
history.map((e) => (
<PaperRow key={e.id} type={e.type} oldValue={e.oldValue} newValue={e.newValue} at={e.at} note={e.note} />
))
)}
</Box>
)}
</DialogContent>
</Dialog>
);
}
function PaperRow({
type,
oldValue,
newValue,
at,
note,
}: {
type: string;
oldValue?: string;
newValue?: string;
at: string;
note?: string;
}) {
return (
<Box
sx={{
border: "1px solid rgba(15,23,42,0.08)",
borderRadius: 2,
p: 1.25,
background: "rgba(255,255,255,0.6)",
}}
>
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
{type}
{oldValue || newValue ? (
<span style={{ fontWeight: 700, opacity: 0.7 }}>
{" "}
({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""})
</span>
) : null}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{at ? new Date(at).toLocaleString() : ""}
{note ? ` - ${note}` : ""}
</Typography>
</Box>
);
}