Compare commits
26
Commits
@@ -92,12 +92,13 @@ npm run build # tsc -b && vite build → dist/
|
||||
`../../docs/superpowers/plans/2026-06-18-premium-driver-pwa.md`, раздел
|
||||
«Deployment notes»: статика на `app.pptaxi.ru`, Caddy с фолбэком на `index.html`.
|
||||
|
||||
## Обкатка: кто может войти и как смотреть чужой экран
|
||||
## Кто может войти и как смотреть чужой экран
|
||||
|
||||
Приложение выложено на боевой домен, а обкатывается интерфейс. Поэтому на время
|
||||
обкатки включены две вещи.
|
||||
> **Запущено 22.08.2026: вход открыт всем водителям.** Список номеров ниже пуст,
|
||||
> то есть гейт выключен. Раздел оставлен как инструкция на случай, если вход
|
||||
> снова понадобится сузить — например под обкатку следующей крупной правки.
|
||||
|
||||
### 1. Вход только по списку номеров
|
||||
### 1. Вход только по списку номеров (сейчас выключено)
|
||||
|
||||
Переменная бэкенда (`crm2/backend/.env`, читается в `app/config.py`):
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#F3EEE3" />
|
||||
<meta name="theme-color" content="#EEF1F5" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
||||
<!-- iOS манифест не читает: иконку «на экран Домой» он берёт только отсюда. -->
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<!-- iOS берёт подпись под иконкой отсюда, а не из манифеста. -->
|
||||
<meta name="apple-mobile-web-app-title" content="Водитель" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
Generated
+3
-1
@@ -33,7 +33,9 @@
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vitest": "^2.0.5"
|
||||
"vitest": "^2.0.5",
|
||||
"workbox-core": "^7.4.1",
|
||||
"workbox-precaching": "^7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vitest": "^2.0.5"
|
||||
"vitest": "^2.0.5",
|
||||
"workbox-core": "^7.4.1",
|
||||
"workbox-precaching": "^7.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 909 B After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "Премиум Водитель",
|
||||
"short_name": "Премиум",
|
||||
"short_name": "Водитель",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#F3EEE3",
|
||||
"theme_color": "#F3EEE3",
|
||||
"background_color": "#EEF1F5",
|
||||
"theme_color": "#EEF1F5",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// Генерация PWA-иконок «Премиум Водитель» без сторонних либ (zlib PNG-энкодер).
|
||||
// Дизайн: кремовый фон #F3EEE3, тёмная скруглённая плитка #1C1B19,
|
||||
// внутри белая «карта» #F6F2E9 с тёмной полосой (финансовый мотив). Без жёлтого/текста.
|
||||
import zlib from "node:zlib";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const CREAM = [243, 238, 227];
|
||||
const INK = [28, 27, 25];
|
||||
const CARD = [246, 242, 233];
|
||||
|
||||
function crc32(buf) {
|
||||
let c = ~0;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c ^= buf[i];
|
||||
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
|
||||
}
|
||||
return (~c) >>> 0;
|
||||
}
|
||||
function chunk(type, data) {
|
||||
const t = Buffer.from(type, "ascii");
|
||||
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
|
||||
const body = Buffer.concat([t, data]);
|
||||
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body), 0);
|
||||
return Buffer.concat([len, body, crc]);
|
||||
}
|
||||
function encodePng(N, rgba) {
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(N, 0); ihdr.writeUInt32BE(N, 4); ihdr[8] = 8; ihdr[9] = 6;
|
||||
const stride = N * 4;
|
||||
const raw = Buffer.alloc(N * (stride + 1));
|
||||
for (let y = 0; y < N; y++) {
|
||||
raw[y * (stride + 1)] = 0;
|
||||
rgba.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
|
||||
}
|
||||
return Buffer.concat([sig, chunk("IHDR", ihdr), chunk("IDAT", zlib.deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
|
||||
}
|
||||
function inRoundRect(px, py, x0, y0, x1, y1, r) {
|
||||
if (px < x0 || px >= x1 || py < y0 || py >= y1) return false;
|
||||
const cx = px < x0 + r ? x0 + r : px >= x1 - r ? x1 - r : px;
|
||||
const cy = py < y0 + r ? y0 + r : py >= y1 - r ? y1 - r : py;
|
||||
const dx = px - cx, dy = py - cy;
|
||||
return dx * dx + dy * dy <= r * r;
|
||||
}
|
||||
|
||||
function draw(N) {
|
||||
const buf = Buffer.alloc(N * N * 4);
|
||||
// tile (centered ~64%)
|
||||
const tile = Math.round(N * 0.64), tx0 = Math.round((N - tile) / 2), ty0 = tx0;
|
||||
const tx1 = tx0 + tile, ty1 = ty0 + tile, tr = Math.round(tile * 0.24);
|
||||
// card inside tile
|
||||
const cw = Math.round(tile * 0.62), ch = Math.round(tile * 0.42);
|
||||
const cx0 = Math.round((N - cw) / 2), cy0 = Math.round((N - ch) / 2);
|
||||
const cx1 = cx0 + cw, cy1 = cy0 + ch, cr = Math.round(ch * 0.18);
|
||||
// stripe near top of card
|
||||
const sy0 = cy0 + Math.round(ch * 0.22), sy1 = sy0 + Math.round(ch * 0.14);
|
||||
for (let y = 0; y < N; y++) {
|
||||
for (let x = 0; x < N; x++) {
|
||||
let col = CREAM;
|
||||
if (inRoundRect(x, y, tx0, ty0, tx1, ty1, tr)) col = INK;
|
||||
if (inRoundRect(x, y, cx0, cy0, cx1, cy1, cr)) col = CARD;
|
||||
if (x >= cx0 && x < cx1 && y >= sy0 && y < sy1) col = INK;
|
||||
const o = (y * N + x) * 4;
|
||||
buf[o] = col[0]; buf[o + 1] = col[1]; buf[o + 2] = col[2]; buf[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
return encodePng(N, buf);
|
||||
}
|
||||
|
||||
const dir = path.resolve("public/icons");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const N of [192, 512]) {
|
||||
fs.writeFileSync(path.join(dir, `icon-${N}.png`), draw(N));
|
||||
console.log(`wrote icon-${N}.png`);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Иконки приложения — тот же значок, что у сайта pptaxi.ru.
|
||||
|
||||
Сайт отдаёт `favicon.svg`: красная скруглённая плитка --s-accent и белая «П».
|
||||
Повторяем её здесь, чтобы значок в браузере, на экране телефона и на сайте был
|
||||
один и тот же.
|
||||
|
||||
Рисуем сами (Pillow), потому что тянуть растеризатор SVG ради одной плитки с
|
||||
буквой незачем. Сглаживание — сверхдискретизацией ×4.
|
||||
|
||||
Запуск (нужен Pillow):
|
||||
python scripts/gen-icons.py
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
OUT = Path(__file__).resolve().parent.parent / "public" / "icons"
|
||||
ACCENT = (220, 38, 38, 255) # --s-accent сайта
|
||||
PAPER = (255, 255, 255, 255) # --s-paper
|
||||
SS = 4
|
||||
|
||||
# Шрифт ищем среди системных: у сайта заголовки набраны Manrope, но ради одной
|
||||
# буквы тащить файл шрифта в репозиторий не стоит — берём ближайший гротеск.
|
||||
FONT_CANDIDATES = [
|
||||
r"C:\Windows\Fonts\segoeuib.ttf",
|
||||
r"C:\Windows\Fonts\arialbd.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
]
|
||||
|
||||
|
||||
def _font(size: int) -> ImageFont.FreeTypeFont:
|
||||
for path in FONT_CANDIDATES:
|
||||
if Path(path).exists():
|
||||
return ImageFont.truetype(path, size)
|
||||
raise SystemExit("не нашёл жирный шрифт для буквы")
|
||||
|
||||
|
||||
def compose(size: int, inset: float, radius_ratio: float = 0.22) -> Image.Image:
|
||||
"""inset — доля стороны под плитку. У maskable она меньше: Android режет
|
||||
иконку по кругу, и плитка без запаса теряет углы."""
|
||||
big = size * SS
|
||||
img = Image.new("RGBA", (big, big), PAPER)
|
||||
draw = ImageDraw.Draw(img)
|
||||
pad = big * (1 - inset) / 2
|
||||
box = (pad, pad, big - pad, big - pad)
|
||||
draw.rounded_rectangle(box, radius=(box[2] - box[0]) * radius_ratio, fill=ACCENT)
|
||||
|
||||
letter = "П"
|
||||
font = _font(int((box[3] - box[1]) * 0.72))
|
||||
l, t, r, b = draw.textbbox((0, 0), letter, font=font)
|
||||
draw.text(((box[0] + box[2] - (r - l)) / 2 - l,
|
||||
(box[1] + box[3] - (b - t)) / 2 - t), letter, font=font, fill=PAPER)
|
||||
return img.resize((size, size), Image.LANCZOS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
for name, size, inset in (("icon-192.png", 192, 1.0),
|
||||
("icon-512.png", 512, 1.0),
|
||||
("icon-maskable-512.png", 512, 0.72),
|
||||
("apple-touch-icon.png", 180, 1.0)):
|
||||
compose(size, inset).save(OUT / name)
|
||||
print("собрано", name)
|
||||
@@ -1,7 +1,9 @@
|
||||
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 { setReturnTo } from "@/lib/returnTo";
|
||||
import { ImpersonationBar } from "@/components/ImpersonationBar";
|
||||
import { LoginPage } from "@/pages/LoginPage";
|
||||
import { TgWaitPage } from "@/pages/TgWaitPage";
|
||||
@@ -9,14 +11,21 @@ import { OnboardingPage } from "@/pages/OnboardingPage";
|
||||
import { BalancePage } from "@/pages/BalancePage";
|
||||
import { TopupPage } from "@/pages/TopupPage";
|
||||
import { PaymentPage } from "@/pages/PaymentPage";
|
||||
import { HistoryPage } from "@/pages/HistoryPage";
|
||||
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);
|
||||
return token ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
const loc = useLocation();
|
||||
if (token) return <>{children}</>;
|
||||
// Запоминаем, куда шёл водитель (напр. ссылку на оплату), чтобы вернуть его
|
||||
// туда после входа, а не на баланс.
|
||||
setReturnTo(loc.pathname + loc.search);
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
/** Отладка существует только для сотрудника: либо он вошёл учёткой CRM, либо
|
||||
@@ -28,12 +37,37 @@ function RequireStaff({ children }: { children: ReactNode }) {
|
||||
return impersonated || staffToken ? <>{children}</> : <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Нажатие по пушу — единственный честный признак, что уведомление увидели.
|
||||
* Сам воркер отметить его не может: токен водителя лежит в хранилище страницы,
|
||||
* ему недоступном. Поэтому воркер добавляет к адресу `?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 (
|
||||
<div className="mx-auto max-w-md min-h-full px-4 pt-3 pb-8">
|
||||
{/* Плашка живёт над маршрутами: чей экран открыт, видно на каждом из них,
|
||||
а не только там, где про неё вспомнили. Сама решает, показываться ли. */}
|
||||
<ImpersonationBar />
|
||||
<MarkPushRead />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/dev" element={<DevLoginPage />} />
|
||||
@@ -43,7 +77,8 @@ export default function App() {
|
||||
<Route path="/topup" element={<RequireAuth><TopupPage /></RequireAuth>} />
|
||||
<Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></RequireAuth>} />
|
||||
<Route path="/mock-pay/:orderId" element={<RequireAuth><MockPayPage /></RequireAuth>} />
|
||||
<Route path="/history" element={<RequireAuth><HistoryPage /></RequireAuth>} />
|
||||
<Route path="/fines/:fineId" element={<RequireAuth><FineDetailPage /></RequireAuth>} />
|
||||
<Route path="/notifications" element={<RequireAuth><NotificationsPage /></RequireAuth>} />
|
||||
<Route path="/debug" element={<RequireAuth><RequireStaff><DebugPage /></RequireStaff></RequireAuth>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { api } from "./client";
|
||||
import { useAuth } from "@/store/auth";
|
||||
|
||||
/**
|
||||
* Продление сеанса: бэкенд кладёт свежий токен в заголовок `X-Driver-Token`,
|
||||
* приложение обязано молча его подменить. Без этого 30 дней жизни токена
|
||||
* считались бы от входа, и раз в месяц водителя выкидывало бы на экран входа —
|
||||
* при том, что пароля у него нет и «войти» означает сходить в бота за кодом.
|
||||
*/
|
||||
function answer(headers: Record<string, string> = {}) {
|
||||
return vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
}));
|
||||
}
|
||||
|
||||
describe("продление токена водителя", () => {
|
||||
beforeEach(() => {
|
||||
useAuth.setState({ token: "OLD", driverId: 7, impersonated: false, driverName: null, own: null });
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("подменяет токен, когда ответ принёс свежий", async () => {
|
||||
vi.stubGlobal("fetch", answer({ "X-Driver-Token": "NEW" }));
|
||||
await api.get("driver/balance").json();
|
||||
expect(useAuth.getState().token).toBe("NEW");
|
||||
expect(useAuth.getState().driverId).toBe(7);
|
||||
});
|
||||
|
||||
it("без заголовка ничего не трогает", async () => {
|
||||
vi.stubGlobal("fetch", answer());
|
||||
await api.get("driver/balance").json();
|
||||
expect(useAuth.getState().token).toBe("OLD");
|
||||
});
|
||||
|
||||
it("не продлевает просмотр от лица водителя", async () => {
|
||||
// Восемь часов просмотра — сознательный предел для чужого баланса на чужом
|
||||
// ноутбуке. Бэкенд заголовок и не пришлёт, но полагаться на это нельзя:
|
||||
// ответ на запрос, ушедший ДО начала просмотра, может прийти уже после.
|
||||
useAuth.setState({ token: "IMP", driverId: 9, impersonated: true, driverName: "Петров" });
|
||||
vi.stubGlobal("fetch", answer({ "X-Driver-Token": "NEW" }));
|
||||
await api.get("driver/balance").json();
|
||||
expect(useAuth.getState().token).toBe("IMP");
|
||||
});
|
||||
|
||||
it("не воскрешает сеанс, закрытый пока запрос был в пути", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => {
|
||||
useAuth.getState().clear(); // водитель нажал «выйти»
|
||||
return new Response("{}", { status: 200, headers: { "X-Driver-Token": "NEW" } });
|
||||
}));
|
||||
await api.get("driver/balance").json();
|
||||
expect(useAuth.getState().token).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,29 @@ export const API_BASE = import.meta.env.VITE_API_BASE ?? "https://crm.pptaxi.ru/
|
||||
/** Засечка времени на конкретный запрос. WeakMap — чтобы не держать Request'ы. */
|
||||
const started = new WeakMap<Request, number>();
|
||||
|
||||
/**
|
||||
* Заголовок, которым бэкенд отдаёт ПРОДЛЁННЫЙ токен водителя (см. driver_auth.py).
|
||||
*
|
||||
* Токен живёт 30 дней, и без продления они считались бы от входа: раз в месяц
|
||||
* водителя выкидывало бы на экран входа, а «войти» для него означает сходить в
|
||||
* бота за кодом. Начиная с пятого дня жизни токена бэкенд кладёт в ответ свежий,
|
||||
* мы его молча подменяем — и 30 дней начинают считаться от последнего захода.
|
||||
*/
|
||||
const RENEWED_TOKEN_HEADER = "X-Driver-Token";
|
||||
|
||||
/** Подменить токен на продлённый, если ответ его принёс. */
|
||||
function adoptRenewedToken(request: Request, response: Response): void {
|
||||
const fresh = response.headers.get(RENEWED_TOKEN_HEADER);
|
||||
if (!fresh) return;
|
||||
const auth = useAuth.getState();
|
||||
// Сеанс мог смениться, пока запрос был в пути: водитель вышел, сотрудник начал
|
||||
// просмотр от чужого лица. Подменяем только тот токен, с которым сами ушли, —
|
||||
// иначе продление чужого ответа вернёт закрытый сеанс к жизни.
|
||||
if (auth.impersonated || auth.driverId == null) return;
|
||||
if (request.headers.get("Authorization") !== `Bearer ${auth.token}`) return;
|
||||
auth.setSession(fresh, auth.driverId);
|
||||
}
|
||||
|
||||
export const api: KyInstance = ky.create({
|
||||
prefixUrl: API_BASE,
|
||||
timeout: 30_000,
|
||||
@@ -30,6 +53,7 @@ export const api: KyInstance = ky.create({
|
||||
status: response.status,
|
||||
ms: Math.round(performance.now() - (started.get(request) ?? performance.now())),
|
||||
});
|
||||
adoptRenewedToken(request, response);
|
||||
},
|
||||
],
|
||||
beforeError: [
|
||||
|
||||
@@ -25,7 +25,17 @@ export type AuthRequest = z.infer<typeof AuthRequestSchema>;
|
||||
|
||||
export const VerifySchema = z.object({ token: z.string(), driver_id: z.number() });
|
||||
export const BalanceSchema = z.object({
|
||||
accounts: z.array(z.object({ bucket: z.string(), balance: z.number() })),
|
||||
accounts: z.array(z.object({
|
||||
bucket: z.string(),
|
||||
balance: z.number(),
|
||||
// Можно ли платить в эту статью. «Страховка» приходит из 1С, но платежом не
|
||||
// закрывается — строку показываем, нажатие не предлагаем.
|
||||
payable: z.boolean().default(true),
|
||||
})),
|
||||
// Итог берём с сервера, а не суммой строк: 1С округляет статьи до рублей, а итог
|
||||
// держит с копейками, и сумма строк с ним не сходится (−8 976 против −8 976,25).
|
||||
total: z.number().nullable().default(null),
|
||||
synced_at: z.string().nullable().default(null),
|
||||
// Принято нами, но ещё не проведено в 1С. В сумму баланса не входит.
|
||||
pending_topup: z.number().default(0),
|
||||
});
|
||||
@@ -76,6 +86,19 @@ export async function apiErrorText(err: unknown, fallback: string): Promise<stri
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сколько секунд ждать перед повторным запросом кода. Берём из заголовка
|
||||
* Retry-After ответа 429; если его нет — null (вызывающий решит сам).
|
||||
*/
|
||||
export function retryAfterSeconds(err: unknown): number | null {
|
||||
if (err instanceof HTTPError) {
|
||||
const raw = err.response.headers.get("Retry-After");
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const tgSession = async () =>
|
||||
TgSessionSchema.parse(await api.post("driver/auth/tg/session").json());
|
||||
/** POST, а не GET: в query-параметре pair_session оседал в access-логах. */
|
||||
@@ -94,8 +117,11 @@ export const getBalance = async () =>
|
||||
BalanceSchema.parse(await api.get("driver/balance").json());
|
||||
export const getBuckets = async () =>
|
||||
BucketsSchema.parse(await api.get("driver/buckets").json());
|
||||
export const topup = async (bucket: string, amount: number, method: "qr" | "card") =>
|
||||
TopupSchema.parse(await api.post("driver/topup", { json: { bucket, amount, method } }).json());
|
||||
export const topup = async (bucket: string, amount: number, method: "qr" | "card",
|
||||
live = false) =>
|
||||
TopupSchema.parse(await api.post("driver/topup", {
|
||||
json: { bucket, amount, method, live },
|
||||
}).json());
|
||||
export const getPaymentState = async (orderId: string) =>
|
||||
PaymentStateSchema.parse(await api.get(`driver/payment/${orderId}`).json());
|
||||
export const getPayments = async () =>
|
||||
@@ -109,3 +135,104 @@ export const mockPayOutcome = async (
|
||||
) => PaymentStateSchema.parse(
|
||||
await api.post(`driver/mock-pay/${orderId}`, { json: { outcome } }).json(),
|
||||
);
|
||||
|
||||
export const StatementSchema = z.object({
|
||||
items: z.array(z.object({
|
||||
date: z.string().nullable(),
|
||||
title: z.string(),
|
||||
description: z.string().default(""),
|
||||
amount: z.number(),
|
||||
kind: z.enum(["charges", "payments", "fines"]),
|
||||
// Карточка штрафа, если постановление у нас есть. null — строка не ссылка.
|
||||
fine_id: z.number().nullable().default(null),
|
||||
})),
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
kind: z.string(),
|
||||
});
|
||||
export type StatementKind = "all" | "charges" | "payments" | "fines";
|
||||
|
||||
/** Выписка по счёту за период. Даты — ISO (ГГГГ-ММ-ДД), как их понимает бэкенд. */
|
||||
export const getStatement = async (from: string, to: string, kind: StatementKind) =>
|
||||
StatementSchema.parse(await api.get("driver/statement", {
|
||||
searchParams: { from, to, kind },
|
||||
}).json());
|
||||
|
||||
const FineBriefSchema = z.object({
|
||||
id: z.number(),
|
||||
number: z.string(),
|
||||
date: z.string().nullable(),
|
||||
article: z.string(),
|
||||
address: z.string().default(""),
|
||||
car_number: z.string().default(""),
|
||||
// Сумма, начисленная ВОДИТЕЛЮ. Номинала постановления, скидки и расчётов парка
|
||||
// с ГИБДД в приложении нет намеренно — водителю нужен факт штрафа и сумма.
|
||||
amount: z.number(),
|
||||
// Погашен ли штраф водителем перед парком. Постановление парк гасит авансом,
|
||||
// и на этот признак его расчёты не влияют.
|
||||
settled: z.enum(["owed", "paid", "not_charged", "cancelled", "unknown"]),
|
||||
cancelled: z.boolean().default(false),
|
||||
has_photo: z.boolean().default(false),
|
||||
});
|
||||
export type FineBrief = z.infer<typeof FineBriefSchema>;
|
||||
|
||||
export const FinesSchema = z.object({
|
||||
items: z.array(FineBriefSchema),
|
||||
open_count: z.number(),
|
||||
// Долг по штрафам из 1С — тот же, что на главном экране (отрицательный).
|
||||
debt: z.number(),
|
||||
});
|
||||
|
||||
export const FineDetailSchema = FineBriefSchema.extend({
|
||||
post_date: z.string().nullable().default(null),
|
||||
speed: z.number().nullable().default(null),
|
||||
speed_limit: z.number().nullable().default(null),
|
||||
speed_excess: z.number().nullable().default(null),
|
||||
camera: z.string().default(""),
|
||||
// Ссылки подписанные и живут час — держать их в кэше дольше нет смысла.
|
||||
photos: z.array(z.string()).default([]),
|
||||
document: z.string().nullable().default(null),
|
||||
});
|
||||
|
||||
export const getFines = async (status: "all" | "open") =>
|
||||
FinesSchema.parse(await api.get("driver/fines", { searchParams: { status } }).json());
|
||||
|
||||
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<typeof NotificationSchema>;
|
||||
|
||||
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 () =>
|
||||
PushKeySchema.parse(await api.get("driver/push/key").json());
|
||||
|
||||
export const subscribePush = async (sub: { endpoint: string; p256dh: string; auth: string }) =>
|
||||
api.post("driver/push/subscribe", { json: sub }).json();
|
||||
|
||||
export const unsubscribePush = async (endpoint: string) =>
|
||||
api.post("driver/push/unsubscribe", { json: { endpoint } }).json();
|
||||
|
||||
/** Пробное уведомление себе. Доступно только при просмотре сотрудником. */
|
||||
export const sendTestPush = async () => api.post("driver/push/test").json();
|
||||
|
||||
@@ -1,7 +1,41 @@
|
||||
export function AppHeader() {
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { NotificationsBell } from "@/pages/NotificationsPage";
|
||||
|
||||
/** Долгое нажатие, после которого открывается вход для сотрудника. */
|
||||
const LONG_PRESS_MS = 1500;
|
||||
|
||||
/** Колокольчик по умолчанию выключен: он тянет счётчик уведомлений, а шапка
|
||||
* стоит на каждом экране — незачем ходить за ним с оплаты или из штрафа.
|
||||
* Включается там, откуда водитель и правда пойдёт читать: на главной. */
|
||||
export function AppHeader({ bell = false }: { bell?: boolean } = {}) {
|
||||
const nav = useNavigate();
|
||||
const timer = useRef<number | null>(null);
|
||||
|
||||
// Ссылки «для сотрудника» на экранах водителя нет намеренно: водителю она
|
||||
// ничего не даёт, а место занимает. Но сотруднику, уже вошедшему водителем,
|
||||
// адрес /dev иначе приходится набирать руками — отсюда долгий тап по названию.
|
||||
const start = () => {
|
||||
timer.current = window.setTimeout(() => nav("/dev"), LONG_PRESS_MS);
|
||||
};
|
||||
const cancel = () => {
|
||||
if (timer.current) window.clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="mb-4">
|
||||
<span className="text-sm font-bold">Премиум Водитель</span>
|
||||
<header className="mb-4 flex items-center justify-between">
|
||||
<span
|
||||
className="text-sm font-bold select-none"
|
||||
onPointerDown={start}
|
||||
onPointerUp={cancel}
|
||||
onPointerLeave={cancel}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
Премиум Водитель
|
||||
</span>
|
||||
{bell && <NotificationsBell />}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Spinner } from "@/components/Spinner";
|
||||
* первый вход сотрудника (/dev) и смена водителя на вкладке «Отладка».
|
||||
*/
|
||||
export function DriverPicker({ onPicked }: { onPicked: (d: DriverBrief) => void }) {
|
||||
const setSession = useAuth((s) => s.setSession);
|
||||
const startImpersonation = useAuth((s) => s.startImpersonation);
|
||||
const [q, setQ] = useState("");
|
||||
const [list, setList] = useState<DriverBrief[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -35,7 +35,7 @@ export function DriverPicker({ onPicked }: { onPicked: (d: DriverBrief) => void
|
||||
setBusyId(d.id);
|
||||
try {
|
||||
const r = await impersonate(d.id);
|
||||
setSession(r.token, r.driver.id, { impersonated: true, driverName: r.driver.fio });
|
||||
startImpersonation(r.token, r.driver.id, r.driver.fio);
|
||||
onPicked(r.driver);
|
||||
} catch (e: unknown) {
|
||||
toast.error(await apiErrorText(e, "Не удалось открыть приложение за водителя"));
|
||||
@@ -46,7 +46,7 @@ export function DriverPicker({ onPicked }: { onPicked: (d: DriverBrief) => void
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card flex items-center gap-2 px-3 py-2.5 mb-3 bg-white">
|
||||
<div className="card flex items-center gap-2 px-3 py-2.5 mb-3 bg-surface">
|
||||
<Search size={16} className="text-muted shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ImpersonationBar() {
|
||||
const onDebug = loc.pathname === "/debug";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-3 px-3 py-2 rounded-xl2 bg-ink text-cream text-xs">
|
||||
<div className="flex items-center gap-2 mb-3 px-3 py-2 rounded-xl2 border border-warn/40 bg-warn/10 text-warn text-xs">
|
||||
<Eye size={14} className="shrink-0" />
|
||||
<span className="min-w-0 truncate">
|
||||
Смотрю как <b>{driverName ?? `водитель #${driverId}`}</b>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
vi.mock("@/lib/push", () => ({
|
||||
currentState: vi.fn(async () => "off"),
|
||||
enable: vi.fn(async () => "on"),
|
||||
installed: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
import { NotificationsPrompt } from "./NotificationsPrompt";
|
||||
import * as push from "@/lib/push";
|
||||
|
||||
describe("предложение включить уведомления", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(push.installed).mockReturnValue(true);
|
||||
vi.mocked(push.currentState).mockResolvedValue("off");
|
||||
});
|
||||
|
||||
it("установленному приложению предлагаем — своей карточкой, а не системным окном", async () => {
|
||||
render(<NotificationsPrompt />);
|
||||
expect(await screen.findByText("Сообщать о новом?")).toBeInTheDocument();
|
||||
// Системное окно вызывается только по кнопке: оно показывается один раз за
|
||||
// установку, и отказ браузер запоминает навсегда.
|
||||
expect(push.enable).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Включить" }));
|
||||
expect(push.enable).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("во вкладке браузера не предлагаем: на iPhone это всё равно не работает", async () => {
|
||||
vi.mocked(push.installed).mockReturnValue(false);
|
||||
render(<NotificationsPrompt />);
|
||||
expect(screen.queryByText("Сообщать о новом?")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("«не сейчас» откладывает на неделю, а не отказывает навсегда", async () => {
|
||||
render(<NotificationsPrompt />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Не сейчас" }));
|
||||
expect(screen.queryByText("Сообщать о новом?")).not.toBeInTheDocument();
|
||||
const until = Number(localStorage.getItem("pp-push-prompt-later"));
|
||||
const days = (until - Date.now()) / 86400_000;
|
||||
expect(days).toBeGreaterThan(6.5);
|
||||
expect(days).toBeLessThan(7.5);
|
||||
|
||||
// Пока срок не вышел — не показываем повторно.
|
||||
render(<NotificationsPrompt />);
|
||||
expect(screen.queryByText("Сообщать о новом?")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("уже включено — карточки нет", async () => {
|
||||
vi.mocked(push.currentState).mockResolvedValue("on");
|
||||
render(<NotificationsPrompt />);
|
||||
expect(screen.queryByText("Сообщать о новом?")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bell } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { currentState, enable, installed, type PushState } from "@/lib/push";
|
||||
|
||||
/**
|
||||
* Предложение включить уведомления — после установки приложения.
|
||||
*
|
||||
* Почему своя карточка, а не сразу системное окно. Системное показывается ОДИН
|
||||
* раз за установку: отказ браузер запоминает навсегда, и переспросить нельзя.
|
||||
* Своё предложение отказа не стоит — «не сейчас» просто откладывает его на
|
||||
* неделю, и вернуться к разговору можно.
|
||||
*
|
||||
* Почему после установки, а не на входе. На iPhone уведомления работают только в
|
||||
* установленном приложении, а человеку, который ещё не решил, нужен ли ему этот
|
||||
* сайт вообще, вопрос про уведомления мешает.
|
||||
*/
|
||||
const DELAY_KEY = "pp-push-prompt-later";
|
||||
const REMIND_AFTER_DAYS = 7;
|
||||
|
||||
function postponed(): boolean {
|
||||
const raw = localStorage.getItem(DELAY_KEY);
|
||||
if (!raw) return false;
|
||||
const until = Number(raw);
|
||||
return Number.isFinite(until) && Date.now() < until;
|
||||
}
|
||||
|
||||
export function NotificationsPrompt() {
|
||||
const [state, setState] = useState<PushState | null>(null);
|
||||
const [hidden, setHidden] = useState(postponed());
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const refresh = () => currentState().then((s) => { if (alive) setState(s); });
|
||||
refresh();
|
||||
// Приложение только что поставили на домашний экран — самое время спросить,
|
||||
// человек как раз занят настройкой.
|
||||
const onInstalled = () => { setHidden(false); refresh(); };
|
||||
window.addEventListener("appinstalled", onInstalled);
|
||||
return () => { alive = false; window.removeEventListener("appinstalled", onInstalled); };
|
||||
}, []);
|
||||
|
||||
// Показываем только установленному приложению и только пока разрешение не
|
||||
// спрашивали: «включить» для остальных живёт строкой внизу экрана.
|
||||
if (hidden || state !== "off" || !installed()) return null;
|
||||
|
||||
async function turnOn() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const next = await enable();
|
||||
setState(next);
|
||||
if (next !== "on") toast.error("Уведомления не включились");
|
||||
} catch {
|
||||
toast.error("Не удалось включить уведомления");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function later() {
|
||||
localStorage.setItem(DELAY_KEY, String(Date.now() + REMIND_AFTER_DAYS * 86400_000));
|
||||
setHidden(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-4 mb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Bell size={18} strokeWidth={1.5} className="text-muted shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold mb-1">Сообщать о новом?</p>
|
||||
<p className="text-muted text-xs">
|
||||
Пришлём уведомление, когда появится новый штраф или когда пополнение
|
||||
дойдёт до баланса.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button className="btn-primary" disabled={busy} onClick={turnOn}>Включить</button>
|
||||
<button className="btn-ghost" disabled={busy} onClick={later}>Не сейчас</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
vi.mock("@/lib/push", () => ({
|
||||
currentState: vi.fn(async () => "off"),
|
||||
enable: vi.fn(async () => "on"),
|
||||
}));
|
||||
|
||||
import { NotificationsRow } from "./NotificationsRow";
|
||||
import * as push from "@/lib/push";
|
||||
|
||||
describe("строка уведомлений", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(push.currentState).mockResolvedValue("off");
|
||||
});
|
||||
|
||||
it("выключенные уведомления предлагает включить", async () => {
|
||||
render(<NotificationsRow />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Включить уведомления/ }));
|
||||
expect(push.enable).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("включённые — строки нет: отказаться от них мы не предлагаем", async () => {
|
||||
// Отключение остаётся системным (настройки телефона или сайта в браузере) —
|
||||
// рядом с балансом кнопка «выключить» только подталкивала бы к отказу.
|
||||
vi.mocked(push.currentState).mockResolvedValue("on");
|
||||
const { container } = render(<NotificationsRow />);
|
||||
await vi.waitFor(() => expect(container.textContent).toBe(""));
|
||||
expect(screen.queryByText(/Выключить/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("запрещённые в браузере — объясняем, а не предлагаем кнопку", async () => {
|
||||
vi.mocked(push.currentState).mockResolvedValue("denied");
|
||||
render(<NotificationsRow />);
|
||||
expect(await screen.findByText(/запрещены в настройках браузера/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bell } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { currentState, enable, type PushState } from "@/lib/push";
|
||||
|
||||
/**
|
||||
* Включение уведомлений — одной строкой на главном экране.
|
||||
*
|
||||
* Не всплывающим окном на входе: разрешение спрашивается один раз за установку,
|
||||
* и потраченная впустую попытка закрывает канал навсегда. Здесь водитель жмёт
|
||||
* сам, увидев, ради чего.
|
||||
*
|
||||
* Выключения здесь НЕТ намеренно (решение владельца 19.08.2026). Включённые
|
||||
* уведомления — это ровно те два события, ради которых приложение и открывают;
|
||||
* кнопка «выключить» рядом с балансом только предлагает от них отказаться.
|
||||
* Способ отключить остаётся системный: настройки уведомлений телефона или
|
||||
* настройки сайта в браузере — там же, где человек управляет всеми остальными
|
||||
* приложениями.
|
||||
*/
|
||||
export function NotificationsRow() {
|
||||
const [state, setState] = useState<PushState | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
currentState().then((s) => { if (alive) setState(s); });
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
// Включено — строки нет: предлагать нечего.
|
||||
if (state === null || state === "unsupported" || state === "on") return null;
|
||||
|
||||
async function turnOn() {
|
||||
setBusy(true);
|
||||
try {
|
||||
setState(await enable());
|
||||
} catch {
|
||||
toast.error("Не удалось включить уведомления");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (state === "needs-install") {
|
||||
return (
|
||||
<p className="text-muted text-xs text-center mt-6">
|
||||
Чтобы получать уведомления о штрафах и платежах, добавьте приложение на домашний экран.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "denied") {
|
||||
return (
|
||||
<p className="text-muted text-xs text-center mt-6">
|
||||
Уведомления запрещены в настройках браузера — включить их можно только там.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex items-center justify-center gap-1.5 text-muted text-xs mt-6 w-full"
|
||||
disabled={busy}
|
||||
onClick={turnOn}
|
||||
>
|
||||
<Bell size={15} /> Включить уведомления
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export function Segment({ value, options, onChange }: Props) {
|
||||
type="button"
|
||||
onClick={() => onChange(o.value)}
|
||||
className={`flex-1 py-2.5 rounded-xl2 text-xs border ${
|
||||
value === o.value ? "bg-ink text-cream border-ink font-bold" : "bg-surface border-line text-muted"
|
||||
value === o.value ? "bg-ink text-white border-ink font-bold" : "bg-surface border-line text-muted"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Шторка снизу — выбор, который не уводит с экрана.
|
||||
*
|
||||
* Отдельным экраном такой выбор стоит дороже, чем кажется: человек теряет из
|
||||
* виду то, ради чего пришёл (баланс), и возвращается кнопкой «назад», которой на
|
||||
* телефоне ещё надо попасть. Здесь выбор происходит поверх экрана, а закрывается
|
||||
* тапом мимо, кнопкой и клавишей Escape.
|
||||
*/
|
||||
export function Sheet({ open, onClose, title, children }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
document.addEventListener("keydown", onKey);
|
||||
// Фон не должен уезжать под шторкой: иначе список категорий листает страницу.
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center">
|
||||
<button
|
||||
aria-label="Закрыть"
|
||||
className="absolute inset-0 bg-ink/40"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="relative w-full max-w-md bg-surface rounded-t-2xl pb-6 pt-2 max-h-[80vh] overflow-y-auto"
|
||||
>
|
||||
{/* Полоска-ручка: на телефоне она объясняет, что панель тянется, лучше любой подписи. */}
|
||||
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-line" />
|
||||
<p className="px-5 pb-2 text-sm font-bold">{title}</p>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ChevronRight, Image as ImageIcon } from "lucide-react";
|
||||
import { getFines, getStatement, type StatementKind } from "@/api/driver";
|
||||
import { fineStatusText, isOwed } from "@/lib/fineStatus";
|
||||
import { formatDate, formatMoney } from "@/lib/format";
|
||||
import { OperationIcon } from "@/components/operationIcon";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
|
||||
const FILTERS: { key: StatementKind; label: string }[] = [
|
||||
{ key: "all", label: "Все операции" },
|
||||
{ key: "charges", label: "Начисления" },
|
||||
{ key: "payments", label: "Пополнения" },
|
||||
{ key: "fines", label: "Штрафы" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Нижняя половина главного экрана: за что списаны деньги.
|
||||
*
|
||||
* Три фильтра показывают проводки 1С, а «Штрафы» — сами постановления, а не
|
||||
* проводки по ним. Разница существенная: штраф, который водитель оплатил сам,
|
||||
* парк не выставлял, проводки по нему нет, и в выписке он бы не появился вовсе.
|
||||
* Отдельного экрана штрафов нет намеренно — два списка одного и того же в разных
|
||||
* местах сбивают с толку.
|
||||
*/
|
||||
export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
|
||||
from: string;
|
||||
to: string;
|
||||
kind: StatementKind;
|
||||
onFrom: (v: string) => void;
|
||||
onTo: (v: string) => void;
|
||||
onKind: (k: StatementKind) => void;
|
||||
}) {
|
||||
const nav = useNavigate();
|
||||
const showFines = kind === "fines";
|
||||
|
||||
const ops = useQuery({
|
||||
queryKey: ["statement", from, to, kind],
|
||||
queryFn: () => getStatement(from, to, kind),
|
||||
enabled: !showFines,
|
||||
});
|
||||
const fines = useQuery({
|
||||
queryKey: ["fines"],
|
||||
queryFn: () => getFines("all"),
|
||||
enabled: showFines,
|
||||
});
|
||||
|
||||
const active = showFines ? fines : ops;
|
||||
// Период распространяется и на штрафы: иначе календарь над списком стоял бы,
|
||||
// ничего не меняя, — это хуже, чем его отсутствие.
|
||||
const fineRows = (fines.data?.items ?? []).filter(
|
||||
(f) => !f.date || (f.date >= from && f.date <= to),
|
||||
);
|
||||
const opRows = ops.data?.items ?? [];
|
||||
const isEmpty = showFines ? fineRows.length === 0 : opRows.length === 0;
|
||||
const rowCls = "w-full flex items-center gap-3 py-2.5 border-b border-line last:border-0 text-left";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{/* Нативный date-input: календарь телефона знакомее любого нашего. */}
|
||||
<input type="date" value={from} max={to} onChange={(e) => onFrom(e.target.value)}
|
||||
aria-label="Начало периода"
|
||||
className="card flex-1 px-3 py-2 text-xs bg-surface" />
|
||||
<span className="text-muted text-xs">—</span>
|
||||
<input type="date" value={to} min={from} onChange={(e) => onTo(e.target.value)}
|
||||
aria-label="Конец периода"
|
||||
className="card flex-1 px-3 py-2 text-xs bg-surface" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 mb-3 overflow-x-auto">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => onKind(f.key)}
|
||||
className={`shrink-0 px-3 py-2 rounded-xl2 text-xs border ${
|
||||
f.key === kind ? "border-ink bg-surface font-semibold" : "border-line text-muted"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{active.isLoading ? (
|
||||
<div className="flex justify-center py-8"><Spinner /></div>
|
||||
) : active.isError ? (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-muted text-sm mb-3">Не удалось загрузить операции</p>
|
||||
<button className="btn-ghost" onClick={() => active.refetch()}>Повторить</button>
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<p className="text-muted text-sm text-center py-8">
|
||||
{showFines ? "Штрафов за период нет" : "Нет операций за выбранный период"}
|
||||
</p>
|
||||
) : showFines ? (
|
||||
<div>
|
||||
{fineRows.map((f) => (
|
||||
// Штраф ведёт в карточку: там фотография с камеры, место и скорость.
|
||||
<button key={f.id} className={rowCls} onClick={() => nav(`/fines/${f.id}`)}>
|
||||
<OperationIcon title="Штраф" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5 text-xs font-semibold">
|
||||
{formatDate(f.date)}
|
||||
{f.has_photo && <ImageIcon size={12} strokeWidth={1.5} className="text-muted" />}
|
||||
</span>
|
||||
<span className="block text-sm truncate">{f.article}</span>
|
||||
<span className="block text-muted text-xs">
|
||||
{fineStatusText(f)}{f.car_number ? ` · ${f.car_number}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<b className={`shrink-0 ${isOwed(f) ? "text-neg" : "text-muted"}`}>
|
||||
{formatMoney(f.amount)}
|
||||
</b>
|
||||
<ChevronRight size={16} className="text-muted shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{opRows.map((op, idx) => {
|
||||
const row = (
|
||||
<>
|
||||
<OperationIcon title={op.title} />
|
||||
<span className="min-w-0 flex-1">
|
||||
{/* Дата — первой строкой: в выписке ищут «что было 12-го», а не
|
||||
название операции. */}
|
||||
<span className="block text-xs font-semibold">{formatDate(op.date)}</span>
|
||||
<span className="block text-sm">{op.title}</span>
|
||||
{op.description && (
|
||||
<span className="block text-muted text-xs truncate">{op.description}</span>
|
||||
)}
|
||||
</span>
|
||||
<b className={`shrink-0 ${op.amount < 0 ? "text-neg" : "text-pos"}`}>
|
||||
{formatMoney(op.amount)}
|
||||
</b>
|
||||
</>
|
||||
);
|
||||
return op.fine_id != null ? (
|
||||
<button key={`${op.date}-${idx}`} className={rowCls}
|
||||
onClick={() => nav(`/fines/${op.fine_id}`)}>
|
||||
{row}
|
||||
<ChevronRight size={16} className="text-muted shrink-0" />
|
||||
</button>
|
||||
) : (
|
||||
<div key={`${op.date}-${idx}`} className={rowCls}>{row}</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { operationIcon } from "./operationIcon";
|
||||
|
||||
/** Названия — настоящие теги 1С из выписки (finance_ledger_entries.tag). */
|
||||
describe("значок операции", () => {
|
||||
it("разные виды операций различаются", () => {
|
||||
const names = [
|
||||
"Аренда автомобиля",
|
||||
"Пени",
|
||||
"Штраф ГИБДД",
|
||||
"Пополнение баланса через мобильное приложение",
|
||||
"ДТП",
|
||||
"Перевод с депозита на баланс",
|
||||
].map((t) => operationIcon(t));
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
it("штраф ГИБДД и штраф компании — один значок: для водителя это одно и то же", () => {
|
||||
expect(operationIcon("Штраф ГИБДД")).toBe(operationIcon("Штраф компании"));
|
||||
});
|
||||
|
||||
it("незнакомый тег получает нейтральный значок, а не пустоту", () => {
|
||||
expect(operationIcon("Списание за что-то новое")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Banknote, Car, Flag, Gavel, Gift, HandCoins, Percent, PiggyBank, Receipt,
|
||||
RefreshCcw, ShieldAlert, TriangleAlert, Wrench, type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* Значок операции в выписке — по тегу 1С, а не по знаку суммы.
|
||||
*
|
||||
* Список сливается в стену текста: за неделю у активного водителя два десятка
|
||||
* строк, и половина из них — одинаковая по виду «аренда автомобиля». Значок
|
||||
* даёт зацепку для глаза, чтобы взгляд находил нужное, не читая каждую строку.
|
||||
*
|
||||
* Общий компонент, а не иконка на месте применения: тот же приём, что в складе
|
||||
* CRM (modules/warehouse/categoryIcons.tsx). Разложены по местам — и размеры с
|
||||
* толщиной линии разъезжаются при первой же правке.
|
||||
*
|
||||
* Монохромные намеренно: цвет в этом списке уже занят и означает направление
|
||||
* денег (красное — списали, зелёное — пришло). Цветной значок спорил бы с ним.
|
||||
*/
|
||||
const BY_TAG: [RegExp, LucideIcon][] = [
|
||||
[/аренда/i, Car],
|
||||
[/пени/i, Percent],
|
||||
[/штраф/i, Gavel],
|
||||
[/дтп|повреждени/i, TriangleAlert],
|
||||
[/депозит/i, PiggyBank],
|
||||
[/пополнение|оплата/i, Banknote],
|
||||
[/ремонт|запчаст/i, Wrench],
|
||||
[/компенсац/i, HandCoins],
|
||||
[/акци|бонус|подар/i, Gift],
|
||||
[/коррекц|сторно|перерасч/i, RefreshCcw],
|
||||
[/страхов/i, ShieldAlert],
|
||||
[/остаток|opening/i, Flag],
|
||||
];
|
||||
|
||||
export function operationIcon(title: string): LucideIcon {
|
||||
for (const [re, icon] of BY_TAG) {
|
||||
if (re.test(title)) return icon;
|
||||
}
|
||||
return Receipt;
|
||||
}
|
||||
|
||||
/** Значок в рамке — как в списке склада: одинаковый размер у всех строк. */
|
||||
export function OperationIcon({ title }: { title: string }) {
|
||||
const Icon = operationIcon(title);
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="shrink-0 w-8 h-8 rounded-xl2 border border-line bg-canvas flex items-center justify-center text-muted"
|
||||
>
|
||||
<Icon size={16} strokeWidth={1.5} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,14 @@
|
||||
@tailwind utilities;
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
body { @apply bg-cream text-ink font-sans antialiased; margin: 0; }
|
||||
body { @apply bg-canvas text-ink font-sans antialiased; margin: 0; }
|
||||
button { font-family: inherit; }
|
||||
|
||||
@layer components {
|
||||
.btn-primary { @apply bg-ink text-cream rounded-xl2 py-3.5 px-4 text-sm font-bold text-center w-full active:opacity-90 disabled:opacity-50; }
|
||||
.btn-ghost { @apply border border-ink text-ink rounded-xl2 py-3 px-4 text-sm font-semibold text-center w-full; }
|
||||
/* Кнопки повторяют сайт: действие — фирменным красным с белым текстом
|
||||
(.s-btn--accent), второстепенное — белым с чернильным текстом и рамкой
|
||||
(.s-btn--ghost). */
|
||||
.btn-primary { @apply bg-accent text-white rounded-xl2 py-3.5 px-4 text-sm font-bold text-center w-full active:bg-accent-press disabled:opacity-50; }
|
||||
.btn-ghost { @apply bg-surface border border-line text-ink rounded-xl2 py-3 px-4 text-sm font-semibold text-center w-full active:bg-canvas-soft; }
|
||||
.card { @apply bg-surface border border-line rounded-2xl; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FineBrief } from "@/api/driver";
|
||||
|
||||
/**
|
||||
* Что написать водителю о штрафе.
|
||||
*
|
||||
* Ключевое различие, которое интерфейс обязан удерживать: постановление в ГИБДД
|
||||
* гасит ПАРК, обычно авансом и со скидкой, — и с этого момента штраф закрыт для
|
||||
* государства, но не для водителя. Водитель возвращает деньги парку, и это видно
|
||||
* только по балансу «Долг по штрафам». Поэтому слово «оплачен» здесь не
|
||||
* используется вовсе: оно означало бы для человека «я ничего не должен».
|
||||
*/
|
||||
export function fineStatusText(f: Pick<FineBrief, "settled">): string {
|
||||
switch (f.settled) {
|
||||
case "owed":
|
||||
return "не погашен";
|
||||
case "paid":
|
||||
return "погашен";
|
||||
case "not_charged":
|
||||
return "не выставлялся";
|
||||
case "cancelled":
|
||||
return "отменён";
|
||||
default:
|
||||
return "уточняется";
|
||||
}
|
||||
}
|
||||
|
||||
/** Долг именно этого штрафа перед парком — по нему красим сумму. */
|
||||
export function isOwed(f: Pick<FineBrief, "settled">): boolean {
|
||||
return f.settled === "owed";
|
||||
}
|
||||
@@ -30,3 +30,21 @@ export function formatPhone(national10: string): string {
|
||||
if (d.length > 8) p.push("-" + d.slice(8, 10));
|
||||
return p.join("");
|
||||
}
|
||||
|
||||
/** Дата человеку — ДД.ММ.ГГГГ. На проводе и в <input type="date"> остаётся ISO. */
|
||||
export function formatDate(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
const [y, m, d] = iso.split("-");
|
||||
return y && m && d ? `${d}.${m}.${y}` : iso;
|
||||
}
|
||||
|
||||
/** ISO-дата на N дней назад от сегодня — для периода выписки по умолчанию. */
|
||||
export function isoDaysAgo(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function isoToday(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Отметка «водитель только что платил» — общая для экранов пополнения и баланса.
|
||||
*
|
||||
* Зачем она нужна, если экран баланса и так опрашивает себя, пока висит
|
||||
* «Зачисляется» (pending_topup > 0). У опроса по одному этому признаку два
|
||||
* слепых пятна, и оба приходятся ровно на сбойные сценарии:
|
||||
*
|
||||
* 1. Водитель ушёл с экрана оплаты жестом «назад» ДО прихода вебхука банка
|
||||
* (СБП подтверждается и через 10–60 секунд). Баланс на тот момент отвечает
|
||||
* pending_topup=0 — опрос не взводится, и когда вебхук наконец ставит PAID,
|
||||
* перезапросить баланс уже некому: экран оплаты размонтирован вместе со
|
||||
* своим опросом. Старый итог висит без строки «Зачисляется» бессрочно.
|
||||
*
|
||||
* 2. «Зачисляется» гаснет в момент записи платежа в 1С, и в норме итог в том же
|
||||
* ответе уже новый. Но точечное обновление снимка — не гарантия (1С может
|
||||
* не ответить, тогда снимок догонит общий синк за ≤2 минуты): опрос,
|
||||
* выключившийся по pending_topup=0, этот догон уже не увидит — деньги
|
||||
* «пропадают» с экрана до смены фокуса.
|
||||
*
|
||||
* Поэтому платёжная активность (создание платежа, факт оплаты) оставляет
|
||||
* отметку, и баланс продолжает опрашиваться ещё WATCH_MS после неё — окно с
|
||||
* запасом накрывает и лаг вебхука, и полный цикл общего синка.
|
||||
*
|
||||
* sessionStorage, а не переменная модуля: PWA на телефоне перезагружает
|
||||
* страницу охотно (нехватка памяти, возврат из банковского приложения), и
|
||||
* отметка в памяти не пережила бы ровно тот переход, ради которого заведена.
|
||||
*/
|
||||
|
||||
const KEY = "pp-payment-activity-at";
|
||||
|
||||
/** Окно опроса после платёжной активности: лаг вебхука (до минуты) + запись в
|
||||
* 1С (≤15 с) + общий синк снимка (≤2 мин) — с запасом. */
|
||||
export const WATCH_MS = 5 * 60_000;
|
||||
|
||||
export function markPaymentActivity(): void {
|
||||
try {
|
||||
sessionStorage.setItem(KEY, String(Date.now()));
|
||||
} catch {
|
||||
// Приватный режим или отключённое хранилище: без отметки остаётся опрос по
|
||||
// «Зачисляется» — деградация до прежнего поведения, а не поломка.
|
||||
}
|
||||
}
|
||||
|
||||
export function paymentActivityWithin(ms: number = WATCH_MS): boolean {
|
||||
try {
|
||||
const t = Number(sessionStorage.getItem(KEY) || 0);
|
||||
return t > 0 && Date.now() - t < ms;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/api/driver", () => ({
|
||||
getPushKey: vi.fn(async () => ({ key: "BIIh_RELLY", enabled: true })),
|
||||
subscribePush: vi.fn(async () => ({ ok: true })),
|
||||
unsubscribePush: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
|
||||
import { currentState, enable, supported } from "./push";
|
||||
import * as api from "@/api/driver";
|
||||
|
||||
const SUB = {
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc",
|
||||
toJSON: () => ({
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc",
|
||||
keys: { p256dh: "p256", auth: "auth" },
|
||||
}),
|
||||
unsubscribe: vi.fn(async () => true),
|
||||
};
|
||||
|
||||
function stubBrowser(opts: {
|
||||
permission?: NotificationPermission;
|
||||
existing?: unknown;
|
||||
ios?: boolean;
|
||||
standalone?: boolean;
|
||||
} = {}) {
|
||||
const perm = opts.permission ?? "default";
|
||||
const registration = {
|
||||
pushManager: {
|
||||
getSubscription: vi.fn(async () => opts.existing ?? null),
|
||||
subscribe: vi.fn(async () => SUB),
|
||||
},
|
||||
};
|
||||
vi.stubGlobal("navigator", {
|
||||
serviceWorker: {
|
||||
getRegistration: vi.fn(async () => registration),
|
||||
ready: Promise.resolve(registration),
|
||||
},
|
||||
userAgent: opts.ios ? "iPhone" : "Android Chrome",
|
||||
standalone: opts.standalone,
|
||||
});
|
||||
const notification = {
|
||||
permission: perm,
|
||||
requestPermission: vi.fn(async () => (opts.permission === "denied" ? "denied" : "granted")),
|
||||
};
|
||||
// Notification обязан лежать И в window: supported() проверяет именно его —
|
||||
// так же, как это делает браузер.
|
||||
vi.stubGlobal("window", {
|
||||
PushManager: function () {},
|
||||
Notification: notification,
|
||||
matchMedia: () => ({ matches: !!opts.standalone }),
|
||||
});
|
||||
vi.stubGlobal("Notification", notification);
|
||||
return registration;
|
||||
}
|
||||
|
||||
describe("уведомления в приложении", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("iPhone без установки на экран: объясняем, а не молчим", async () => {
|
||||
vi.stubGlobal("navigator", { userAgent: "iPhone", standalone: false });
|
||||
vi.stubGlobal("window", { matchMedia: () => ({ matches: false }) });
|
||||
expect(supported()).toBe(false);
|
||||
expect(await currentState()).toBe("needs-install");
|
||||
});
|
||||
|
||||
it("разрешение отклонено — переспросить нечем", async () => {
|
||||
stubBrowser({ permission: "denied" });
|
||||
expect(await currentState()).toBe("denied");
|
||||
});
|
||||
|
||||
it("подписка уходит на сервер с ключами из браузера", async () => {
|
||||
stubBrowser();
|
||||
expect(await enable()).toBe("on");
|
||||
expect(api.subscribePush).toHaveBeenCalledWith({
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc",
|
||||
p256dh: "p256",
|
||||
auth: "auth",
|
||||
});
|
||||
});
|
||||
|
||||
it("сервер не настроен — разрешение НЕ спрашиваем", async () => {
|
||||
// Единственная попытка на установку: потратить её впустую нельзя.
|
||||
vi.mocked(api.getPushKey).mockResolvedValueOnce({ key: "", enabled: false });
|
||||
stubBrowser();
|
||||
expect(await enable()).toBe("off");
|
||||
expect(Notification.requestPermission).not.toHaveBeenCalled();
|
||||
expect(api.subscribePush).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { getPushKey, subscribePush, unsubscribePush } from "@/api/driver";
|
||||
|
||||
/**
|
||||
* Включение уведомлений в приложении.
|
||||
*
|
||||
* Главное правило экрана вокруг этого кода: разрешение спрашивается ОДИН раз за
|
||||
* всю жизнь установки. Отказ браузер запоминает, переспросить нельзя — только
|
||||
* руками в настройках сайта, куда водитель не пойдёт. Поэтому запрос идёт
|
||||
* строго по нажатию и только когда сервер подтвердил, что уведомления настроены.
|
||||
*/
|
||||
export type PushState =
|
||||
| "unsupported" // браузер не умеет (или iOS без установки на домашний экран)
|
||||
| "needs-install" // iOS: умеет, но только когда приложение добавлено на экран
|
||||
| "denied" // разрешение отклонено — переспросить нечем
|
||||
| "off" // можно включить
|
||||
| "on"; // подписка есть
|
||||
|
||||
/** Приложение открыто как установленное (с домашнего экрана), а не вкладкой. */
|
||||
export function installed(): boolean {
|
||||
return isStandalone();
|
||||
}
|
||||
|
||||
function isStandalone(): boolean {
|
||||
return window.matchMedia("(display-mode: standalone)").matches ||
|
||||
// iOS до 17 не поддерживает display-mode: standalone в matchMedia
|
||||
(navigator as { standalone?: boolean }).standalone === true;
|
||||
}
|
||||
|
||||
function isIos(): boolean {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
export function supported(): boolean {
|
||||
return "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||
}
|
||||
|
||||
export async function currentState(): Promise<PushState> {
|
||||
if (!supported()) return isIos() && !isStandalone() ? "needs-install" : "unsupported";
|
||||
if (Notification.permission === "denied") return "denied";
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
return sub ? "on" : "off";
|
||||
}
|
||||
|
||||
/** base64url → байты: ключ VAPID браузер принимает только так.
|
||||
* Возвращаем ArrayBuffer, а не Uint8Array: типы DOM ждут BufferSource. */
|
||||
function keyToBytes(base64: string): ArrayBuffer {
|
||||
const padded = base64.replace(/-/g, "+").replace(/_/g, "/") +
|
||||
"=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const raw = atob(padded);
|
||||
const bytes = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/** Включить уведомления. Возвращает новое состояние. */
|
||||
export async function enable(): Promise<PushState> {
|
||||
if (!supported()) return currentState();
|
||||
const { key, enabled } = await getPushKey();
|
||||
// Ключа нет — сервер уведомления не отправляет. Разрешение не спрашиваем:
|
||||
// потратить единственную попытку впустую нельзя.
|
||||
if (!enabled || !key) return "off";
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") return permission === "denied" ? "denied" : "off";
|
||||
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: keyToBytes(key),
|
||||
});
|
||||
const json = sub.toJSON() as { endpoint?: string; keys?: { p256dh?: string; auth?: string } };
|
||||
await subscribePush({
|
||||
endpoint: json.endpoint ?? sub.endpoint,
|
||||
p256dh: json.keys?.p256dh ?? "",
|
||||
auth: json.keys?.auth ?? "",
|
||||
});
|
||||
return "on";
|
||||
}
|
||||
|
||||
/** Выключить: снимаем подписку и в браузере, и на сервере. */
|
||||
export async function disable(): Promise<PushState> {
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
// Сначала сервер: не сумеем достучаться — подписка останется живой, и
|
||||
// уведомления продолжат приходить, а это лучше «выключил, а они идут».
|
||||
await unsubscribePush(sub.endpoint);
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
return "off";
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Куда вернуть водителя после входа.
|
||||
*
|
||||
* Ссылки на оплату (`/pay/:id`) открываются под RequireAuth: незалогиненного
|
||||
* бросает на /login. Раньше после входа он попадал на баланс, а не на оплату,
|
||||
* ради которой пришёл. Запоминаем нужный путь тут (переживает переход через
|
||||
* /tg-wait и онбординг), после любого успешного входа — забираем и очищаем.
|
||||
*/
|
||||
const KEY = "pp-return-to";
|
||||
|
||||
/** Путь для сохранения — только внутренние маршруты приложения. */
|
||||
export function setReturnTo(path: string | null | undefined): void {
|
||||
try {
|
||||
if (path && path.startsWith("/") && !path.startsWith("//") && path !== "/login") {
|
||||
sessionStorage.setItem(KEY, path);
|
||||
}
|
||||
} catch {
|
||||
/* приватный режим/недоступный storage — не критично */
|
||||
}
|
||||
}
|
||||
|
||||
/** Забрать сохранённый путь (и очистить). По умолчанию — на главную. */
|
||||
export function takeReturnTo(): string {
|
||||
try {
|
||||
const v = sessionStorage.getItem(KEY);
|
||||
sessionStorage.removeItem(KEY);
|
||||
if (v && v.startsWith("/") && !v.startsWith("//")) return v;
|
||||
} catch {
|
||||
/* см. выше */
|
||||
}
|
||||
return "/";
|
||||
}
|
||||
@@ -1,47 +1,175 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
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: -3200 },
|
||||
{ bucket: "Депозит", balance: 5000 },
|
||||
{ bucket: "Долг аренда", balance: -3050, payable: true },
|
||||
{ bucket: "Долг по штрафам", balance: -5156, payable: true },
|
||||
{ bucket: "Страховка", balance: 200, payable: false },
|
||||
],
|
||||
total: -8976.25,
|
||||
synced_at: null,
|
||||
pending_topup: 1500,
|
||||
})),
|
||||
getFines: vi.fn(async () => ({
|
||||
items: [{
|
||||
id: 12, number: "18810525260813035846", date: "2026-08-13",
|
||||
article: "12.09.2 - Превышение скорости", address: "", car_number: "А947УТ125",
|
||||
amount: 618.75, settled: "owed" as const, cancelled: false, has_photo: true,
|
||||
}],
|
||||
open_count: 1, debt: -5156,
|
||||
})),
|
||||
getStatement: vi.fn(async () => ({
|
||||
items: [
|
||||
{ date: "2026-08-17", title: "Аренда автомобиля", description: "ТС А947УТ125",
|
||||
amount: -2600, kind: "charges" },
|
||||
{ date: "2026-08-17", title: "Пополнение баланса", description: "", amount: 1000,
|
||||
kind: "payments" },
|
||||
],
|
||||
from: "2026-08-11", to: "2026-08-18", kind: "all",
|
||||
})),
|
||||
}));
|
||||
|
||||
import { BalancePage } from "./BalancePage";
|
||||
// Экран режет постановления по периоду «последние 7 дней от сегодня», а даты в
|
||||
// моках фиксированные: без замороженных часов штраф от 13.08 выпадает из окна,
|
||||
// как только настоящее «сегодня» уходит за 20.08. Морозим только Date — таймеры
|
||||
// остаются настоящими, иначе userEvent и waitFor зависнут.
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-08-18T12:00:00Z"));
|
||||
});
|
||||
afterAll(() => vi.useRealTimers());
|
||||
|
||||
import { BalancePage, balancePollMs } from "./BalancePage";
|
||||
import { markPaymentActivity } from "@/lib/paymentWatch";
|
||||
import * as driver from "@/api/driver";
|
||||
|
||||
function wrap() {
|
||||
const qc = new QueryClient();
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter><BalancePage /></MemoryRouter>
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<BalancePage />} />
|
||||
<Route path="/topup" element={<p>экран пополнения</p>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("BalancePage", () => {
|
||||
it("shows hero total (sum of negatives) and account rows", async () => {
|
||||
wrap();
|
||||
expect(await screen.findByText("Долг аренда")).toBeInTheDocument();
|
||||
expect(screen.getByText("Депозит")).toBeInTheDocument();
|
||||
// Appears in both the hero total and the single debt account row.
|
||||
expect(screen.getAllByText("−3 200 ₽").length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole("button", { name: /Пополнить/i })).toBeInTheDocument();
|
||||
describe("главный экран", () => {
|
||||
it("опрос баланса: пока висит «зачисляется» или водитель недавно платил", () => {
|
||||
// Водитель после оплаты смотрит на экран и ждёт новый итог — экран обязан
|
||||
// обновиться сам. Вне платёжных окон опроса быть не должно.
|
||||
sessionStorage.clear();
|
||||
expect(balancePollMs({ pending_topup: 1500 })).toBe(4000);
|
||||
expect(balancePollMs({ pending_topup: 0 })).toBe(false);
|
||||
expect(balancePollMs(undefined)).toBe(false);
|
||||
// Свежая платёжная активность держит опрос и без «зачисляется»: вебхук
|
||||
// банка может запаздывать (опрос обязан застать появление строки), а снимок
|
||||
// баланса — догонять платёж общим синком уже после её погашения.
|
||||
markPaymentActivity();
|
||||
expect(balancePollMs({ pending_topup: 0 })).toBe(4000);
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("shows an error state when balance fails to load", async () => {
|
||||
const driver = await import("@/api/driver");
|
||||
(driver.getBalance as any).mockRejectedValueOnce(new Error("boom"));
|
||||
// re-render fresh
|
||||
const { QueryClient, QueryClientProvider } = await import("@tanstack/react-query");
|
||||
const { MemoryRouter } = await import("react-router-dom");
|
||||
const { render, screen } = await import("@testing-library/react");
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={qc}><MemoryRouter><BalancePage /></MemoryRouter></QueryClientProvider>);
|
||||
expect(await screen.findByText(/Не удалось загрузить/i)).toBeInTheDocument();
|
||||
it("сверху деньги: итог с сервера и строка «зачисляется»", async () => {
|
||||
wrap();
|
||||
// Сумма статей дала бы −8 006, а в CRM у водителя −8 976,25. Показываем её.
|
||||
expect(await screen.findByText("−8 976,25 ₽")).toBeInTheDocument();
|
||||
expect(screen.getByText("Зачисляется")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 500 ₽")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("снизу выписка за период", async () => {
|
||||
wrap();
|
||||
expect(await screen.findByText("Аренда автомобиля")).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/17\.08\.2026/).length).toBe(2); // обе операции того дня
|
||||
expect(screen.getByRole("button", { name: "Все операции" })).toBeInTheDocument();
|
||||
expect(driver.getStatement).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("фильтр перезапрашивает операции выбранного вида", async () => {
|
||||
wrap();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Начисления" }));
|
||||
const calls = vi.mocked(driver.getStatement).mock.calls;
|
||||
expect(calls[calls.length - 1][2]).toBe("charges");
|
||||
});
|
||||
|
||||
it("фильтр «Штрафы» показывает постановления, а не проводки", async () => {
|
||||
// Штраф, оплаченный водителем самостоятельно, парк не выставлял — проводки по
|
||||
// нему нет, и в выписке он бы не появился вовсе (Морозов, 18.08.2026).
|
||||
wrap();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Штрафы" }));
|
||||
expect(await screen.findByText(/Превышение скорости/)).toBeInTheDocument();
|
||||
expect(driver.getFines).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("аренда и штрафы видны сразу, остальные статьи — нет", async () => {
|
||||
wrap();
|
||||
await screen.findByText("−8 976,25 ₽");
|
||||
// Эти две водитель проверяет каждый день — они на экране.
|
||||
expect(screen.getByText("Долг аренда")).toBeInTheDocument();
|
||||
expect(screen.getByText("Долг по штрафам")).toBeInTheDocument();
|
||||
expect(screen.getByText("−3 050 ₽")).toBeInTheDocument();
|
||||
// Редкие статьи на экран не лезут: для них есть шторка.
|
||||
expect(screen.queryByText("Страховка")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("аренда ведёт к оплате, а штрафы — в свой список", async () => {
|
||||
wrap();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Долг аренда/ }));
|
||||
expect(await screen.findByText("экран пополнения")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("строка штрафов включает фильтр внизу, а не уводит на другой экран", async () => {
|
||||
wrap();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Долг по штрафам/ }));
|
||||
expect(await screen.findByText(/Превышение скорости/)).toBeInTheDocument();
|
||||
expect(driver.getFines).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("категории для выбора живут в шторке «Пополнить»", async () => {
|
||||
wrap();
|
||||
await screen.findByText("−8 976,25 ₽");
|
||||
await userEvent.click(screen.getByRole("button", { name: /^Пополнить$/ }));
|
||||
const sheet = await screen.findByRole("dialog", { name: "Куда пополнить" });
|
||||
const inSheet = within(sheet);
|
||||
expect(inSheet.getByText("Долг аренда")).toBeInTheDocument();
|
||||
expect(inSheet.getByText("Долг по штрафам")).toBeInTheDocument();
|
||||
// «Страховка» платежом не закрывается — в списке выбора ей делать нечего.
|
||||
expect(inSheet.queryByText("Страховка")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("шторка закрывается тапом мимо", async () => {
|
||||
wrap();
|
||||
await screen.findByText("−8 976,25 ₽");
|
||||
await userEvent.click(screen.getByRole("button", { name: /^Пополнить$/ }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Закрыть" }));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("отдельного раздела пополнений нет — они в выписке", async () => {
|
||||
// Раздел «Мои пополнения» показывал наши платежи и у большинства водителей был
|
||||
// пуст: 1С проводит пополнения сама, и они уже видны фильтром в выписке.
|
||||
wrap();
|
||||
await screen.findByText("−8 976,25 ₽");
|
||||
expect(screen.queryByText(/Мои пополнения/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Пополнения" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("баланс не загрузился — экран говорит об этом, а не рисует ноль", async () => {
|
||||
vi.mocked(driver.getBalance).mockRejectedValueOnce(new Error("boom"));
|
||||
wrap();
|
||||
expect(await screen.findByRole("button", { name: /Обновить баланс/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,69 +1,167 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { History, LogOut } from "lucide-react";
|
||||
import { getBalance } from "@/api/driver";
|
||||
import { formatMoney } from "@/lib/format";
|
||||
import { ArrowUp, ChevronRight, LogOut } from "lucide-react";
|
||||
import { getBalance, type StatementKind } from "@/api/driver";
|
||||
import { formatMoney, isoDaysAgo, isoToday } from "@/lib/format";
|
||||
import { paymentActivityWithin } from "@/lib/paymentWatch";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { AppHeader } from "@/components/AppHeader";
|
||||
import { NotificationsPrompt } from "@/components/NotificationsPrompt";
|
||||
import { NotificationsRow } from "@/components/NotificationsRow";
|
||||
import { Sheet } from "@/components/Sheet";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { Statement } from "@/components/Statement";
|
||||
|
||||
// Две статьи, которые водитель проверяет каждый день: за них он платит и из-за
|
||||
// них ему звонят. Остальные (депозит, повреждения, прочее) — редкие, им хватает
|
||||
// шторки «Пополнить», иначе экран снова превращается в столбик из шести строк.
|
||||
const MAIN_BUCKETS = ["Долг аренда", "Долг по штрафам"];
|
||||
|
||||
// Пока висит «Зачисляется» ЛИБО водитель недавно платил, экран опрашивает
|
||||
// баланс сам: платёж доезжает до 1С за секунды, и новый итог должен появиться
|
||||
// на глазах — водитель после оплаты смотрит именно сюда, а перезапускать
|
||||
// приложение он не обязан. Одного «зачисляется» мало: опрос по нему не
|
||||
// взводится, пока вебхук банка запаздывает, и гаснет раньше, чем снимок
|
||||
// баланса догонит платёж при сбое точечного обновления (см. lib/paymentWatch).
|
||||
// Вне этих окон опрос не нужен: остальные движения баланса водитель не
|
||||
// караулит у экрана.
|
||||
export const balancePollMs = (d?: { pending_topup?: number }): number | false =>
|
||||
(d?.pending_topup ?? 0) > 0 || paymentActivityWithin() ? 4000 : false;
|
||||
|
||||
/**
|
||||
* Главный экран из двух половин: сверху деньги, снизу — за что они списаны.
|
||||
*
|
||||
* Разбивка по статьям живёт в шторке «Пополнить», а не на экране: водителю она
|
||||
* нужна ровно в момент, когда он решает, куда платить, а всё остальное время
|
||||
* это шесть строк, отодвигающих выписку за нижний край телефона.
|
||||
*/
|
||||
export function BalancePage() {
|
||||
const nav = useNavigate();
|
||||
const clear = useAuth((s) => s.clear);
|
||||
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["balance"], queryFn: getBalance });
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["balance"],
|
||||
queryFn: getBalance,
|
||||
refetchInterval: (q) => balancePollMs(q.state.data),
|
||||
});
|
||||
const [sheet, setSheet] = useState(false);
|
||||
// Неделя — тот же период, что у вендора: столько человек помнит свои поездки.
|
||||
const [from, setFrom] = useState(isoDaysAgo(7));
|
||||
const [to, setTo] = useState(isoToday());
|
||||
const [kind, setKind] = useState<StatementKind>("all");
|
||||
|
||||
const accounts = data?.accounts ?? [];
|
||||
const total = accounts.reduce((s, a) => s + a.balance, 0);
|
||||
const total = data?.total ?? accounts.reduce((s, a) => s + a.balance, 0);
|
||||
const pending = data?.pending_topup ?? 0;
|
||||
const noData = !isLoading && !isError && accounts.length === 0;
|
||||
const payable = accounts.filter((a) => a.payable);
|
||||
const main = MAIN_BUCKETS.map((b) => accounts.find((a) => a.bucket === b)).filter(
|
||||
(a): a is NonNullable<typeof a> => a != null,
|
||||
);
|
||||
|
||||
function pick(bucket?: string) {
|
||||
setSheet(false);
|
||||
nav("/topup", { state: bucket ? { bucket } : undefined });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AppHeader />
|
||||
<div className="card p-4 mb-4 text-center">
|
||||
<p className="text-muted text-xs mb-1">Баланс</p>
|
||||
<p className={`text-2xl font-extrabold ${total < 0 ? "text-neg" : "text-pos"}`}>{formatMoney(total)}</p>
|
||||
{pending > 0 && (
|
||||
// Платёж принят нами, но 1С его ещё не провела. В сумму баланса он не
|
||||
// входит намеренно: сложить оба источника значило бы показать
|
||||
// пополнение дважды, как только расчётный лист принесёт тот же платёж.
|
||||
<p className="text-muted text-xs mt-2">
|
||||
{formatMoney(pending)} зачисляется — учтётся после проведения
|
||||
</p>
|
||||
<AppHeader bell />
|
||||
|
||||
<NotificationsPrompt />
|
||||
|
||||
{/* ── Деньги ─────────────────────────────────────────────── */}
|
||||
<div className="card p-4 mb-3">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="text-muted text-xs">Баланс в парке</span>
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : noData ? (
|
||||
<span className="text-sm font-semibold">уточняется</span>
|
||||
) : (
|
||||
<b className={`text-2xl ${total < 0 ? "text-neg" : "text-pos"}`}>{formatMoney(total)}</b>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-10"><Spinner /></div>
|
||||
) : isError ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted text-sm mb-3">Не удалось загрузить баланс</p>
|
||||
<button className="btn-ghost" onClick={() => refetch()}>Повторить</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-4">
|
||||
{accounts.map((a) => (
|
||||
{main.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-line">
|
||||
{main.map((a) => (
|
||||
// Аренда ведёт к оплате — за тем её и открывают. Штрафы включают
|
||||
// фильтр в списке ниже: «−5 156 ₽» не отвечает на вопрос «за что»,
|
||||
// а отдельного экрана штрафов нет — два списка одного и того же в
|
||||
// разных местах сбивали с толку.
|
||||
<button
|
||||
key={a.bucket}
|
||||
onClick={() => nav("/topup", { state: { bucket: a.bucket } })}
|
||||
className="w-full flex justify-between items-center py-3 border-b border-line last:border-0 text-left"
|
||||
disabled={!a.payable}
|
||||
onClick={() =>
|
||||
a.bucket === "Долг по штрафам"
|
||||
? setKind("fines")
|
||||
: nav("/topup", { state: { bucket: a.bucket } })
|
||||
}
|
||||
className="w-full flex justify-between items-baseline py-1.5 text-left disabled:opacity-100"
|
||||
>
|
||||
<span className="text-sm">{a.bucket}</span>
|
||||
<b className={a.balance < 0 ? "text-neg" : "text-pos"}>{formatMoney(a.balance)}</b>
|
||||
<span className="text-muted text-xs">{a.bucket}</span>
|
||||
<span className={`text-sm ${a.balance < 0 ? "text-neg" : a.balance > 0 ? "text-pos" : ""}`}>
|
||||
{formatMoney(a.balance)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pending > 0 && (
|
||||
// Платёж принят нами, но 1С его ещё не провела. В сумму не входит: сложить
|
||||
// оба источника значило бы показать пополнение дважды после проведения.
|
||||
<div className="flex justify-between items-baseline mt-3 pt-3 border-t border-line">
|
||||
<span className="text-muted text-xs">Зачисляется</span>
|
||||
<span className="text-sm">{formatMoney(pending)}</span>
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<button className="btn-ghost mt-3" onClick={() => refetch()}>Обновить баланс</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="btn-primary" onClick={() => nav("/topup")}>Пополнить</button>
|
||||
|
||||
<div className="flex justify-center gap-6 mt-6 text-muted text-xs">
|
||||
<button className="flex items-center gap-1.5" onClick={() => nav("/history")}>
|
||||
<History size={15} /> История
|
||||
<button className="btn-primary flex items-center justify-center gap-2 mb-5"
|
||||
onClick={() => setSheet(true)}>
|
||||
<ArrowUp size={18} /> Пополнить
|
||||
</button>
|
||||
|
||||
{/* ── За что списано ─────────────────────────────────────── */}
|
||||
<Statement from={from} to={to} kind={kind}
|
||||
onFrom={setFrom} onTo={setTo} onKind={setKind} />
|
||||
|
||||
<NotificationsRow />
|
||||
|
||||
<div className="flex justify-center mt-6 text-muted text-xs">
|
||||
{/* Отдельного раздела «Мои пополнения» нет намеренно: пополнения видны в
|
||||
выписке выше по фильтру, а дублирующий пустой экран сбивал с толку. */}
|
||||
<button className="flex items-center gap-1.5" onClick={() => { clear(); nav("/login"); }}>
|
||||
<LogOut size={15} /> Выйти
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Sheet open={sheet} onClose={() => setSheet(false)} title="Куда пополнить">
|
||||
{payable.length === 0 ? (
|
||||
<p className="text-muted text-sm px-5 py-4">
|
||||
Категории пока не загрузились. Попробуйте ещё раз через минуту.
|
||||
</p>
|
||||
) : (
|
||||
payable.map((a) => (
|
||||
<button
|
||||
key={a.bucket}
|
||||
onClick={() => pick(a.bucket)}
|
||||
className="w-full flex justify-between items-center gap-3 px-5 py-3.5 border-b border-line last:border-0 text-left"
|
||||
>
|
||||
<span className="text-sm">{a.bucket}</span>
|
||||
<span className="flex items-center gap-2 shrink-0">
|
||||
<b className={a.balance < 0 ? "text-neg" : a.balance > 0 ? "text-pos" : "text-muted"}>
|
||||
{formatMoney(a.balance)}
|
||||
</b>
|
||||
<ChevronRight size={16} className="text-muted" />
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -65,3 +68,35 @@ describe("вкладка отладки", () => {
|
||||
expect(screen.getByText("123 мс")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("возврат к своему аккаунту", () => {
|
||||
beforeEach(() => { useAuth.getState().clear(); useStaff.getState().clear(); clearLog(); });
|
||||
|
||||
it("свой вход не теряется при просмотре чужого экрана", () => {
|
||||
useAuth.getState().setSession("мой-токен", 91);
|
||||
useAuth.getState().startImpersonation("чужой-токен", 6866, "Тест Тестовый тестович");
|
||||
let s = useAuth.getState();
|
||||
expect([s.token, s.driverId, s.impersonated]).toEqual(["чужой-токен", 6866, true]);
|
||||
|
||||
// смена водителя внутри просмотра не должна подменять отложенный свой вход
|
||||
useAuth.getState().startImpersonation("третий-токен", 2869, "Тестик Николай");
|
||||
expect(useAuth.getState().own).toEqual({ token: "мой-токен", driverId: 91 });
|
||||
|
||||
expect(useAuth.getState().stopImpersonation()).toBe(true);
|
||||
s = useAuth.getState();
|
||||
expect([s.token, s.driverId, s.impersonated, s.own]).toEqual(["мой-токен", 91, false, null]);
|
||||
});
|
||||
|
||||
it("если своего входа не было — просмотр просто заканчивается выходом", () => {
|
||||
useAuth.getState().startImpersonation("чужой-токен", 6866, "Тест");
|
||||
expect(useAuth.getState().stopImpersonation()).toBe(false);
|
||||
expect(useAuth.getState().token).toBeNull();
|
||||
});
|
||||
|
||||
it("кнопка предлагает вернуться к себе, когда есть куда", async () => {
|
||||
useAuth.getState().setSession("мой-токен", 91);
|
||||
useAuth.getState().startImpersonation("чужой-токен", 6866, "Тест Тестовый тестович");
|
||||
renderAt("/debug");
|
||||
expect(screen.getByRole("button", { name: /Вернуться к своему аккаунту/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ function Row({ k, v }: { k: string; v: string }) {
|
||||
*/
|
||||
export function DebugPage() {
|
||||
const nav = useNavigate();
|
||||
const { token, driverId, driverName, impersonated, clear } = useAuth();
|
||||
const { token, driverId, driverName, impersonated, own, clear, stopImpersonation } = useAuth();
|
||||
const staff = useStaff();
|
||||
const log = useSyncExternalStore(subscribe, getLog);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
@@ -68,10 +68,30 @@ export function DebugPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{impersonated && (
|
||||
<div className="card p-4 mb-4">
|
||||
<p className="text-sm font-semibold mb-1">Боевая оплата</p>
|
||||
<p className="text-muted text-xs mb-3">
|
||||
Выключено — пополнение открывает учебный экран, деньги не двигаются.
|
||||
Включено — платёж уходит в банк по-настоящему: спишется с вашей карты,
|
||||
чек уйдёт на телефон водителя, сумма зачислится ему в 1С.
|
||||
</p>
|
||||
<button
|
||||
className={staff.livePay ? "btn-primary" : "btn-ghost"}
|
||||
onClick={() => staff.setLivePay(!staff.livePay)}
|
||||
>
|
||||
{staff.livePay ? "Боевая оплата включена — выключить" : "Включить боевую оплату"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card p-4 mb-4">
|
||||
<p className="text-muted text-xs mb-2">Сведения</p>
|
||||
<Row k="driver_id" v={String(driverId ?? "—")} />
|
||||
<Row k="режим" v={impersonated ? "просмотр сотрудником (imp)" : "обычный вход водителя"} />
|
||||
{impersonated && (
|
||||
<Row k="оплата" v={staff.livePay ? "боевая — деньги двигаются" : "учебная"} />
|
||||
)}
|
||||
<Row k="сотрудник" v={staff.username ?? "не входил"} />
|
||||
<Row k="токен истекает" v={expiresIn(info?.exp ?? null)} />
|
||||
<Row k="API" v={API_BASE} />
|
||||
@@ -112,9 +132,16 @@ export function DebugPage() {
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost flex items-center justify-center gap-2"
|
||||
onClick={() => { clear(); nav(staff.token ? "/dev" : "/login"); }}
|
||||
onClick={() => {
|
||||
// Свой вход, отложенный на время просмотра, возвращаем на место: иначе
|
||||
// «посмотреть чужой экран» означало бы заново входить через Telegram.
|
||||
if (impersonated && stopImpersonation()) { nav("/"); return; }
|
||||
clear();
|
||||
nav(staff.token ? "/dev" : "/login");
|
||||
}}
|
||||
>
|
||||
<LogOut size={16} /> {impersonated ? "Выйти из просмотра" : "Выйти"}
|
||||
<LogOut size={16} />{" "}
|
||||
{impersonated ? (own ? "Вернуться к своему аккаунту" : "Выйти из просмотра") : "Выйти"}
|
||||
</button>
|
||||
{staff.token && (
|
||||
<button className="text-muted text-xs w-full mt-4"
|
||||
|
||||
@@ -59,8 +59,9 @@ describe("DevLoginPage", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
|
||||
expect(useStaff.getState().token).toBeNull();
|
||||
|
||||
// Шестая цифра отправляет код сама: раньше форма ждала нажатия, а «Готово» на
|
||||
// клавиатуре телефона ничего не делало — выглядело как «код не принят».
|
||||
await userEvent.type(await screen.findByPlaceholderText("000000"), "123456");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Подтвердить" }));
|
||||
expect(staffApi.staffLogin2fa).toHaveBeenCalledWith("partial", "123456");
|
||||
expect(useStaff.getState().token).toBe("staff-tok");
|
||||
});
|
||||
|
||||
@@ -42,11 +42,11 @@ export function DevLoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submit2fa() {
|
||||
if (!partial || code.length < 6 || busy) return;
|
||||
async function submit2fa(value = code) {
|
||||
if (!partial || value.length < 6 || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await staffLogin2fa(partial, code);
|
||||
const r = await staffLogin2fa(partial, value);
|
||||
if (!r.access_token) throw new Error("no token");
|
||||
setStaff(r.access_token, username);
|
||||
setPartial(null);
|
||||
@@ -80,13 +80,23 @@ export function DevLoginPage() {
|
||||
<>
|
||||
<p className="text-sm mb-2">Код из приложения-аутентификатора.</p>
|
||||
<input
|
||||
autoFocus
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value.replace(/\D/g, "").slice(0, 6);
|
||||
setCode(next);
|
||||
// Шесть цифр — код собран, ждать нажатия нечего: у TOTP всё равно
|
||||
// остаётся секунд тридцать. Раньше отправки не было ни по Enter, ни
|
||||
// по «Готово» на клавиатуре телефона, и это выглядело как «код не принят».
|
||||
if (next.length === 6) void submit2fa(next);
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void submit2fa(); }}
|
||||
placeholder="000000"
|
||||
className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-white"
|
||||
className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-surface"
|
||||
/>
|
||||
<button className="btn-primary" disabled={busy || code.length < 6} onClick={submit2fa}>
|
||||
<button className="btn-primary" disabled={busy || code.length < 6} onClick={() => submit2fa()}>
|
||||
{busy ? <Spinner /> : "Подтвердить"}
|
||||
</button>
|
||||
<button className="btn-ghost mt-2" onClick={() => { setPartial(null); setCode(""); }}>
|
||||
@@ -101,7 +111,7 @@ export function DevLoginPage() {
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Логин"
|
||||
className="card w-full px-4 py-3 mb-2 text-base bg-white"
|
||||
className="card w-full px-4 py-3 mb-2 text-base bg-surface"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
@@ -109,7 +119,7 @@ export function DevLoginPage() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
|
||||
placeholder="Пароль"
|
||||
className="card w-full px-4 py-3 mb-3 text-base bg-white"
|
||||
className="card w-full px-4 py-3 mb-3 text-base bg-surface"
|
||||
/>
|
||||
<button className="btn-primary" disabled={busy || !username || !password} onClick={submit}>
|
||||
{busy ? <Spinner /> : "Войти"}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { FileText, Gauge } from "lucide-react";
|
||||
import { getFine } from "@/api/driver";
|
||||
import { fineStatusText, isOwed } from "@/lib/fineStatus";
|
||||
import { formatDate, formatMoney } from "@/lib/format";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
|
||||
function Row({ k, v }: { k: string; v: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-3 py-2 border-b border-line last:border-0">
|
||||
<span className="text-muted text-xs shrink-0">{k}</span>
|
||||
<span className="text-xs text-right">{v}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Карточка штрафа. Главное здесь — фотография с камеры: по ней водитель узнаёт
|
||||
* свою машину и свою поездку либо понимает, что штраф не его. Поэтому она стоит
|
||||
* первой, до реквизитов, и открывается в полный размер по нажатию.
|
||||
*
|
||||
* Что парк заплатил ГИБДД, со скидкой ли и какой номинал у постановления — здесь
|
||||
* не показывается: водителю нужен факт штрафа и сумма к возврату, а разбор
|
||||
* расчётов парка уводит разговор от долга к внутренней кухне.
|
||||
*/
|
||||
export function FineDetailPage() {
|
||||
const { fineId = "" } = useParams();
|
||||
const nav = useNavigate();
|
||||
const { data: f, isLoading, isError } = useQuery({
|
||||
queryKey: ["fine", fineId],
|
||||
queryFn: () => getFine(Number(fineId)),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="flex justify-center py-16"><Spinner /></div>;
|
||||
if (isError || !f) {
|
||||
return (
|
||||
<div className="pt-16 text-center">
|
||||
<p className="text-sm font-semibold mb-1">Штраф не найден</p>
|
||||
<p className="text-muted text-xs mb-6">Возможно, он больше не числится за вами.</p>
|
||||
<button className="btn-ghost" onClick={() => nav("/")}>На главную</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-2 pb-4">
|
||||
<button className="text-muted text-xs mb-3" onClick={() => nav("/")}>‹ Назад</button>
|
||||
|
||||
{f.photos.length > 0 ? (
|
||||
<div className="mb-4 space-y-2">
|
||||
{f.photos.map((src, i) => (
|
||||
<a key={src} href={src} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<img
|
||||
src={src}
|
||||
alt={`Фото нарушения ${i + 1}`}
|
||||
loading="lazy"
|
||||
className="w-full rounded-xl2 border border-line"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
<p className="text-muted text-[11px]">Нажмите на фото, чтобы открыть в полном размере</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted text-xs mb-4">
|
||||
Фотографии по этому постановлению нет — её присылают не по всем нарушениям.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h1 className="text-base font-extrabold mb-1">{f.article}</h1>
|
||||
<p className="text-muted text-xs mb-4">
|
||||
{formatDate(f.date)}{f.address ? ` · ${f.address}` : ""}
|
||||
</p>
|
||||
|
||||
<div className="card p-4 mb-4 text-center">
|
||||
<p className="text-muted text-xs mb-1">Начислено вам</p>
|
||||
<p className={`text-2xl font-extrabold ${isOwed(f) ? "text-neg" : ""}`}>
|
||||
{formatMoney(f.amount)}
|
||||
</p>
|
||||
<p className="text-muted text-xs mt-1">{fineStatusText(f)}</p>
|
||||
</div>
|
||||
|
||||
{f.speed != null && f.speed_limit != null && (
|
||||
<div className="card p-4 mb-4 flex items-center gap-3">
|
||||
<Gauge size={20} strokeWidth={1.5} className="text-muted shrink-0" />
|
||||
<p className="text-sm">
|
||||
{f.speed} км/ч при разрешённых {f.speed_limit}
|
||||
{f.speed_excess ? ` — превышение на ${f.speed_excess}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card p-4 mb-4">
|
||||
<Row k="Постановление" v={f.number} />
|
||||
{f.post_date && <Row k="Дата постановления" v={formatDate(f.post_date)} />}
|
||||
{f.car_number && <Row k="Автомобиль" v={f.car_number} />}
|
||||
{f.camera && <Row k="Камера" v={f.camera} />}
|
||||
</div>
|
||||
|
||||
{f.document && (
|
||||
<a href={f.document} target="_blank" rel="noopener noreferrer"
|
||||
className="btn-ghost flex items-center justify-center gap-2 no-underline mb-2">
|
||||
<FileText size={16} /> Открыть постановление
|
||||
</a>
|
||||
)}
|
||||
|
||||
{isOwed(f) && (
|
||||
<button className="btn-primary"
|
||||
onClick={() => nav("/topup", { state: { bucket: "Долг по штрафам" } })}>
|
||||
Оплатить штрафы
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("@/api/driver", () => ({
|
||||
getPayments: vi.fn(async () => ({ payments: [
|
||||
{ order_id: "o1", bucket: "Долг аренда", amount: 1000, commission: 52, method: "qr", status: "PAID", created_at: "2026-06-18T09:00:00Z", paid_at: "2026-06-18T09:01:00Z" },
|
||||
] })),
|
||||
}));
|
||||
import { HistoryPage } from "./HistoryPage";
|
||||
|
||||
function wrap() {
|
||||
const qc = new QueryClient();
|
||||
return render(<QueryClientProvider client={qc}><MemoryRouter><HistoryPage /></MemoryRouter></QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe("HistoryPage", () => {
|
||||
it("lists payments with amount + status", async () => {
|
||||
wrap();
|
||||
expect(await screen.findByText("Долг аренда")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 000 ₽")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Оплачено/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getPayments } from "@/api/driver";
|
||||
import { formatMoney } from "@/lib/format";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
|
||||
/**
|
||||
* Все статусы, которые может прислать бэкенд. Раньше их было пять из тринадцати,
|
||||
* и водитель видел в истории «DEADLINE_EXPIRED» или «ATTEMPTS_EXPIRED» латиницей,
|
||||
* а возврат денег выглядел ровно как обычный отказ.
|
||||
*/
|
||||
const STATUS_RU: Record<string, string> = {
|
||||
NEW: "Ожидает оплаты",
|
||||
FORM: "Ожидает оплаты",
|
||||
PAID: "Оплачено",
|
||||
CONFIRMED: "Оплачено",
|
||||
REJECTED: "Отклонён банком",
|
||||
AUTH_FAIL: "Отклонён банком",
|
||||
CANCELED: "Отменён",
|
||||
DEADLINE_EXPIRED: "Истёк срок оплаты",
|
||||
ATTEMPTS_EXPIRED: "Исчерпаны попытки оплаты",
|
||||
ERROR: "Ошибка",
|
||||
REVERSED: "Деньги возвращены",
|
||||
REFUNDED: "Деньги возвращены",
|
||||
PARTIAL_REVERSED: "Возвращена часть",
|
||||
PARTIAL_REFUNDED: "Возвращена часть",
|
||||
};
|
||||
|
||||
const RETURNED = new Set(["REVERSED", "REFUNDED", "PARTIAL_REVERSED", "PARTIAL_REFUNDED"]);
|
||||
|
||||
export function HistoryPage() {
|
||||
const nav = useNavigate();
|
||||
const { data, isLoading, isError } = useQuery({ queryKey: ["payments"], queryFn: getPayments });
|
||||
const rows = data?.payments ?? [];
|
||||
|
||||
return (
|
||||
<div className="pt-2">
|
||||
<button className="text-muted text-xs mb-3" onClick={() => nav("/")}>‹ Назад</button>
|
||||
<h1 className="text-lg font-extrabold mb-4">История пополнений</h1>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-10"><Spinner /></div>
|
||||
) : isError ? (
|
||||
<p className="text-muted text-sm">Не удалось загрузить историю</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-muted text-sm">Пока нет пополнений</p>
|
||||
) : (
|
||||
rows.map((p) => {
|
||||
const returned = RETURNED.has(p.status) || !!p.reversed_at;
|
||||
return (
|
||||
<div key={p.order_id} className="flex justify-between items-center py-3 border-b border-line">
|
||||
<div>
|
||||
<p className="text-sm">{p.bucket}</p>
|
||||
<p className="text-muted text-xs">
|
||||
{p.created_at ? new Date(p.created_at).toLocaleDateString("ru-RU") : ""} ·{" "}
|
||||
{STATUS_RU[p.status] ?? p.status}
|
||||
</p>
|
||||
</div>
|
||||
<b className={returned ? "text-muted line-through" : undefined}>
|
||||
{formatMoney(p.amount)}
|
||||
</b>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { apiErrorText, authRequest, authVerify, tgSession } from "@/api/driver";
|
||||
import { apiErrorText, authRequest, authVerify, retryAfterSeconds, tgSession } from "@/api/driver";
|
||||
import { digitsOnly, formatPhone } from "@/lib/format";
|
||||
import { savePairing } from "@/lib/pairing";
|
||||
import { takeReturnTo } from "@/lib/returnTo";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
|
||||
// Кулдаун повторной отправки кода — синхронно с бэкендом (60с). Точное значение
|
||||
// приходит в Retry-After на 429; это дефолт после успешной отправки.
|
||||
const RESEND_COOLDOWN_SEC = 60;
|
||||
|
||||
function fmtMMSS(sec: number): string {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const nav = useNavigate();
|
||||
const setSession = useAuth((s) => s.setSession);
|
||||
@@ -16,6 +27,8 @@ export function LoginPage() {
|
||||
const [channel, setChannel] = useState<string>("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [mode, setMode] = useState<"choose" | "code">("choose");
|
||||
// Секунды до разрешённой повторной отправки кода (0 — можно отправлять).
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
// Prefetched pairing session so the «Войти через Telegram» control is a real
|
||||
// <a href> the OS opens in Telegram (a synchronous user gesture) WITHOUT
|
||||
// navigating the PWA away — the app stays mounted on /tg-wait and polls.
|
||||
@@ -29,6 +42,13 @@ export function LoginPage() {
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
// Тик обратного отсчёта повторной отправки.
|
||||
useEffect(() => {
|
||||
if (cooldown <= 0) return;
|
||||
const t = setTimeout(() => setCooldown((s) => Math.max(0, s - 1)), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [cooldown]);
|
||||
|
||||
function startTg() {
|
||||
if (!tg) return;
|
||||
savePairing(tg);
|
||||
@@ -36,7 +56,7 @@ export function LoginPage() {
|
||||
}
|
||||
|
||||
async function requestCode() {
|
||||
if (phone.length < 10) return;
|
||||
if (phone.length < 10 || cooldown > 0) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await authRequest(e164);
|
||||
@@ -51,13 +71,23 @@ export function LoginPage() {
|
||||
}
|
||||
setChannel(r.channel);
|
||||
setStep("code");
|
||||
// Запускаем отсчёт до повторной отправки — чтобы водитель не жал повторно
|
||||
// раньше времени и не упирался в «слишком часто».
|
||||
setCooldown(RESEND_COOLDOWN_SEC);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 429) {
|
||||
// Точный остаток — из Retry-After; ведём по нему отсчёт и не пугаем.
|
||||
const wait = retryAfterSeconds(err) ?? RESEND_COOLDOWN_SEC;
|
||||
setCooldown(wait);
|
||||
if (step === "phone") setStep("code"); // код мог уйти ранее — дать ввести
|
||||
toast.info(await apiErrorText(err, `Код уже отправляли. Повторить можно через ${wait} сек.`));
|
||||
} else {
|
||||
const fallback =
|
||||
status === 404 ? "Номер не найден. Обратитесь в парк." :
|
||||
status === 429 ? "Слишком часто. Попробуйте позже." :
|
||||
"Не удалось отправить код";
|
||||
toast.error(await apiErrorText(err, fallback));
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -69,7 +99,8 @@ export function LoginPage() {
|
||||
try {
|
||||
const r = await authVerify(e164, code);
|
||||
setSession(r.token, r.driver_id);
|
||||
nav("/");
|
||||
// Возврат туда, откуда пришёл (напр. ссылка на оплату), иначе на баланс.
|
||||
nav(takeReturnTo(), { replace: true });
|
||||
} catch (err: unknown) {
|
||||
toast.error(await apiErrorText(err, "Неверный или просроченный код"));
|
||||
} finally {
|
||||
@@ -116,10 +147,10 @@ export function LoginPage() {
|
||||
placeholder="Номер телефона"
|
||||
value={phone ? formatPhone(phone) : ""}
|
||||
onChange={(e) => setPhone(digitsOnly(e.target.value).replace(/^7/, "").slice(0, 10))}
|
||||
className="card w-full px-4 py-3 mb-3 text-base bg-white"
|
||||
className="card w-full px-4 py-3 mb-3 text-base bg-surface"
|
||||
/>
|
||||
<button className="btn-primary" disabled={busy || phone.length < 10} onClick={requestCode}>
|
||||
{busy ? <Spinner /> : "Получить код"}
|
||||
<button className="btn-primary" disabled={busy || phone.length < 10 || cooldown > 0} onClick={requestCode}>
|
||||
{busy ? <Spinner /> : cooldown > 0 ? `Повторить через ${fmtMMSS(cooldown)}` : "Получить код"}
|
||||
</button>
|
||||
<p className="text-muted text-xs mt-3">Код придёт в Telegram или MAX.</p>
|
||||
</>
|
||||
@@ -131,12 +162,22 @@ export function LoginPage() {
|
||||
placeholder="Код из сообщения"
|
||||
value={code}
|
||||
onChange={(e) => setCode(digitsOnly(e.target.value).slice(0, 6))}
|
||||
className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-white"
|
||||
className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-surface"
|
||||
/>
|
||||
<button className="btn-primary" disabled={busy || code.length < 4} onClick={verify}>
|
||||
{busy ? <Spinner /> : "Войти"}
|
||||
</button>
|
||||
<button className="btn-ghost mt-2" onClick={() => setStep("phone")}>Изменить номер</button>
|
||||
{/* Повторная отправка прямо здесь: не пришёл код — не надо
|
||||
возвращаться к номеру и упираться в «слишком часто». Кнопка
|
||||
ждёт кулдаун и показывает, сколько осталось. */}
|
||||
<button
|
||||
className="btn-ghost mt-2"
|
||||
disabled={busy || cooldown > 0}
|
||||
onClick={requestCode}
|
||||
>
|
||||
{cooldown > 0 ? `Отправить ещё раз через ${fmtMMSS(cooldown)}` : "Отправить код ещё раз"}
|
||||
</button>
|
||||
<button className="btn-ghost mt-1" onClick={() => setStep("phone")}>Изменить номер</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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(<MemoryRouter><NotificationsPage /></MemoryRouter>);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
fine: "Штраф ГИБДД",
|
||||
topup: "Пополнение баланса",
|
||||
};
|
||||
|
||||
/**
|
||||
* Лента уведомлений водителя.
|
||||
*
|
||||
* Нужна не только водителю. Пуш живёт до первого смахивания, сообщение в боте
|
||||
* тонет в переписке — и разговор «мне ничего не приходило» упирался в слово
|
||||
* против слова. Здесь у водителя есть место, куда посмотреть, а у парка —
|
||||
* честная отметка «прочитано»: она ставится ровно тогда, когда человек открыл
|
||||
* этот экран или нажал на само уведомление, и ниоткуда больше.
|
||||
*
|
||||
* Отмечаем прочитанным ПОСЛЕ показа, а не при загрузке: иначе счётчик гаснет у
|
||||
* того, кто просто мазнул по экрану и ушёл.
|
||||
*/
|
||||
export function NotificationsPage() {
|
||||
const nav = useNavigate();
|
||||
const [items, setItems] = useState<DriverNotification[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="p-4">
|
||||
<AppHeader />
|
||||
<h1 className="text-lg font-bold mb-3">Уведомления</h1>
|
||||
|
||||
{error && <div className="text-sm text-muted">{error}</div>}
|
||||
{!items && !error && <Spinner />}
|
||||
|
||||
{items && items.length === 0 && (
|
||||
<div className="text-sm text-muted">
|
||||
Пока ничего не приходило. Здесь будут появляться сообщения о штрафах и платежах.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{items?.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
className="flex gap-3 items-start text-left bg-surface border border-line rounded-xl p-3"
|
||||
onClick={() => n.url && nav(n.url)}
|
||||
>
|
||||
<OperationIcon title={ICON_BY_KIND[n.kind] ?? ""} />
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className="font-semibold text-sm">{n.title}</span>
|
||||
{!n.read_at && <span className="w-2 h-2 rounded-full bg-accent shrink-0" />}
|
||||
</span>
|
||||
<span className="block text-sm text-muted mt-0.5">{n.body}</span>
|
||||
<span className="block text-xs text-muted mt-1">{when(n.created_at)}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Дата человеку — ДД.ММ.ГГГГ и время, без 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 (
|
||||
<button type="button" className="relative p-1 -m-1" aria-label="Уведомления"
|
||||
onClick={() => nav("/notifications")}>
|
||||
<Bell size={22} strokeWidth={1.5} />
|
||||
{unread > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-4 h-4 px-1 rounded-full bg-accent
|
||||
text-white text-[10px] leading-4 text-center">
|
||||
{unread > 9 ? "9+" : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Clock, Undo2, WifiOff, X } from "lucide-react";
|
||||
import { formatMoney } from "@/lib/format";
|
||||
import { markPaymentActivity } from "@/lib/paymentWatch";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { usePaymentStatus } from "@/hooks/usePaymentStatus";
|
||||
|
||||
@@ -31,7 +32,12 @@ export function PaymentPage() {
|
||||
const card = st.method ? st.method === "card" : !!st.payUrl && !st.payUrl.includes("qr.nspk.ru");
|
||||
|
||||
useEffect(() => {
|
||||
if (paid) qc.invalidateQueries({ queryKey: ["balance"] });
|
||||
if (paid) {
|
||||
// Отметка продлевает окно опроса баланса от ФАКТА оплаты: путь до нового
|
||||
// итога (запись в 1С + снимок) начинается только теперь.
|
||||
markPaymentActivity();
|
||||
qc.invalidateQueries({ queryKey: ["balance"] });
|
||||
}
|
||||
}, [paid, qc]);
|
||||
|
||||
const retry = (
|
||||
@@ -103,7 +109,7 @@ export function PaymentPage() {
|
||||
|
||||
return (
|
||||
<div className="pt-10 flex flex-col items-center text-center">
|
||||
<span className="px-4 py-2.5 rounded-xl2 bg-ink text-cream text-sm font-extrabold mb-4">{card ? "Картой" : "СБП"}</span>
|
||||
<span className="px-4 py-2.5 rounded-xl2 bg-ink text-white text-sm font-extrabold mb-4">{card ? "Картой" : "СБП"}</span>
|
||||
<div className="mb-6">
|
||||
{/* после перезагрузки суммы в state нет: лучше не показать ничего, чем показать 0 ₽ */}
|
||||
{st.total != null && <p className="text-lg font-extrabold">{formatMoney(st.total)}</p>}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { usePairPoll } from "@/hooks/usePairPoll";
|
||||
import { clearPairing, loadPairing } from "@/lib/pairing";
|
||||
import { takeReturnTo } from "@/lib/returnTo";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
|
||||
export function TgWaitPage() {
|
||||
@@ -16,7 +17,8 @@ export function TgWaitPage() {
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") {
|
||||
clearPairing();
|
||||
nav("/", { replace: true });
|
||||
// Возврат туда, откуда пришёл (напр. ссылка на оплату), иначе на баланс.
|
||||
nav(takeReturnTo(), { replace: true });
|
||||
}
|
||||
if (status === "expired") {
|
||||
clearPairing();
|
||||
|
||||
@@ -5,7 +5,8 @@ import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("@/api/driver", () => ({
|
||||
topup: vi.fn(async () => ({ order_id: "o9", pay_url: "https://qr.nspk.ru/x", commission: 52, total: 1052 })),
|
||||
topup: vi.fn(async () => ({ order_id: "o9", pay_url: "https://qr.nspk.ru/x", commission: 52,
|
||||
total: 1052, mock: false })),
|
||||
getCommission: vi.fn(async () => ({ rate: 0.052 })),
|
||||
getBuckets: vi.fn(async () => ({
|
||||
buckets: ["Долг аренда", "Долг по штрафам"], with_debt: ["Долг аренда"],
|
||||
@@ -34,7 +35,7 @@ describe("TopupPage", () => {
|
||||
expect(screen.getByTestId("amount").textContent).toBe("1 000 ₽");
|
||||
expect(screen.getByTestId("total").textContent).toContain("1 052");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Оплатить/i }));
|
||||
expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 1000, "qr");
|
||||
expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 1000, "qr", false);
|
||||
});
|
||||
|
||||
it("decimal key builds kopecks and they reach the backend", async () => {
|
||||
@@ -46,7 +47,7 @@ describe("TopupPage", () => {
|
||||
}
|
||||
expect(screen.getByTestId("amount").textContent).toBe("100,52 ₽");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Оплатить/i }));
|
||||
expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 100.52, "qr");
|
||||
expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 100.52, "qr", false);
|
||||
});
|
||||
|
||||
it("lets the driver choose the debt category instead of hardcoding rent", async () => {
|
||||
@@ -57,6 +58,32 @@ describe("TopupPage", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "0" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "0" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Оплатить/i }));
|
||||
expect(driver.topup).toHaveBeenCalledWith("Долг по штрафам", 500, "qr");
|
||||
expect(driver.topup).toHaveBeenCalledWith("Долг по штрафам", 500, "qr", false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("боевая оплата под просмотром", () => {
|
||||
it("по умолчанию платёж учебный — флаг в запрос уходит выключенным", async () => {
|
||||
const { useStaff } = await import("@/store/staff");
|
||||
useStaff.getState().clear();
|
||||
wrap();
|
||||
await userEvent.click(screen.getByRole("button", { name: "5" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "0" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "0" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Оплатить/i }));
|
||||
expect(driver.topup).toHaveBeenLastCalledWith("Долг аренда", 500, "qr", false);
|
||||
});
|
||||
|
||||
it("включённая боевая оплата предупреждает и уходит с флагом", async () => {
|
||||
const { useStaff } = await import("@/store/staff");
|
||||
useStaff.getState().setLivePay(true);
|
||||
wrap();
|
||||
expect(screen.getByText(/деньги спишутся по-настоящему/i)).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "5" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "0" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "0" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Оплатить/i }));
|
||||
expect(driver.topup).toHaveBeenLastCalledWith("Долг аренда", 500, "qr", true);
|
||||
useStaff.getState().clear();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import { toast } from "sonner";
|
||||
import { apiErrorText, topup, getCommission, getBuckets } from "@/api/driver";
|
||||
import { keypadReduce, parseAmount, withCommission } from "@/lib/amount";
|
||||
import { formatMoney } from "@/lib/format";
|
||||
import { markPaymentActivity } from "@/lib/paymentWatch";
|
||||
import { useStaff } from "@/store/staff";
|
||||
import { Segment } from "@/components/Segment";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
|
||||
@@ -14,6 +16,7 @@ const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", ",", "0", "back"];
|
||||
|
||||
export function TopupPage() {
|
||||
const nav = useNavigate();
|
||||
const livePay = useStaff((s) => s.livePay);
|
||||
const loc = useLocation();
|
||||
const fromBalance = (loc.state as { bucket?: string } | null)?.bucket;
|
||||
// Список категорий приходит с сервера — там же он и проверяется. Хардкод
|
||||
@@ -40,13 +43,16 @@ export function TopupPage() {
|
||||
if (base <= 0 || !bucket || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await topup(bucket, base, method as "qr" | "card");
|
||||
const r = await topup(bucket, base, method as "qr" | "card", livePay);
|
||||
// Учебный платёж (сотрудник смотрит от чужого лица): банка нет, платить
|
||||
// негде — сразу на экран выбора исхода, дальше всё как у водителя.
|
||||
if (r.mock) {
|
||||
nav(`/mock-pay/${r.order_id}`, { state: { total: r.total, bucket, base, method } });
|
||||
return;
|
||||
}
|
||||
// Отметка ДО ухода на оплату: вернись водитель на баланс жестом «назад»
|
||||
// раньше вебхука банка, экран обязан опрашивать себя и подхватить платёж.
|
||||
markPaymentActivity();
|
||||
nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base, method } });
|
||||
} catch (err: unknown) {
|
||||
// Текст с сервера, а не глухое «не удалось»: минимум СБП, потолок суммы и
|
||||
@@ -61,7 +67,13 @@ export function TopupPage() {
|
||||
<div className="pt-2">
|
||||
<button className="text-muted text-xs mb-3" onClick={() => nav(-1)}>‹ Назад</button>
|
||||
|
||||
{options.length > 1 && (
|
||||
{/* Категорию выбирают в шторке на главном экране. Пришли оттуда — показываем
|
||||
выбранное строкой, а не рядом чипов: второй выбор того же самого сбивает
|
||||
с толку. Чипы остаются для входа сюда напрямую (кнопка «Пополнить» без
|
||||
категории, возврат «пополнить ещё раз» с экрана оплаты). */}
|
||||
{fromBalance ? (
|
||||
<p className="text-muted text-xs mb-3">Пополняем: <b className="text-ink">{bucket}</b></p>
|
||||
) : options.length > 1 ? (
|
||||
<div className="flex gap-1.5 mb-3 overflow-x-auto">
|
||||
{options.map((b) => (
|
||||
<button
|
||||
@@ -75,7 +87,7 @@ export function TopupPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<p data-testid="amount" className="text-3xl font-extrabold text-center mt-2">{formatMoney(base)}</p>
|
||||
<p data-testid="total" className="text-muted text-xs text-center mb-3">
|
||||
@@ -107,6 +119,13 @@ export function TopupPage() {
|
||||
<Segment value={method} onChange={setMethod} options={[{ value: "qr", label: "СБП" }, { value: "card", label: "Картой" }]} />
|
||||
</div>
|
||||
|
||||
{livePay && (
|
||||
// Предупреждение прямо над кнопкой: под чужим лицом ошибиться легко, а
|
||||
// отыграть боевой платёж — это возврат в банке и правка в 1С.
|
||||
<p className="text-neg text-xs mb-2 text-center">
|
||||
Боевая оплата: деньги спишутся по-настоящему и зачислятся водителю
|
||||
</p>
|
||||
)}
|
||||
<button className="btn-primary" disabled={busy || base <= 0 || !bucket} onClick={pay}>
|
||||
{busy ? <Spinner /> : "Оплатить"}
|
||||
</button>
|
||||
|
||||
@@ -10,20 +10,33 @@ export interface ViewAs {
|
||||
driverName: string | null;
|
||||
}
|
||||
|
||||
/** Собственный сеанс, отложенный на время просмотра чужого. */
|
||||
interface OwnSession {
|
||||
token: string;
|
||||
driverId: number | null;
|
||||
}
|
||||
|
||||
interface AuthState extends ViewAs {
|
||||
token: string | null;
|
||||
driverId: number | null;
|
||||
/** Сеанс, к которому вернёт «выйти из просмотра». null — возвращаться некуда. */
|
||||
own: OwnSession | null;
|
||||
setSession: (token: string, driverId: number, view?: Partial<ViewAs>) => void;
|
||||
/** Начать просмотр от лица водителя, отложив собственный вход. */
|
||||
startImpersonation: (token: string, driverId: number, driverName: string) => void;
|
||||
/** Вернуться к себе. true — вернулись, false — своего входа не было. */
|
||||
stopImpersonation: () => boolean;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useAuth = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
token: null,
|
||||
driverId: null,
|
||||
impersonated: false,
|
||||
driverName: null,
|
||||
own: null,
|
||||
setSession: (token, driverId, view) =>
|
||||
set({
|
||||
token,
|
||||
@@ -31,7 +44,35 @@ export const useAuth = create<AuthState>()(
|
||||
impersonated: view?.impersonated ?? false,
|
||||
driverName: view?.driverName ?? null,
|
||||
}),
|
||||
clear: () => set({ token: null, driverId: null, impersonated: false, driverName: null }),
|
||||
startImpersonation: (token, driverId, driverName) => {
|
||||
const s = get();
|
||||
// Свой вход откладываем ОДИН раз — при переходе из обычного сеанса. Смена
|
||||
// водителя внутри просмотра не должна затирать отложенное собственным
|
||||
// чужим токеном, иначе «вернуться к себе» вернёт к предыдущему водителю.
|
||||
const own = s.impersonated
|
||||
? s.own
|
||||
: s.token
|
||||
? { token: s.token, driverId: s.driverId }
|
||||
: null;
|
||||
set({ token, driverId, impersonated: true, driverName, own });
|
||||
},
|
||||
stopImpersonation: () => {
|
||||
const own = get().own;
|
||||
if (!own) {
|
||||
set({ token: null, driverId: null, impersonated: false, driverName: null });
|
||||
return false;
|
||||
}
|
||||
set({
|
||||
token: own.token,
|
||||
driverId: own.driverId,
|
||||
impersonated: false,
|
||||
driverName: null,
|
||||
own: null,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
clear: () =>
|
||||
set({ token: null, driverId: null, impersonated: false, driverName: null, own: null }),
|
||||
}),
|
||||
{ name: "pp-driver-auth" }
|
||||
)
|
||||
|
||||
@@ -12,7 +12,11 @@ import { persist } from "zustand/middleware";
|
||||
interface StaffState {
|
||||
token: string | null;
|
||||
username: string | null;
|
||||
/** Боевая оплата под просмотром: платёж уходит в банк и зачисляется водителю.
|
||||
* Выключено по умолчанию — под чужим лицом легко забыть, кого пополняешь. */
|
||||
livePay: boolean;
|
||||
setStaff: (token: string, username: string) => void;
|
||||
setLivePay: (on: boolean) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
@@ -21,8 +25,11 @@ export const useStaff = create<StaffState>()(
|
||||
(set) => ({
|
||||
token: null,
|
||||
username: null,
|
||||
livePay: false,
|
||||
setStaff: (token, username) => set({ token, username }),
|
||||
clear: () => set({ token: null, username: null }),
|
||||
setLivePay: (livePay) => set({ livePay }),
|
||||
// Выход сотрудника гасит и боевой режим: следующий раз он включается осознанно.
|
||||
clear: () => set({ token: null, username: null, livePay: false }),
|
||||
}),
|
||||
{ name: "pp-driver-staff" }
|
||||
)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/// <reference lib="webworker" />
|
||||
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
|
||||
import { clientsClaim } from "workbox-core";
|
||||
|
||||
/**
|
||||
* Сервис-воркер приложения.
|
||||
*
|
||||
* Свой, а не сгенерированный: `generateSW` умеет только кэш, а нам нужны
|
||||
* обработчики `push` и `notificationclick` — уведомления показывает именно
|
||||
* воркер, страница для этого может быть закрыта.
|
||||
*
|
||||
* Кэш остаётся прежним (precache от workbox), поведение обновления тоже:
|
||||
* `skipWaiting` + `clientsClaim`, как было при registerType: "autoUpdate".
|
||||
*/
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
cleanupOutdatedCaches();
|
||||
self.skipWaiting();
|
||||
clientsClaim();
|
||||
|
||||
interface PushPayload {
|
||||
title?: string;
|
||||
body?: string;
|
||||
url?: string;
|
||||
tag?: string;
|
||||
/** Номер уведомления в журнале — по нему отмечается прочтение. */
|
||||
id?: number;
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event: PushEvent) => {
|
||||
let data: PushPayload = {};
|
||||
try {
|
||||
data = event.data ? (event.data.json() as PushPayload) : {};
|
||||
} catch {
|
||||
// Пуш без разбираемой нагрузки: показать что-то всё равно надо — молчание
|
||||
// выглядит как «уведомления не работают».
|
||||
data = {};
|
||||
}
|
||||
const title = data.title || "Премиум Водитель";
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body: data.body || "",
|
||||
icon: "/icons/icon-192.png",
|
||||
badge: "/icons/icon-192.png",
|
||||
// tag схлопывает однотипные: пять штрафов подряд не завалят шторку.
|
||||
tag: data.tag,
|
||||
data: { url: data.url || "/", id: data.id },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event: NotificationEvent) => {
|
||||
event.notification.close();
|
||||
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 });
|
||||
// Уже открытое окно приложения переиспользуем: второй экземпляр рядом с
|
||||
// первым сбивает с толку, и водитель теряет то, что было на экране.
|
||||
for (const client of all) {
|
||||
if ("focus" in client) {
|
||||
await client.focus();
|
||||
if ("navigate" in client) await client.navigate(target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await self.clients.openWindow(target);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@@ -3,12 +3,35 @@ export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
/**
|
||||
* Палитра сайта pptaxi.ru — снята с ЖИВОГО сайта (переменные --s-* в его
|
||||
* инлайн-CSS), а не из локального чекаута: тот отстал на редизайн и
|
||||
* предлагал тёмно-синюю тему, которой на сайте давно нет.
|
||||
*
|
||||
* Имена — роли, а не краски: «canvas» останется фоном страницы, даже если
|
||||
* однажды перестанет быть светлым.
|
||||
*/
|
||||
colors: {
|
||||
cream: "#F3EEE3", surface: "#FBF8F1", line: "#E4DCCB",
|
||||
ink: "#1C1B19", muted: "#7C7361", neg: "#C0493A", pos: "#3B6E4A",
|
||||
canvas: "#EEF1F5", // --s-bg — фон страницы
|
||||
"canvas-soft": "#F5F7FA", // --s-bg-soft — подложка под блоками
|
||||
surface: "#FFFFFF", // --s-paper — карточки, поля ввода
|
||||
line: "#DDE3EC", // --s-line — границы и разделители
|
||||
"line-soft": "#E5EAF1",
|
||||
ink: "#0F172A", // --s-ink — основной текст
|
||||
"ink-soft": "#475569", // --s-ink-soft — вторичный текст
|
||||
muted: "#94A3B8", // --s-ink-mute — подписи
|
||||
accent: "#DC2626", // --s-accent — фирменный красный, кнопка действия
|
||||
"accent-press": "#B91C1C",
|
||||
"accent-soft": "#FEF2F2",
|
||||
warn: "#D97706", // --s-warn
|
||||
// Деньги: приход зелёным (--s-ok), долг — фирменным красным. Свой оттенок
|
||||
// тут заводить не стали: у долга и главного действия один цвет намеренно,
|
||||
// это то, ради чего экран открывают.
|
||||
pos: "#059669",
|
||||
neg: "#DC2626",
|
||||
},
|
||||
fontFamily: { sans: ["Inter", "system-ui", "sans-serif"] },
|
||||
borderRadius: { xl2: "14px" },
|
||||
borderRadius: { xl2: "12px" }, // --s-radius-md
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021", "useDefineForClassFields": true, "lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"target": "ES2021", "useDefineForClassFields": true, "lib": ["ES2021", "DOM", "DOM.Iterable", "WebWorker"],
|
||||
"module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true,
|
||||
"noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true,
|
||||
"noUnusedParameters": true, "noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".", "paths": { "@/*": ["src/*"] }, "types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
"baseUrl": ".", "paths": { "@/*": ["src/*"] }, "types": ["vite-plugin-pwa/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -54,7 +54,17 @@ export default defineConfig(({ command, mode }) => {
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({ registerType: "autoUpdate", manifest: false, includeAssets: ["icons/*.png"] }),
|
||||
// injectManifest, а не generateSW: воркер свой, потому что уведомления
|
||||
// показывает он (см. src/sw.ts), а сгенерированный умеет только кэш.
|
||||
VitePWA({
|
||||
strategies: "injectManifest",
|
||||
srcDir: "src",
|
||||
filename: "sw.ts",
|
||||
registerType: "autoUpdate",
|
||||
manifest: false,
|
||||
includeAssets: ["icons/*.png"],
|
||||
injectManifest: { globPatterns: ["**/*.{js,css,html,png,svg,webmanifest}"] },
|
||||
}),
|
||||
],
|
||||
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user