feat(companies): add recruiter relationship history
This commit is contained in:
@@ -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 }>;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user