diff --git a/driver-pwa/frontend/src/App.tsx b/driver-pwa/frontend/src/App.tsx index 9426bb0..7d60ffa 100644 --- a/driver-pwa/frontend/src/App.tsx +++ b/driver-pwa/frontend/src/App.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; -import { Navigate, Route, Routes } from "react-router-dom"; +import { useEffect } from "react"; +import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "@/store/auth"; import { useStaff } from "@/store/staff"; import { ImpersonationBar } from "@/components/ImpersonationBar"; @@ -13,6 +14,8 @@ import { DevLoginPage } from "@/pages/DevLoginPage"; import { DebugPage } from "@/pages/DebugPage"; import { MockPayPage } from "@/pages/MockPayPage"; import { FineDetailPage } from "@/pages/FineDetailPage"; +import { NotificationsPage } from "@/pages/NotificationsPage"; +import { markNotificationsRead } from "@/api/driver"; function RequireAuth({ children }: { children: ReactNode }) { const token = useAuth((s) => s.token); @@ -28,12 +31,37 @@ function RequireStaff({ children }: { children: ReactNode }) { return impersonated || staffToken ? <>{children} : ; } +/** + * Нажатие по пушу — единственный честный признак, что уведомление увидели. + * Сам воркер отметить его не может: токен водителя лежит в хранилище страницы, + * ему недоступном. Поэтому воркер добавляет к адресу `?n=<номер>`, а отмечает + * страница — и тут же убирает параметр, чтобы он не остался в истории. + */ +function MarkPushRead() { + const loc = useLocation(); + const nav = useNavigate(); + + useEffect(() => { + const params = new URLSearchParams(loc.search); + const raw = params.get("n"); + if (!raw) return; + const id = Number(raw); + if (Number.isFinite(id) && id > 0) void markNotificationsRead([id], "push"); + params.delete("n"); + const rest = params.toString(); + nav(loc.pathname + (rest ? `?${rest}` : ""), { replace: true }); + }, [loc.search, loc.pathname, nav]); + + return null; +} + export default function App() { return (
{/* Плашка живёт над маршрутами: чей экран открыт, видно на каждом из них, а не только там, где про неё вспомнили. Сама решает, показываться ли. */} + } /> } /> @@ -44,6 +72,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/driver-pwa/frontend/src/api/driver.ts b/driver-pwa/frontend/src/api/driver.ts index 31ce636..a6cdb53 100644 --- a/driver-pwa/frontend/src/api/driver.ts +++ b/driver-pwa/frontend/src/api/driver.ts @@ -187,6 +187,29 @@ export const getFines = async (status: "all" | "open") => export const getFine = async (id: number) => FineDetailSchema.parse(await api.get(`driver/fines/${id}`).json()); +export const NotificationSchema = z.object({ + id: z.number(), + kind: z.string(), + title: z.string(), + body: z.string(), + url: z.string().nullable().default(null), + created_at: z.string().nullable().default(null), + read_at: z.string().nullable().default(null), +}); +export type DriverNotification = z.infer; + +export const NotificationsSchema = z.object({ + items: z.array(NotificationSchema), + unread: z.number(), +}); + +export const getNotifications = async () => + NotificationsSchema.parse(await api.get("driver/notifications").json()); + +/** Отметить прочитанным. Без списка — «всё»: так отмечает открытие ленты. */ +export const markNotificationsRead = async (ids?: number[], source: "app" | "push" = "app") => + api.post("driver/notifications/read", { json: { ids: ids ?? null, source } }).json(); + export const PushKeySchema = z.object({ key: z.string(), enabled: z.boolean() }); export const getPushKey = async () => diff --git a/driver-pwa/frontend/src/components/AppHeader.tsx b/driver-pwa/frontend/src/components/AppHeader.tsx index 224ce07..263a942 100644 --- a/driver-pwa/frontend/src/components/AppHeader.tsx +++ b/driver-pwa/frontend/src/components/AppHeader.tsx @@ -1,10 +1,15 @@ import { useNavigate } from "react-router-dom"; import { useRef } from "react"; +import { NotificationsBell } from "@/pages/NotificationsPage"; + /** Долгое нажатие, после которого открывается вход для сотрудника. */ const LONG_PRESS_MS = 1500; -export function AppHeader() { +/** Колокольчик по умолчанию выключен: он тянет счётчик уведомлений, а шапка + * стоит на каждом экране — незачем ходить за ним с оплаты или из штрафа. + * Включается там, откуда водитель и правда пойдёт читать: на главной. */ +export function AppHeader({ bell = false }: { bell?: boolean } = {}) { const nav = useNavigate(); const timer = useRef(null); @@ -20,7 +25,7 @@ export function AppHeader() { }; return ( -
+
Премиум Водитель + {bell && }
); } diff --git a/driver-pwa/frontend/src/pages/BalancePage.test.tsx b/driver-pwa/frontend/src/pages/BalancePage.test.tsx index 413377b..b926635 100644 --- a/driver-pwa/frontend/src/pages/BalancePage.test.tsx +++ b/driver-pwa/frontend/src/pages/BalancePage.test.tsx @@ -6,6 +6,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; // Ответ как у настоящего водителя: статьи 1С, итог отдельным числом. vi.mock("@/api/driver", () => ({ + // Колокольчик в шапке главного экрана спрашивает счётчик непрочитанного. + getNotifications: vi.fn(async () => ({ items: [], unread: 0 })), + markNotificationsRead: vi.fn(async () => ({ marked: 0 })), getBalance: vi.fn(async () => ({ accounts: [ { bucket: "Долг аренда", balance: -3050, payable: true }, diff --git a/driver-pwa/frontend/src/pages/BalancePage.tsx b/driver-pwa/frontend/src/pages/BalancePage.tsx index c53d20b..c9e8cc6 100644 --- a/driver-pwa/frontend/src/pages/BalancePage.tsx +++ b/driver-pwa/frontend/src/pages/BalancePage.tsx @@ -50,7 +50,7 @@ export function BalancePage() { return (
- + 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 });