feat(ui): migrate company relationships and save recovery
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
@@ -30,7 +30,8 @@ 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";
|
||||
import { useViewResource, type ViewResourceError } from "../hooks/useViewResource";
|
||||
import { statusLabel } from "../pipeline";
|
||||
|
||||
export default function CompaniesTable() {
|
||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||
@@ -39,12 +40,22 @@ export default function CompaniesTable() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editError, setEditError] = useState("");
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<Company | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const savePending = useRef(false);
|
||||
const editContentRef = useRef<HTMLDivElement>(null);
|
||||
const editCompanyIdRef = useRef<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [focusReturn, setFocusReturn] = useState<{ action: "edit" | "relationship"; id?: number | null } | null>(null);
|
||||
const [relationshipOpen, setRelationshipOpen] = useState(false);
|
||||
const [relationshipCompany, setRelationshipCompany] = useState<Company | null>(null);
|
||||
const [relationshipLoading, setRelationshipLoading] = useState(false);
|
||||
const [relationshipError, setRelationshipError] = useState("");
|
||||
const [relationshipError, setRelationshipError] = useState<ViewResourceError | null>(null);
|
||||
const [relationship, setRelationship] = useState<CompanyRelationship | null>(null);
|
||||
const relationshipSequence = useRef(0);
|
||||
const relationshipPending = useRef(false);
|
||||
const relationshipContentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [recruiterName, setRecruiterName] = useState("");
|
||||
const [recruiterEmail, setRecruiterEmail] = useState("");
|
||||
@@ -60,14 +71,14 @@ export default function CompaniesTable() {
|
||||
},
|
||||
{
|
||||
initialData: [],
|
||||
errorMessage: t("companiesLoadFailed"),
|
||||
deps: [t],
|
||||
errorMessage: "",
|
||||
},
|
||||
);
|
||||
|
||||
const companies = companiesResource.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (savePending.current) return;
|
||||
const params = new URLSearchParams(location.search);
|
||||
const editId = Number(params.get("edit") || 0);
|
||||
if (!editId || companies.length === 0) return;
|
||||
@@ -75,11 +86,22 @@ export default function CompaniesTable() {
|
||||
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]);
|
||||
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : "", hash: location.hash }, { replace: true, state: location.state });
|
||||
}, [companies, location.pathname, location.search, location.hash, location.state, navigate, saving]);
|
||||
|
||||
useEffect(() => () => { relationshipSequence.current += 1; }, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Restore after MUI has removed the exiting modal.
|
||||
if (focusReturn?.id && document.activeElement === document.body) {
|
||||
containerRef.current?.querySelector<HTMLButtonElement>(`[data-company-${focusReturn.action}="${focusReturn.id}"]`)?.focus();
|
||||
}
|
||||
}, [focusReturn]);
|
||||
|
||||
const openEdit = (c: Company) => {
|
||||
setEditError("");
|
||||
if (savePending.current) return;
|
||||
editCompanyIdRef.current = c.id;
|
||||
setEditError(null);
|
||||
setEditing(c);
|
||||
setRecruiterName(c.recruiterName ?? "");
|
||||
setRecruiterEmail(c.recruiterEmail ?? "");
|
||||
@@ -91,22 +113,40 @@ export default function CompaniesTable() {
|
||||
};
|
||||
|
||||
const openRelationship = async (company: Company) => {
|
||||
setRelationshipOpen(true); setRelationshipLoading(true); setRelationshipError(""); setRelationship(null);
|
||||
const request = ++relationshipSequence.current;
|
||||
relationshipPending.current = true;
|
||||
setRelationshipCompany(company);
|
||||
setRelationshipOpen(true); setRelationshipLoading(true); setRelationshipError(null); setRelationship(null);
|
||||
try {
|
||||
const response = await api.get<CompanyRelationship>(`/companies/${company.id}/relationship`);
|
||||
setRelationship(response.data);
|
||||
if (request === relationshipSequence.current) setRelationship(response.data);
|
||||
} catch (error) {
|
||||
setRelationshipError(getApiErrorMessage(error, t("companiesRelationshipLoadFailed")));
|
||||
if (request !== relationshipSequence.current) return;
|
||||
const status = (error as { response?: { status?: number } })?.response?.status;
|
||||
const unauthorized = status === 401 || status === 403;
|
||||
setRelationshipError({ kind: unauthorized ? "unauthorized" : "unavailable", message: getApiErrorMessage(error, ""), retryable: !unauthorized, status });
|
||||
} finally {
|
||||
setRelationshipLoading(false);
|
||||
if (request === relationshipSequence.current) {
|
||||
relationshipPending.current = false;
|
||||
setRelationshipLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closeRelationship = () => {
|
||||
relationshipSequence.current += 1;
|
||||
relationshipPending.current = false;
|
||||
setRelationshipOpen(false);
|
||||
};
|
||||
|
||||
const canSave = useMemo(() => !!editing?.id, [editing]);
|
||||
|
||||
const save = async () => {
|
||||
if (!editing?.id) return;
|
||||
setEditError("");
|
||||
if (!editing?.id || savePending.current) return;
|
||||
savePending.current = true;
|
||||
editContentRef.current?.focus();
|
||||
setSaving(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
const res = await api.put<Company>(`/companies/${editing.id}`, {
|
||||
name: editing.name,
|
||||
@@ -125,7 +165,10 @@ export default function CompaniesTable() {
|
||||
setEditOpen(false);
|
||||
setEditing(null);
|
||||
} catch (error) {
|
||||
setEditError(getApiErrorMessage(error, t("companiesUpdateFailed")));
|
||||
setEditError(getApiErrorMessage(error, ""));
|
||||
} finally {
|
||||
savePending.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,10 +180,10 @@ export default function CompaniesTable() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: { xs: 1.5, sm: 0 } }}>
|
||||
<Box ref={containerRef} sx={{ minWidth: 0 }}>
|
||||
<ViewStateNotice
|
||||
loading={companiesResource.loading}
|
||||
error={companiesResource.error}
|
||||
error={companiesResource.error ? { ...companiesResource.error, message: companiesResource.error.message || t("companiesLoadFailed") } : null}
|
||||
title={t("companiesLoadFailed")}
|
||||
description={t("companiesLoadFailedBody")}
|
||||
onRetry={companiesResource.reload}
|
||||
@@ -150,17 +193,17 @@ export default function CompaniesTable() {
|
||||
isMobile ? (
|
||||
<Stack spacing={1.5}>
|
||||
{companies.map((c) => (
|
||||
<Paper key={c.id} sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Paper key={c.id} component="article" aria-label={c.name} variant="outlined" sx={{ p: "var(--app-card-padding)", minWidth: 0 }}>
|
||||
<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>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography component="h2" variant="sectionTitle" sx={{ 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)}>
|
||||
<IconButton size="small" data-company-edit={c.id} 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)}>
|
||||
<IconButton size="small" data-company-relationship={c.id} aria-label={`${t("companiesRelationship")}: ${c.name}`} onClick={() => void openRelationship(c)}>
|
||||
<PeopleAltOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
@@ -181,8 +224,8 @@ export default function CompaniesTable() {
|
||||
) : 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>
|
||||
<TableContainer component={Paper} variant="outlined" role="region" aria-label={t("companies")} tabIndex={0} sx={{ maxWidth: "100%", overflow: "auto" }}>
|
||||
<Table aria-label={t("companies")} sx={{ minWidth: 840, "& th, & td": { overflowWrap: "anywhere" }, "& thead th": { whiteSpace: "nowrap" } }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t("companiesName")}</TableCell>
|
||||
@@ -191,26 +234,26 @@ export default function CompaniesTable() {
|
||||
<TableCell>{t("companiesPipeline")}</TableCell>
|
||||
<TableCell>{t("companiesRecruiter")}</TableCell>
|
||||
<TableCell>{t("companiesNextContact")}</TableCell>
|
||||
<TableCell width={1} align="right" />
|
||||
<TableCell width={1} align="right" sx={{ whiteSpace: "nowrap" }}>{t("jobTableActions")}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{companies.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>{c.name}</TableCell>
|
||||
<TableCell component="th" scope="row">{c.name}</TableCell>
|
||||
<TableCell>{c.location ?? ""}</TableCell>
|
||||
<TableCell>{c.source ?? ""}</TableCell>
|
||||
<TableCell>{c.pipelineStage ?? ""}</TableCell>
|
||||
<TableCell sx={{ minWidth: 110 }}>{c.source ?? ""}</TableCell>
|
||||
<TableCell sx={{ minWidth: 140 }}>{c.pipelineStage ?? ""}</TableCell>
|
||||
<TableCell>
|
||||
{c.recruiterName ?? ""}
|
||||
{c.recruiterEmail ? ` (${c.recruiterEmail})` : ""}
|
||||
</TableCell>
|
||||
<TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : ""}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" aria-label={`${t("companiesRelationship")}: ${c.name}`} onClick={() => void openRelationship(c)}>
|
||||
<TableCell sx={{ whiteSpace: "nowrap" }}>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") : ""}</TableCell>
|
||||
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
|
||||
<IconButton size="small" data-company-relationship={c.id} 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)}>
|
||||
<IconButton size="small" data-company-edit={c.id} aria-label={`${t("jobTableEdit")}: ${c.name}`} onClick={() => openEdit(c)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
@@ -231,45 +274,54 @@ export default function CompaniesTable() {
|
||||
)
|
||||
) : null}
|
||||
|
||||
<Dialog open={editOpen} onClose={() => setEditOpen(false)} fullWidth fullScreen={isMobile} maxWidth="sm">
|
||||
<Dialog open={editOpen} onClose={() => { if (!savePending.current) setEditOpen(false); }} fullWidth fullScreen={isMobile} maxWidth="sm"
|
||||
slotProps={{ transition: { onExited: () => setFocusReturn({ action: "edit", id: editCompanyIdRef.current }) } }}>
|
||||
<DialogTitle>{t("companiesEdit")}</DialogTitle>
|
||||
<DialogContent>
|
||||
{editError ? <Alert severity="error" sx={{ mt: 1 }}>{editError}</Alert> : null}
|
||||
<DialogContent ref={editContentRef} role="region" aria-label={t("companiesEdit")} tabIndex={-1} aria-busy={saving}>
|
||||
{saving ? <ViewStateNotice compact loading title={t("companiesUpdateFailed")} /> : null}
|
||||
{editError !== null ? <Alert severity="error" sx={{ mt: 1 }}>{editError || t("companiesUpdateFailed")}</Alert> : null}
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
label={t("companiesName")}
|
||||
disabled={saving}
|
||||
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")}
|
||||
disabled={saving}
|
||||
value={editing?.location ?? ""}
|
||||
onChange={(e) => setEditing((p) => (p ? { ...p, location: e.target.value } : p))}
|
||||
/>
|
||||
<TextField
|
||||
label={t("companiesSource")}
|
||||
disabled={saving}
|
||||
value={editing?.source ?? ""}
|
||||
onChange={(e) => setEditing((p) => (p ? { ...p, source: e.target.value } : p))}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={t("companiesPipelineStage")}
|
||||
disabled={saving}
|
||||
value={pipelineStage}
|
||||
onChange={(e) => setPipelineStage(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label={t("companiesRecruiterName")}
|
||||
disabled={saving}
|
||||
value={recruiterName}
|
||||
onChange={(e) => setRecruiterName(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label={t("companiesRecruiterEmail")}
|
||||
disabled={saving}
|
||||
value={recruiterEmail}
|
||||
onChange={(e) => setRecruiterEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label={t("companiesRecruiterLinkedIn")}
|
||||
disabled={saving}
|
||||
value={recruiterLinkedIn}
|
||||
onChange={(e) => setRecruiterLinkedIn(e.target.value)}
|
||||
sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
|
||||
@@ -277,6 +329,7 @@ export default function CompaniesTable() {
|
||||
|
||||
<TextField
|
||||
label={t("companiesLastContacted")}
|
||||
disabled={saving}
|
||||
type="date"
|
||||
value={lastContactedAt}
|
||||
onChange={(e) => setLastContactedAt(e.target.value)}
|
||||
@@ -284,6 +337,7 @@ export default function CompaniesTable() {
|
||||
/>
|
||||
<TextField
|
||||
label={t("companiesNextContactField")}
|
||||
disabled={saving}
|
||||
type="date"
|
||||
value={nextContactAt}
|
||||
onChange={(e) => setNextContactAt(e.target.value)}
|
||||
@@ -292,18 +346,24 @@ export default function CompaniesTable() {
|
||||
</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}>
|
||||
<Button onClick={() => { if (!savePending.current) setEditOpen(false); }} disabled={saving} fullWidth={isMobile}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={save} disabled={!canSave || saving} 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}
|
||||
<Dialog open={relationshipOpen} onClose={closeRelationship} fullWidth fullScreen={isMobile} maxWidth="md"
|
||||
slotProps={{ transition: { onExited: () => setFocusReturn({ action: "relationship", id: relationshipCompany?.id }) } }}>
|
||||
<DialogTitle>{relationship?.company.name ?? relationshipCompany?.name ?? t("companiesRelationship")}</DialogTitle>
|
||||
<DialogContent ref={relationshipContentRef} role="region" aria-label={t("companiesRelationship")} tabIndex={-1} aria-busy={relationshipLoading}>
|
||||
<ViewStateNotice compact loading={relationshipLoading} title={t("companiesRelationshipLoadFailed")}
|
||||
error={relationshipError ? { ...relationshipError, message: relationshipError.message || t("companiesRelationshipLoadFailed") } : null}
|
||||
onRetry={() => {
|
||||
if (!relationshipCompany || relationshipPending.current) return;
|
||||
relationshipContentRef.current?.focus();
|
||||
void openRelationship(relationshipCompany);
|
||||
}} />
|
||||
{relationship ? (
|
||||
<Stack spacing={2} sx={{ mt: 0.5 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(3, minmax(0, 1fr))" }, gap: 1.5 }}>
|
||||
@@ -312,21 +372,21 @@ export default function CompaniesTable() {
|
||||
{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>
|
||||
<Typography component="h3" variant="sectionTitle" sx={{ 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>
|
||||
<Box sx={{ flex: "1 1 220px", minWidth: 0 }}><Typography sx={{ fontWeight: 700, overflowWrap: "anywhere" }}>{application.jobTitle}</Typography><Typography variant="caption" color="text.secondary">{statusLabel(t, application.status)} · {t("companiesMessageCount", { count: application.messageCount })}</Typography></Box>
|
||||
<Button size="small" onClick={() => { closeRelationship(); navigate(`/jobs/${application.id}`); }}>{t("notificationsOpen")}</Button>
|
||||
</Paper>
|
||||
))}</Stack>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("companiesRecentContact")}</Typography>
|
||||
<Typography component="h3" variant="sectionTitle" sx={{ 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" }}>
|
||||
<Box key={contact.id} sx={{ pb: 1, borderBottom: 1, borderColor: "divider", overflowWrap: "anywhere" }}>
|
||||
<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>
|
||||
@@ -336,9 +396,9 @@ export default function CompaniesTable() {
|
||||
</Stack>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
<DialogActions><Button onClick={() => setRelationshipOpen(false)}>{t("close")}</Button></DialogActions>
|
||||
<DialogActions><Button onClick={closeRelationship}>{t("close")}</Button></DialogActions>
|
||||
</Dialog>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user