-
+
diff --git a/driver-pwa/frontend/src/pages/DebugPage.test.tsx b/driver-pwa/frontend/src/pages/DebugPage.test.tsx
index 38ab8d0..5db9279 100644
--- a/driver-pwa/frontend/src/pages/DebugPage.test.tsx
+++ b/driver-pwa/frontend/src/pages/DebugPage.test.tsx
@@ -11,6 +11,9 @@ vi.mock("@/api/staff", () => ({
vi.mock("@/api/driver", () => ({
apiErrorText: vi.fn(async (_e: unknown, f: string) => f),
getBalance: vi.fn(async () => ({ accounts: [], pending_topup: 0 })),
+ // Тесты доходят до главного экрана, а у него в шапке колокольчик.
+ getNotifications: vi.fn(async () => ({ items: [], unread: 0 })),
+ markNotificationsRead: vi.fn(async () => ({ marked: 0 })),
}));
import App from "@/App";
diff --git a/driver-pwa/frontend/src/pages/NotificationsPage.test.tsx b/driver-pwa/frontend/src/pages/NotificationsPage.test.tsx
new file mode 100644
index 0000000..453c381
--- /dev/null
+++ b/driver-pwa/frontend/src/pages/NotificationsPage.test.tsx
@@ -0,0 +1,48 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+
+vi.mock("@/api/driver", () => ({
+ getNotifications: vi.fn(async () => ({
+ items: [
+ { id: 2, kind: "fine", title: "Новый штраф", body: "Постановление от 18.08.2026.",
+ url: "/fines/12", created_at: "2026-08-18T10:15:00+10:00", read_at: null },
+ { id: 1, kind: "topup", title: "Платёж получен", body: "Пополнение принято.",
+ url: "/", created_at: "2026-08-17T09:00:00+10:00", read_at: "2026-08-17T09:05:00+10:00" },
+ ],
+ unread: 1,
+ })),
+ markNotificationsRead: vi.fn(async () => ({ marked: 1 })),
+}));
+
+import { NotificationsPage } from "./NotificationsPage";
+import * as api from "@/api/driver";
+
+const show = () => render(
);
+
+describe("лента уведомлений", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("показывает, что мы писали водителю", async () => {
+ show();
+ expect(await screen.findByText("Новый штраф")).toBeInTheDocument();
+ expect(screen.getByText("Платёж получен")).toBeInTheDocument();
+ // Дата человеку — ДД.ММ.ГГГГ, не ISO (текст постановления содержит её же).
+ expect(screen.getByText("18.08.2026 10:15")).toBeInTheDocument();
+ });
+
+ it("открытие ленты отмечает непрочитанное", async () => {
+ show();
+ await screen.findByText("Новый штраф");
+ // Это единственный честный источник отметки: пуш живёт до смахивания, а
+ // сообщение в боте тонет в переписке — там прочтения не видно вовсе.
+ expect(api.markNotificationsRead).toHaveBeenCalled();
+ });
+
+ it("без непрочитанного лишний запрос не делаем", async () => {
+ vi.mocked(api.getNotifications).mockResolvedValueOnce({ items: [], unread: 0 });
+ show();
+ expect(await screen.findByText(/Пока ничего не приходило/)).toBeInTheDocument();
+ expect(api.markNotificationsRead).not.toHaveBeenCalled();
+ });
+});
diff --git a/driver-pwa/frontend/src/pages/NotificationsPage.tsx b/driver-pwa/frontend/src/pages/NotificationsPage.tsx
new file mode 100644
index 0000000..9b80561
--- /dev/null
+++ b/driver-pwa/frontend/src/pages/NotificationsPage.tsx
@@ -0,0 +1,122 @@
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { Bell } from "lucide-react";
+
+import { getNotifications, markNotificationsRead, type DriverNotification } from "@/api/driver";
+import { AppHeader } from "@/components/AppHeader";
+import { Spinner } from "@/components/Spinner";
+import { OperationIcon } from "@/components/operationIcon";
+
+// Значок берём тем же подбором, что и в выписке: событие приложения переводим
+// в тег 1С, чтобы штраф в ленте выглядел так же, как штраф в списке операций.
+const ICON_BY_KIND: Record
= {
+ fine: "Штраф ГИБДД",
+ topup: "Пополнение баланса",
+};
+
+/**
+ * Лента уведомлений водителя.
+ *
+ * Нужна не только водителю. Пуш живёт до первого смахивания, сообщение в боте
+ * тонет в переписке — и разговор «мне ничего не приходило» упирался в слово
+ * против слова. Здесь у водителя есть место, куда посмотреть, а у парка —
+ * честная отметка «прочитано»: она ставится ровно тогда, когда человек открыл
+ * этот экран или нажал на само уведомление, и ниоткуда больше.
+ *
+ * Отмечаем прочитанным ПОСЛЕ показа, а не при загрузке: иначе счётчик гаснет у
+ * того, кто просто мазнул по экрану и ушёл.
+ */
+export function NotificationsPage() {
+ const nav = useNavigate();
+ const [items, setItems] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let alive = true;
+ getNotifications()
+ .then((r) => {
+ if (!alive) return;
+ setItems(r.items);
+ if (r.unread > 0) void markNotificationsRead();
+ })
+ .catch(() => alive && setError("Не удалось загрузить уведомления"));
+ return () => {
+ alive = false;
+ };
+ }, []);
+
+ return (
+
+
+
Уведомления
+
+ {error &&
{error}
}
+ {!items && !error &&
}
+
+ {items && items.length === 0 && (
+
+ Пока ничего не приходило. Здесь будут появляться сообщения о штрафах и платежах.
+
+ )}
+
+
+ {items?.map((n) => (
+
+ ))}
+
+
+ );
+}
+
+/** Дата человеку — ДД.ММ.ГГГГ и время, без ISO. */
+function when(ts: string | null): string {
+ if (!ts) return "";
+ const d = new Date(ts);
+ const p = (n: number) => String(n).padStart(2, "0");
+ return `${p(d.getDate())}.${p(d.getMonth() + 1)}.${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}`;
+}
+
+/** Колокольчик со счётчиком непрочитанного — для шапки главного экрана. */
+export function NotificationsBell() {
+ const nav = useNavigate();
+ const [unread, setUnread] = useState(0);
+
+ useEffect(() => {
+ let alive = true;
+ getNotifications()
+ .then((r) => alive && setUnread(r.unread))
+ // Молча: колокольчик — не тот элемент, ради которого стоит показывать ошибку.
+ .catch(() => undefined);
+ return () => {
+ alive = false;
+ };
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/driver-pwa/frontend/src/sw.ts b/driver-pwa/frontend/src/sw.ts
index ee606ec..2d249a9 100644
--- a/driver-pwa/frontend/src/sw.ts
+++ b/driver-pwa/frontend/src/sw.ts
@@ -24,6 +24,8 @@ interface PushPayload {
body?: string;
url?: string;
tag?: string;
+ /** Номер уведомления в журнале — по нему отмечается прочтение. */
+ id?: number;
}
self.addEventListener("push", (event: PushEvent) => {
@@ -43,14 +45,19 @@ self.addEventListener("push", (event: PushEvent) => {
badge: "/icons/icon-192.png",
// tag схлопывает однотипные: пять штрафов подряд не завалят шторку.
tag: data.tag,
- data: { url: data.url || "/" },
+ data: { url: data.url || "/", id: data.id },
}),
);
});
self.addEventListener("notificationclick", (event: NotificationEvent) => {
event.notification.close();
- const target = (event.notification.data?.url as string) || "/";
+ const url = (event.notification.data?.url as string) || "/";
+ const id = event.notification.data?.id as number | undefined;
+ // Нажатие — единственный честный признак, что уведомление увидели. Отметить
+ // его прямо отсюда нельзя: токен водителя лежит в хранилище страницы, а
+ // воркеру оно недоступно. Поэтому номер едет параметром, а отмечает страница.
+ const target = id ? `${url}${url.includes("?") ? "&" : "?"}n=${id}` : url;
event.waitUntil(
(async () => {
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });