346 lines
16 KiB
TypeScript
346 lines
16 KiB
TypeScript
import React, { useEffect, useMemo, useState } from "react";
|
|
import { useLocation, useNavigate } from "react-router-dom";
|
|
|
|
import {
|
|
Box,
|
|
Alert,
|
|
Button,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogTitle,
|
|
IconButton,
|
|
Paper,
|
|
Stack,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TableRow,
|
|
TextField,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
|
|
|
import { api, getApiErrorMessage } from "../api";
|
|
import ViewStateNotice from "./ViewStateNotice";
|
|
import { Company } from "../types";
|
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
|
import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined";
|
|
import { useToast } from "../toast";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
import { useViewResource } from "../hooks/useViewResource";
|
|
|
|
export default function CompaniesTable() {
|
|
const isMobile = useMediaQuery("(max-width:767.95px)");
|
|
const { toast } = useToast();
|
|
const { language, t } = useI18n();
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const [editOpen, setEditOpen] = useState(false);
|
|
const [editing, setEditing] = useState<Company | null>(null);
|
|
const [relationshipOpen, setRelationshipOpen] = useState(false);
|
|
const [relationshipLoading, setRelationshipLoading] = useState(false);
|
|
const [relationshipError, setRelationshipError] = useState("");
|
|
const [relationship, setRelationship] = useState<CompanyRelationship | null>(null);
|
|
|
|
const [recruiterName, setRecruiterName] = useState("");
|
|
const [recruiterEmail, setRecruiterEmail] = useState("");
|
|
const [recruiterLinkedIn, setRecruiterLinkedIn] = useState("");
|
|
const [pipelineStage, setPipelineStage] = useState("");
|
|
const [lastContactedAt, setLastContactedAt] = useState("");
|
|
const [nextContactAt, setNextContactAt] = useState("");
|
|
|
|
const companiesResource = useViewResource(
|
|
async () => {
|
|
const response = await api.get<Company[]>("/companies");
|
|
return response.data;
|
|
},
|
|
{
|
|
initialData: [],
|
|
errorMessage: t("companiesLoadFailed"),
|
|
deps: [t],
|
|
},
|
|
);
|
|
|
|
const companies = companiesResource.data;
|
|
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(location.search);
|
|
const editId = Number(params.get("edit") || 0);
|
|
if (!editId || companies.length === 0) return;
|
|
const company = companies.find((c) => c.id === editId);
|
|
if (!company) return;
|
|
openEdit(company);
|
|
params.delete("edit");
|
|
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : "" }, { replace: true });
|
|
}, [companies, location.pathname, location.search, navigate]);
|
|
|
|
const openEdit = (c: Company) => {
|
|
setEditing(c);
|
|
setRecruiterName(c.recruiterName ?? "");
|
|
setRecruiterEmail(c.recruiterEmail ?? "");
|
|
setRecruiterLinkedIn(c.recruiterLinkedIn ?? "");
|
|
setPipelineStage(c.pipelineStage ?? "");
|
|
setLastContactedAt((c.lastContactedAt ?? "").slice(0, 10));
|
|
setNextContactAt((c.nextContactAt ?? "").slice(0, 10));
|
|
setEditOpen(true);
|
|
};
|
|
|
|
const openRelationship = async (company: Company) => {
|
|
setRelationshipOpen(true); setRelationshipLoading(true); setRelationshipError(""); setRelationship(null);
|
|
try {
|
|
const response = await api.get<CompanyRelationship>(`/companies/${company.id}/relationship`);
|
|
setRelationship(response.data);
|
|
} catch (error) {
|
|
setRelationshipError(getApiErrorMessage(error, t("companiesRelationshipLoadFailed")));
|
|
} finally {
|
|
setRelationshipLoading(false);
|
|
}
|
|
};
|
|
|
|
const canSave = useMemo(() => !!editing?.id, [editing]);
|
|
|
|
const save = async () => {
|
|
if (!editing?.id) return;
|
|
try {
|
|
const res = await api.put<Company>(`/companies/${editing.id}`, {
|
|
name: editing.name,
|
|
location: editing.location ?? null,
|
|
source: editing.source ?? null,
|
|
recruiterName: recruiterName.trim() || null,
|
|
recruiterEmail: recruiterEmail.trim() || null,
|
|
recruiterLinkedIn: recruiterLinkedIn.trim() || null,
|
|
pipelineStage: pipelineStage.trim() || null,
|
|
lastContactedAt: lastContactedAt || null,
|
|
nextContactAt: nextContactAt || null,
|
|
});
|
|
|
|
companiesResource.setData((prev) => prev.map((x) => (x.id === res.data.id ? res.data : x)));
|
|
toast(t("companiesUpdated"), "success");
|
|
setEditOpen(false);
|
|
setEditing(null);
|
|
} catch (error) {
|
|
toast(getApiErrorMessage(error, t("companiesUpdateFailed")), "error");
|
|
}
|
|
};
|
|
|
|
const renderCompanyMeta = (label: string, value?: string | null) => (
|
|
<Box>
|
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>{label}</Typography>
|
|
<Typography variant="body2" sx={{ overflowWrap: "anywhere" }}>{value || "—"}</Typography>
|
|
</Box>
|
|
);
|
|
|
|
return (
|
|
<Paper sx={{ mt: 0, p: { xs: 1.5, sm: 0 } }}>
|
|
<ViewStateNotice
|
|
loading={companiesResource.loading}
|
|
error={companiesResource.error}
|
|
title={t("companiesLoadFailed")}
|
|
description={t("companiesLoadFailedBody")}
|
|
onRetry={companiesResource.reload}
|
|
/>
|
|
|
|
{!companiesResource.loading && !companiesResource.error ? (
|
|
isMobile ? (
|
|
<Stack spacing={1.5}>
|
|
{companies.map((c) => (
|
|
<Paper key={c.id} sx={{ p: 1.5, borderRadius: 3 }}>
|
|
<Stack spacing={1.25}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1 }}>
|
|
<Box>
|
|
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{c.name}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{c.location || t("companiesLocation")}</Typography>
|
|
</Box>
|
|
<IconButton size="small" aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}>
|
|
<EditOutlinedIcon fontSize="small" />
|
|
</IconButton>
|
|
<IconButton size="small" aria-label={`${t("companiesRelationship")}: ${c.name}`} onClick={() => void openRelationship(c)}>
|
|
<PeopleAltOutlinedIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
|
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}>
|
|
{renderCompanyMeta(t("companiesSource"), c.source)}
|
|
{renderCompanyMeta(t("companiesPipeline"), c.pipelineStage)}
|
|
{renderCompanyMeta(t("companiesRecruiter"), [c.recruiterName, c.recruiterEmail].filter(Boolean).join(" · "))}
|
|
{renderCompanyMeta(t("companiesNextContact"), c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : null)}
|
|
</Box>
|
|
</Stack>
|
|
</Paper>
|
|
))}
|
|
{companies.length === 0 ? (
|
|
<Typography sx={{ py: 2, textAlign: "center" }}>
|
|
{t("companiesEmpty")}
|
|
</Typography>
|
|
) : null}
|
|
</Stack>
|
|
) : (
|
|
<TableContainer sx={{ borderRadius: 3, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
|
<Table>
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>{t("companiesName")}</TableCell>
|
|
<TableCell>{t("companiesLocation")}</TableCell>
|
|
<TableCell>{t("companiesSource")}</TableCell>
|
|
<TableCell>{t("companiesPipeline")}</TableCell>
|
|
<TableCell>{t("companiesRecruiter")}</TableCell>
|
|
<TableCell>{t("companiesNextContact")}</TableCell>
|
|
<TableCell width={1} align="right" />
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{companies.map((c) => (
|
|
<TableRow key={c.id}>
|
|
<TableCell>{c.name}</TableCell>
|
|
<TableCell>{c.location ?? ""}</TableCell>
|
|
<TableCell>{c.source ?? ""}</TableCell>
|
|
<TableCell>{c.pipelineStage ?? ""}</TableCell>
|
|
<TableCell>
|
|
{c.recruiterName ?? ""}
|
|
{c.recruiterEmail ? ` (${c.recruiterEmail})` : ""}
|
|
</TableCell>
|
|
<TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""}</TableCell>
|
|
<TableCell align="right">
|
|
<IconButton size="small" aria-label={`${t("companiesRelationship")}: ${c.name}`} onClick={() => void openRelationship(c)}>
|
|
<PeopleAltOutlinedIcon fontSize="small" />
|
|
</IconButton>
|
|
<IconButton size="small" aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}>
|
|
<EditOutlinedIcon fontSize="small" />
|
|
</IconButton>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
{companies.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={7}>
|
|
<Typography sx={{ py: 2, textAlign: "center" }}>
|
|
{t("companiesEmpty")}
|
|
</Typography>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
)
|
|
) : null}
|
|
|
|
<Dialog open={editOpen} onClose={() => setEditOpen(false)} fullWidth fullScreen={isMobile} maxWidth="sm">
|
|
<DialogTitle>{t("companiesEdit")}</DialogTitle>
|
|
<DialogContent>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
|
<TextField
|
|
label={t("companiesName")}
|
|
value={editing?.name ?? ""}
|
|
onChange={(e) => setEditing((p) => (p ? { ...p, name: e.target.value } : p))}
|
|
sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
|
|
/>
|
|
<TextField
|
|
label={t("companiesLocation")}
|
|
value={editing?.location ?? ""}
|
|
onChange={(e) => setEditing((p) => (p ? { ...p, location: e.target.value } : p))}
|
|
/>
|
|
<TextField
|
|
label={t("companiesSource")}
|
|
value={editing?.source ?? ""}
|
|
onChange={(e) => setEditing((p) => (p ? { ...p, source: e.target.value } : p))}
|
|
/>
|
|
|
|
<TextField
|
|
label={t("companiesPipelineStage")}
|
|
value={pipelineStage}
|
|
onChange={(e) => setPipelineStage(e.target.value)}
|
|
/>
|
|
<TextField
|
|
label={t("companiesRecruiterName")}
|
|
value={recruiterName}
|
|
onChange={(e) => setRecruiterName(e.target.value)}
|
|
/>
|
|
<TextField
|
|
label={t("companiesRecruiterEmail")}
|
|
value={recruiterEmail}
|
|
onChange={(e) => setRecruiterEmail(e.target.value)}
|
|
/>
|
|
<TextField
|
|
label={t("companiesRecruiterLinkedIn")}
|
|
value={recruiterLinkedIn}
|
|
onChange={(e) => setRecruiterLinkedIn(e.target.value)}
|
|
sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
|
|
/>
|
|
|
|
<TextField
|
|
label={t("companiesLastContacted")}
|
|
type="date"
|
|
value={lastContactedAt}
|
|
onChange={(e) => setLastContactedAt(e.target.value)}
|
|
InputLabelProps={{ shrink: true }}
|
|
/>
|
|
<TextField
|
|
label={t("companiesNextContactField")}
|
|
type="date"
|
|
value={nextContactAt}
|
|
onChange={(e) => setNextContactAt(e.target.value)}
|
|
InputLabelProps={{ shrink: true }}
|
|
/>
|
|
</Box>
|
|
</DialogContent>
|
|
<DialogActions sx={{ px: 3, pb: 3, flexDirection: { xs: "column-reverse", sm: "row" }, gap: 1 }}>
|
|
<Button onClick={() => setEditOpen(false)} fullWidth={isMobile}>{t("cancel")}</Button>
|
|
<Button variant="contained" onClick={save} disabled={!canSave} fullWidth={isMobile}>
|
|
{t("save")}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
<Dialog open={relationshipOpen} onClose={() => setRelationshipOpen(false)} fullWidth fullScreen={isMobile} maxWidth="md">
|
|
<DialogTitle>{relationship?.company.name ?? t("companiesRelationship")}</DialogTitle>
|
|
<DialogContent>
|
|
{relationshipLoading ? <Typography color="text.secondary">{t("loading")}</Typography> : null}
|
|
{relationshipError ? <Alert severity="error">{relationshipError}</Alert> : null}
|
|
{relationship ? (
|
|
<Stack spacing={2} sx={{ mt: 0.5 }}>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(3, minmax(0, 1fr))" }, gap: 1.5 }}>
|
|
{renderCompanyMeta(t("companiesRecruiter"), [relationship.company.recruiterName, relationship.company.recruiterEmail].filter(Boolean).join(" · "))}
|
|
{renderCompanyMeta(t("companiesLastContacted"), relationship.company.lastContactedAt ? new Date(relationship.company.lastContactedAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : null)}
|
|
{renderCompanyMeta(t("companiesNextContact"), relationship.company.nextContactAt ? new Date(relationship.company.nextContactAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : null)}
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("companiesApplications")}</Typography>
|
|
{relationship.applications.length === 0 ? <Typography variant="body2" color="text.secondary">{t("companiesNoApplications")}</Typography> : (
|
|
<Stack spacing={1}>{relationship.applications.map((application) => (
|
|
<Paper key={application.id} variant="outlined" sx={{ p: 1.25, display: "flex", flexWrap: "wrap", gap: 1, alignItems: "center" }}>
|
|
<Box sx={{ flex: "1 1 220px" }}><Typography sx={{ fontWeight: 700 }}>{application.jobTitle}</Typography><Typography variant="caption" color="text.secondary">{application.status} · {t("companiesMessageCount", { count: application.messageCount })}</Typography></Box>
|
|
<Button size="small" onClick={() => { setRelationshipOpen(false); navigate(`/jobs/${application.id}`); }}>{t("notificationsOpen")}</Button>
|
|
</Paper>
|
|
))}</Stack>
|
|
)}
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("companiesRecentContact")}</Typography>
|
|
{relationship.recentContacts.length === 0 ? <Typography variant="body2" color="text.secondary">{t("companiesNoContact")}</Typography> : (
|
|
<Stack spacing={1}>{relationship.recentContacts.map((contact) => (
|
|
<Box key={contact.id} sx={{ pb: 1, borderBottom: 1, borderColor: "divider" }}>
|
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>{contact.subject || contact.channel || t("companiesContact")}</Typography>
|
|
<Typography variant="caption" color="text.secondary">{contact.jobTitle} · {new Date(contact.date).toLocaleDateString(language === "nb" ? "nb-NO" : "en")} · {contact.from}</Typography>
|
|
</Box>
|
|
))}</Stack>
|
|
)}
|
|
</Box>
|
|
</Stack>
|
|
) : null}
|
|
</DialogContent>
|
|
<DialogActions><Button onClick={() => setRelationshipOpen(false)}>{t("close")}</Button></DialogActions>
|
|
</Dialog>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
type CompanyRelationship = {
|
|
company: Company;
|
|
applications: Array<{ id: number; jobTitle: string; status: string; dateApplied?: string; followUpAt?: string; messageCount: number; lastContactAt?: string }>;
|
|
recentContacts: Array<{ id: number; jobApplicationId: number; jobTitle: string; date: string; direction?: string; channel?: string; subject?: string; from: string }>;
|
|
};
|