Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 27 additions & 9 deletions frontend/src/domains/new_dashboard/NewDashboardPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useAuth } from "@/domains/auth/application/AuthContext";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { DashboardTab } from "./components/dashboard/DashboardTab";
import { HelpTab } from "./components/help/HelpTab";
import { HomeTab } from "./components/home/HomeTab";
Expand Down Expand Up @@ -134,11 +134,11 @@ function scoreJobWithTechnologies(
matchedTechnologies.length === 0
? 45
: Math.min(
99,
55 +
Math.round(coverage * 35) +
Math.min(matchedTechnologies.length * 4, 9),
);
99,
55 +
Math.round(coverage * 35) +
Math.min(matchedTechnologies.length * 4, 9),
);

return {
...job,
Expand All @@ -163,6 +163,7 @@ export default function NewDashboardPage() {
const [countryFilter, setCountryFilter] = useState<CountryFilter>("Todos");
const [matchSort, setMatchSort] = useState<MatchSort>("default");
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const [isAddJobOpen, setIsAddJobOpen] = useState(false);
const [toast, setToast] = useState("");
const [hasUserChangedJobFilters, setHasUserChangedJobFilters] =
Expand All @@ -185,14 +186,31 @@ export default function NewDashboardPage() {
() => getModelFilterFromJobTypes(searchPreferences.jobTypes),
[searchPreferences.jobTypes],
);

useEffect(() => {
const jobId = searchParams.get("jobId");
if (!jobId) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- sincroniza o jobId vindo da URL (navegação externa) com o estado local; execução única por navegação, não é loop de renderização.
setSelectedJobId(jobId);
setSelectedJobId(jobId);
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.delete("jobId");
return next;
},
{ replace: true },
);
}, [searchParams, setSearchParams]);

