diff --git a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx index 07c94b7..873021b 100644 --- a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx +++ b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx @@ -163,6 +163,7 @@ export default function NewDashboardPage() { const [countryFilter, setCountryFilter] = useState("Todos"); const [matchSort, setMatchSort] = useState("default"); const [selectedJobId, setSelectedJobId] = useState(null); + const [timelineVersion, setTimelineVersion] = useState(0); const [searchParams, setSearchParams] = useSearchParams(); const [isAddJobOpen, setIsAddJobOpen] = useState(false); const [toast, setToast] = useState(""); @@ -357,6 +358,7 @@ export default function NewDashboardPage() { const updatedJob = await changeJobStatus(jobId, status); if (updatedJob && selectedJobId === jobId) { setSelectedJobId(updatedJob.id); + setTimelineVersion((current) => current + 1); } showToast(`Vaga atualizada para: ${jobStatuses[status]}`); } catch { @@ -572,6 +574,8 @@ export default function NewDashboardPage() { onClose={handleCloseJob} onStatusChange={handleStatusChange} onNotesChange={handleNotesChange} + isTracked={trackedJobs.some((job) => job.id === selectedJob.id)} + timelineVersion={timelineVersion} /> ) : null} diff --git a/frontend/src/domains/new_dashboard/components/jobs/JobDetailModal.tsx b/frontend/src/domains/new_dashboard/components/jobs/JobDetailModal.tsx index 1d46fdc..be9677c 100644 --- a/frontend/src/domains/new_dashboard/components/jobs/JobDetailModal.tsx +++ b/frontend/src/domains/new_dashboard/components/jobs/JobDetailModal.tsx @@ -1,6 +1,8 @@ import { ExternalLink } from "lucide-react"; +import { useEffect, useState } from "react"; import { jobStatusClasses, jobStatuses } from "../../constants"; -import type { Job, JobStatus } from "../../types"; +import type { Job, JobStatus, JobTimelineEvent } from "../../types"; +import { getDashboardSavedJobEvents } from "../../infrastructure/dashboardJobsApi"; import { Modal } from "../shared/Modal"; import { FormattedJobDescription } from "./FormattedJobDescription"; @@ -9,6 +11,8 @@ interface JobDetailModalProps { onClose: () => void; onStatusChange: (jobId: string, status: JobStatus) => void; onNotesChange: (jobId: string, notes: string) => void; + isTracked?: boolean; + timelineVersion?: number; } const payloadLabels: Record = { @@ -44,12 +48,69 @@ function isExternalUrl(value: string) { return /^https?:\/\//i.test(value); } +function formatTimelineDate(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Data não informada"; + + return new Intl.DateTimeFormat("pt-BR", { + dateStyle: "short", + timeStyle: "short", + }).format(date); +} + +function orderTimeline(events: JobTimelineEvent[]) { + return [...events].sort( + (first, second) => + new Date(first.createdAt).getTime() - new Date(second.createdAt).getTime(), + ); +} + +function timelineMetadataText(metadata: Record | null) { + if (!metadata) return ""; + + return Object.entries(metadata) + .map(([key, value]) => `${key}: ${payloadValueToText(value)}`) + .join(" • "); +} + export function JobDetailModal({ job, onClose, onStatusChange, onNotesChange, + isTracked = false, + timelineVersion = 0, }: JobDetailModalProps) { + const [timeline, setTimeline] = useState([]); + const [isTimelineLoading, setIsTimelineLoading] = useState(false); + const [timelineError, setTimelineError] = useState(false); + + useEffect(() => { + if (!isTracked) return; + + let active = true; + void Promise.resolve() + .then(() => { + if (!active) return null; + setIsTimelineLoading(true); + setTimelineError(false); + return getDashboardSavedJobEvents(job.id); + }) + .then((events) => { + if (active && events) setTimeline(orderTimeline(events)); + }) + .catch(() => { + if (active) setTimelineError(true); + }) + .finally(() => { + if (active) setIsTimelineLoading(false); + }); + + return () => { + active = false; + }; + }, [isTracked, job.id, timelineVersion]); + const payloadEntries = Object.entries(job.rawPayload ?? {}).filter( ([key]) => key !== "description", ); @@ -199,6 +260,51 @@ export function JobDetailModal({ className="min-h-28 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:border-ring" /> + + {isTracked ? ( +
+

+ Histórico da candidatura +

+ {isTimelineLoading ? ( +

Carregando histórico...

+ ) : null} + {timelineError ? ( +

+ Não foi possível carregar o histórico. +

+ ) : null} + {!isTimelineLoading && !timelineError && timeline.length === 0 ? ( +

+ Nenhuma mudança de status registrada. +

+ ) : null} + {!isTimelineLoading && !timelineError && timeline.length > 0 ? ( +
    + {timeline.map((event) => ( +
  1. + +

    + Status alterado de {jobStatuses[event.fromStatus]} para{" "} + {jobStatuses[event.toStatus]} +

    + + {timelineMetadataText(event.metadata) ? ( +

    + {timelineMetadataText(event.metadata)} +

    + ) : null} +
  2. + ))} +
+ ) : null} +
+ ) : null} ); diff --git a/frontend/src/domains/new_dashboard/infrastructure/dashboardJobsApi.ts b/frontend/src/domains/new_dashboard/infrastructure/dashboardJobsApi.ts index 44a4ea7..e475a24 100644 --- a/frontend/src/domains/new_dashboard/infrastructure/dashboardJobsApi.ts +++ b/frontend/src/domains/new_dashboard/infrastructure/dashboardJobsApi.ts @@ -4,6 +4,7 @@ import type { Job, JobLevel, JobStatus, + JobTimelineEvent, JobType, MatchSort, NewJob, @@ -54,8 +55,18 @@ const ApiSavedJobSchema = z.object({ updatedAt: z.string().optional(), }); +const ApiSavedJobEventSchema = z.object({ + id: z.string(), + type: z.literal("status_changed"), + fromStatus: z.enum(["saved", "applied", "interviewing", "rejected", "accepted"]), + toStatus: z.enum(["saved", "applied", "interviewing", "rejected", "accepted"]), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), + createdAt: z.string(), +}); + type ApiSearchJob = z.infer; type ApiSavedJob = z.infer; +type ApiSavedJobEvent = z.infer; type SearchJobsResponse = z.infer; export type SearchJobFilters = { @@ -279,6 +290,13 @@ export function toDashboardSavedJob(job: ApiSavedJob): Job { }; } +function toDashboardSavedJobEvent(event: ApiSavedJobEvent): JobTimelineEvent { + return { + ...event, + metadata: event.metadata ?? null, + }; +} + function toSearchJobsResult( response: SearchJobsResponse, page: number, @@ -355,6 +373,11 @@ export async function getDashboardSavedJobs() { return z.array(ApiSavedJobSchema).parse(data).map(toDashboardSavedJob); } +export async function getDashboardSavedJobEvents(id: string) { + const { data } = await api.get(`/saved-jobs/${id}/events`); + return z.array(ApiSavedJobEventSchema).parse(data).map(toDashboardSavedJobEvent); +} + export async function createDashboardSavedJob( job: Job | NewJob, status: JobStatus = "saved", diff --git a/frontend/src/domains/new_dashboard/types/job.ts b/frontend/src/domains/new_dashboard/types/job.ts index 3ca5e4b..3b63354 100644 --- a/frontend/src/domains/new_dashboard/types/job.ts +++ b/frontend/src/domains/new_dashboard/types/job.ts @@ -52,6 +52,14 @@ export type JobType = z.infer; export type JobLevel = z.infer; export type Job = z.infer; export type NewJob = z.infer; +export type JobTimelineEvent = { + id: string; + type: "status_changed"; + fromStatus: JobStatus; + toStatus: JobStatus; + metadata: Record | null; + createdAt: string; +}; export type MatchSort = "default" | "desc" | "asc"; export type JobModelFilter = | "Todos" diff --git a/frontend/tests/unit/new_dashboard/api.test.ts b/frontend/tests/unit/new_dashboard/api.test.ts index 1f2b2c5..4d9d035 100644 --- a/frontend/tests/unit/new_dashboard/api.test.ts +++ b/frontend/tests/unit/new_dashboard/api.test.ts @@ -14,6 +14,7 @@ vi.mock("@/shared/lib/apiClient", () => ({ import { createDashboardSavedJob, deleteDashboardSavedJob, + getDashboardSavedJobEvents, getDashboardSavedJobs, searchDashboardJobs, toRecommendedJob, @@ -379,6 +380,32 @@ it("mapeia entityId para jobId apenas quando entityType é job, e readAt para is }); }); + it("busca e tipa os eventos da vaga salva", async () => { + apiMock.get.mockResolvedValue({ + data: [ + { + id: "event-1", + type: "status_changed", + fromStatus: "saved", + toStatus: "applied", + metadata: { source: "dashboard" }, + createdAt: "2026-07-10T12:00:00.000Z", + }, + ], + }); + + const events = await getDashboardSavedJobEvents("saved-1"); + + expect(apiMock.get).toHaveBeenCalledWith("/saved-jobs/saved-1/events"); + expect(events).toEqual([ + expect.objectContaining({ + fromStatus: "saved", + toStatus: "applied", + metadata: { source: "dashboard" }, + }), + ]); + }); + it("cria, atualiza e remove vagas salvas", async () => { apiMock.post.mockResolvedValue({ data: { diff --git a/frontend/tests/unit/new_dashboard/jobs.test.tsx b/frontend/tests/unit/new_dashboard/jobs.test.tsx index 30cb84e..6586c84 100644 --- a/frontend/tests/unit/new_dashboard/jobs.test.tsx +++ b/frontend/tests/unit/new_dashboard/jobs.test.tsx @@ -1,6 +1,6 @@ -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { useState } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { AddJobModal } from "@/domains/new_dashboard/components/jobs/AddJobModal"; import { JobDetailModal } from "@/domains/new_dashboard/components/jobs/JobDetailModal"; import { JobFilter } from "@/domains/new_dashboard/components/jobs/JobFilter"; @@ -15,6 +15,14 @@ import type { CountryFilter, } from "@/domains/new_dashboard/utils/locationFilters"; +const dashboardApiMock = vi.hoisted(() => ({ + getDashboardSavedJobEvents: vi.fn(), +})); + +vi.mock("@/domains/new_dashboard/infrastructure/dashboardJobsApi", () => ({ + getDashboardSavedJobEvents: dashboardApiMock.getDashboardSavedJobEvents, +})); + const baseJob: Job = { id: "job-1", jobTitle: "Frontend Developer", @@ -48,6 +56,118 @@ function makeJobs(count: number): Job[] { } describe("new_dashboard job components", () => { + beforeEach(() => { + dashboardApiMock.getDashboardSavedJobEvents.mockReset(); + }); + + it("exibe a timeline em ordem cronológica e atualiza após mudar o status", async () => { + dashboardApiMock.getDashboardSavedJobEvents + .mockResolvedValueOnce([ + { + id: "event-2", + type: "status_changed", + fromStatus: "applied", + toStatus: "interviewing", + metadata: null, + createdAt: "2026-07-11T12:00:00.000Z", + }, + { + id: "event-1", + type: "status_changed", + fromStatus: "saved", + toStatus: "applied", + metadata: { source: "dashboard" }, + createdAt: "2026-07-10T12:00:00.000Z", + }, + ]) + .mockResolvedValueOnce([ + { + id: "event-3", + type: "status_changed", + fromStatus: "interviewing", + toStatus: "accepted", + metadata: null, + createdAt: "2026-07-12T12:00:00.000Z", + }, + ]); + + const view = render( + , + ); + + await waitFor(() => { + expect( + screen.getByText(/status alterado de salva para candidatura enviada/i), + ).toBeInTheDocument(); + }); + + const entries = screen.getAllByRole("listitem"); + expect(entries[0]).toHaveTextContent(/salva para candidatura enviada/i); + expect(entries[1]).toHaveTextContent( + /candidatura enviada para em entrevista/i, + ); + expect(entries[0]).toHaveTextContent("source: dashboard"); + + view.rerender( + , + ); + + await waitFor(() => { + expect( + screen.getByText(/em entrevista para proposta aceita/i), + ).toBeInTheDocument(); + }); + expect(dashboardApiMock.getDashboardSavedJobEvents).toHaveBeenCalledTimes(2); + }); + + it("mostra estados vazio e de erro da timeline", async () => { + dashboardApiMock.getDashboardSavedJobEvents.mockResolvedValueOnce([]); + const view = render( + , + ); + + await waitFor(() => { + expect(screen.getByText(/nenhuma mudança de status registrada/i)).toBeInTheDocument(); + }); + + dashboardApiMock.getDashboardSavedJobEvents.mockRejectedValueOnce( + new Error("falha"), + ); + view.rerender( + , + ); + + await waitFor(() => { + expect(screen.getByText(/não foi possível carregar o histórico/i)).toBeInTheDocument(); + }); + }); + it("atualiza os filtros da busca", () => { const setSearchQuery = vi.fn(); const setFilterType = vi.fn(); diff --git a/frontend/tests/unit/new_dashboard/page.test.tsx b/frontend/tests/unit/new_dashboard/page.test.tsx index 906656b..93b7247 100644 --- a/frontend/tests/unit/new_dashboard/page.test.tsx +++ b/frontend/tests/unit/new_dashboard/page.test.tsx @@ -39,6 +39,10 @@ vi.mock("@/domains/new_dashboard/hooks/useDashboardJobs", () => ({ useDashboardJobs: () => mockUseDashboardJobs(), })); +vi.mock("@/domains/new_dashboard/infrastructure/dashboardJobsApi", () => ({ + getDashboardSavedJobEvents: vi.fn().mockResolvedValue([]), +})); + vi.mock("@/domains/new_dashboard/infrastructure/notificationsApi", () => ({ getDashboardNotificationFeed: vi.fn().mockResolvedValue({ messages: [],