feat(companies): add recruiter relationship history

This commit is contained in:
cesnimda
2026-08-31 17:25:31 +02:00
parent 7d5a78ad5d
commit f3569d05a9
6 changed files with 201 additions and 1 deletions
@@ -0,0 +1,31 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { api } from "./api";
import CompaniesTable from "./components/CompaniesTable";
import { I18nProvider } from "./i18n/I18nProvider";
import { ToastProvider } from "./toast";
jest.mock("./api", () => ({
api: { get: jest.fn(), put: jest.fn() },
getApiErrorMessage: (_error: unknown, fallback: string) => fallback,
}));
const mockedApi = api as jest.Mocked<typeof api>;
test("opens one company relationship view with applications and recent contact", async () => {
const company = { id: 3, name: "Acme", recruiterName: "Rita", recruiterEmail: "rita@example.test" };
mockedApi.get.mockImplementation((url) => Promise.resolve({ data: String(url).endsWith("/relationship") ? {
company,
applications: [{ id: 9, jobTitle: "Backend Developer", status: "Interview", messageCount: 1 }],
recentContacts: [{ id: 12, jobApplicationId: 9, jobTitle: "Backend Developer", date: "2026-08-10T10:00:00Z", channel: "email", subject: "Interview", from: "Rita" }],
} : [company] } as any));
render(<I18nProvider><ToastProvider><MemoryRouter><CompaniesTable /></MemoryRouter></ToastProvider></I18nProvider>);
fireEvent.click(await screen.findByRole("button", { name: "Relationship history: Acme" }));
expect(await screen.findByText("Applications at this company")).toBeInTheDocument();
expect(screen.getAllByText("Backend Developer").length).toBeGreaterThan(0);
expect(screen.getByText(/Interview · 1 messages/)).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledWith("/companies/3/relationship");
});
@@ -3,6 +3,7 @@ import { useLocation, useNavigate } from "react-router-dom";
import {
Box,
Alert,
Button,
Dialog,
DialogActions,
@@ -26,6 +27,7 @@ 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";
@@ -33,11 +35,15 @@ import { useViewResource } from "../hooks/useViewResource";
export default function CompaniesTable() {
const isMobile = useMediaQuery("(max-width:767.95px)");
const { toast } = useToast();
const { t } = useI18n();
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("");
@@ -82,6 +88,18 @@ export default function CompaniesTable() {
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 () => {
@@ -139,6 +157,9 @@ export default function CompaniesTable() {
<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 }}>
@@ -183,6 +204,9 @@ export default function CompaniesTable() {
</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>
@@ -270,6 +294,52 @@ export default function CompaniesTable() {
</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 }>;
};
+16
View File
@@ -1431,6 +1431,14 @@ export const translations = {
companiesUpdateFailed: "Failed to update company.",
companiesLoadFailed: "Unable to load companies",
companiesLoadFailedBody: "The companies list is unavailable right now. Try again when the API is reachable.",
companiesRelationship: "Relationship history",
companiesRelationshipLoadFailed: "Could not load the relationship history.",
companiesApplications: "Applications at this company",
companiesNoApplications: "No active applications are linked to this company.",
companiesMessageCount: "{count} messages",
companiesRecentContact: "Recent contact",
companiesNoContact: "No correspondence has been recorded for this company.",
companiesContact: "Contact",
adminUsersTitle: "Users",
adminUsersSubtitle: "Admin-only user management.",
adminUsersCreateUser: "Create user",
@@ -3812,6 +3820,14 @@ export const translations = {
companiesUpdateFailed: "Kunne ikke oppdatere selskap.",
companiesLoadFailed: "Kunne ikke laste selskaper",
companiesLoadFailedBody: "Selskapslisten er utilgjengelig akkurat nå. Prøv igjen når API-et er tilgjengelig.",
companiesRelationship: "Relasjonshistorikk",
companiesRelationshipLoadFailed: "Kunne ikke laste relasjonshistorikken.",
companiesApplications: "Søknader hos dette selskapet",
companiesNoApplications: "Ingen aktive søknader er knyttet til dette selskapet.",
companiesMessageCount: "{count} meldinger",
companiesRecentContact: "Nylig kontakt",
companiesNoContact: "Ingen korrespondanse er registrert for dette selskapet.",
companiesContact: "Kontakt",
adminUsersTitle: "Brukere",
adminUsersSubtitle: "Brukeradministrasjon kun for administratorer.",
adminUsersCreateUser: "Opprett bruker",