const initialRecommendationSearch = useMemo(
() =>
isLoadingUserData
? null
: {
keywords: [],
filters: modelFilterToApiFilter(preferredModelFilter),
},
keywords: [],
filters: modelFilterToApiFilter(preferredModelFilter),
},
[isLoadingUserData, preferredModelFilter],
);
const {
Expand Down
54 changes: 40 additions & 14 deletions frontend/src/domains/new_dashboard/components/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
clearDashboardNotifications,
getDashboardNotificationFeed,
markDashboardNotificationsRead,
markSingleNotificationRead,
} from "../../infrastructure/notificationsApi";
import type { Message, Notification, UserProfile } from "../../types";
import {
Expand Down Expand Up @@ -154,6 +155,8 @@ export function Header({
text: detail.item!.text,
type: detail.item!.type,
date: detail.item!.date,
jobId: detail.item!.jobId,
isRead: detail.item!.isRead ?? false,
},
...current.filter((item) => item.text !== detail.item!.text),
]);
Expand Down Expand Up @@ -320,20 +323,43 @@ export function Header({
</button>
</div>
<div className="space-y-5 pt-4">
{menuNotifications.length > 0 ? (
menuNotifications.map((notification) => (
<div key={notification.id} className="flex gap-3">
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-emerald-500" />
<div>
<p className="text-xs leading-5 text-slate-600 dark:text-slate-300">
{notification.text}
</p>
<span className="text-[11px] text-slate-400">
{notification.date}
</span>
</div>
</div>
))
{menuNotifications.length > 0 ? (
menuNotifications.map((notification) => {
const jobId = notification.jobId;
const isRead = notification.isRead ?? false;
return (
<div
key={notification.id}
role={jobId ? "button" : undefined}
tabIndex={jobId ? 0 : undefined}
onClick={
jobId
? () => {
setShowNotificationsMenu(false);
setMenuNotifications((current) =>
current.map((item) => item.id === notification.id ? { ...item, isRead: true } : item,),
);
void markSingleNotificationRead(String(notification.id)).catch(() => {
});
navigate(`/dashboard?jobId=${jobId}`);
}
: undefined
}
className={`flex gap-3 ${jobId ? "cursor-pointer hover:opacity-50" : ""} ${ isRead ? "opacity-50" : "" }`}
>
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${ isRead ? "bg-slate-400" : "bg-emerald-500" }`}
/>
<div>
<p className="text-xs leading-5 text-slate-600 dark:text-slate-300">
{notification.text}
</p>
<span className="text-[11px] text-slate-400">
{notification.date}
</span>
</div>
</div>
);
})
) : (
<p className="text-xs leading-5 text-slate-600 dark:text-slate-300">
Nenhuma notificação recente.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ function notifyJobEvent(job: Job, status?: JobStatus) {
text,
type: isApplied ? "success" : "info",
date: "Agora",
jobId: job.id,
isRead: false,
},
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const ApiNotificationSchema = z.object({
message: z.string(),
readAt: z.string().nullable().optional(),
createdAt: z.string(),
entityType: z.string().nullable().optional(),
entityId: z.string().nullable().optional(),
});

const ApiNotificationsResponseSchema = z.object({
Expand Down Expand Up @@ -70,6 +72,8 @@ function toNotification(item: ApiNotification): Notification {
text: item.message,
type: notificationType(item.type),
date: formatNotificationDate(item.createdAt),
jobId: item.entityType === "job" ? item.entityId ?? undefined : undefined,
isRead: Boolean(item.readAt)
};
}

Expand Down Expand Up @@ -114,6 +118,10 @@ export async function markDashboardNotificationsRead(channel: NotificationChanne
});
}

export async function markSingleNotificationRead(notificationId: string) {
await api.patch(`/notifications/${notificationId}/read`);
}

export async function clearDashboardNotifications(channel: NotificationChannel) {
await api.delete("/notifications", {
params: { channel },
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/domains/new_dashboard/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export const NotificationSchema = z.object({
text: z.string().min(1),
type: z.enum(["info", "success", "match"]),
date: z.string().min(1),
jobId: z.string().optional(),
isRead: z.boolean().optional(),
});

export const MessageSchema = z.object({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export type DashboardNotificationsRefreshDetail = {
date: string;
sender?: string;
origin?: "recruiter" | "mentor" | "system";
jobId?: string;
isRead?: boolean;
};
};

Expand Down
50 changes: 50 additions & 0 deletions frontend/tests/unit/new_dashboard/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
clearDashboardNotifications,
getDashboardNotificationFeed as getNotificationFeed,
markDashboardNotificationsRead as markNotificationsRead,
markSingleNotificationRead,
} from "@/domains/new_dashboard/infrastructure/notificationsApi";
import {
getUserPreferences,
Expand Down Expand Up @@ -184,6 +185,55 @@ describe("new_dashboard api adapters", () => {
});
});

it("marca uma única notificação como lida pela rota correta", async () => {
apiMock.patch.mockResolvedValue({ data: {} });

await markSingleNotificationRead("notification-42");

expect(apiMock.patch).toHaveBeenCalledWith(
"/notifications/notification-42/read",
);
});

it("mapeia entityId para jobId apenas quando entityType é job, e readAt para isRead", async () => {
apiMock.get.mockResolvedValueOnce({
data: {
unreadCount: 2,
notifications: [
{
id: "job-linked",
channel: "notification",
type: "job_applied",
title: "Candidatura enviada",
message: "Sua candidatura foi registrada.",
readAt: null,
createdAt: new Date().toISOString(),
entityType: "job",
entityId: "job-99",
},
{
id: "mentor-linked",
channel: "notification",
type: "mentor_message",
title: "Mentoria",
message: "Nova mensagem do mentor.",
readAt: "2026-08-01T10:00:00.000Z",
createdAt: new Date().toISOString(),
entityType: "mentor",
entityId: "mentor-5",
},
],
},
});

const result = await getNotificationFeed("notification");

expect(result.notifications).toMatchObject([
{ id: "job-linked", jobId: "job-99", isRead: false },
{ id: "mentor-linked", jobId: undefined, isRead: true },
]);
});

it("normaliza tipos, origens e datas do feed de notificações", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-21T12:00:00.000Z"));
Expand Down
Loading
Loading