Compare commits
32
Commits
@@ -1 +1,26 @@
|
||||
VITE_API_BASE=https://crm.pptaxi.ru/api
|
||||
# Премиум Водитель (PWA) — переменные сборки.
|
||||
# Как поднять фронт локально — README.md рядом с этим файлом.
|
||||
|
||||
# --- VITE_API_BASE: база API для ky (src/api/client.ts) ----------------------
|
||||
#
|
||||
# Боевой сборке задавать её НЕ обязательно: если переменной нет, client.ts
|
||||
# подставляет https://crm.pptaxi.ru/api. Поэтому строка ниже закомментирована
|
||||
# намеренно — скопированный в .env боевой адрес сборке ничего не добавляет,
|
||||
# а локальную разработку ломает: значение из файла приоритетнее дев-дефолта,
|
||||
# и `npm run dev` начнёт бить в боевой терминал вместо локального бэкенда.
|
||||
#
|
||||
#VITE_API_BASE=https://crm.pptaxi.ru/api
|
||||
|
||||
# --- Локальная разработка ----------------------------------------------------
|
||||
#
|
||||
# Копировать этот файл в .env не нужно. `npm run dev` сам ставит
|
||||
# VITE_API_BASE=/api и проксирует /api на локальный бэкенд (vite.config.ts),
|
||||
# то есть запрос идёт с того же origin, что и страница, и CORS не участвует —
|
||||
# CORS_ALLOW_LOCALHOST в backend/.env для этого фронта не нужен.
|
||||
#
|
||||
# Свой адрес бэкенда, поход в API мимо прокси, PowerShell-вариант команд —
|
||||
# в README.md. Рецепты держим в одном месте: разъедутся — поверят не тому.
|
||||
#
|
||||
# ВНИМАНИЕ: crm.pptaxi.ru — боевой терминал Т-Банка и боевая касса АТОЛ.
|
||||
# Пополнение, сделанное «просто посмотреть», — это реальные деньги водителя,
|
||||
# реальный чек 54-ФЗ и реальная проводка в леджере.
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Премиум Водитель — PWA водителя
|
||||
|
||||
React + Vite + TypeScript. Прод: **https://app.pptaxi.ru**.
|
||||
Бэкенд — модуль `payments` в Premium CRM (`https://crm.pptaxi.ru/api`,
|
||||
репозиторий `crm2`, `backend/app/modules/payments/`).
|
||||
|
||||
## Как поднять локально
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5174
|
||||
```
|
||||
|
||||
Отдельно поднимается бэкенд `crm2` на **127.0.0.1:8000** (тот же порт, что в
|
||||
`backend/Dockerfile`). Больше настраивать нечего: dev-сервер сам подставляет
|
||||
`VITE_API_BASE=/api` и проксирует `/api` на бэкенд — см. `vite.config.ts`.
|
||||
|
||||
**Почему через прокси, а не «разрешить localhost в CORS бэкенда».** Прокси
|
||||
делает запрос ОДНОГО происхождения со страницей (`http://localhost:5174/api/…`),
|
||||
поэтому CORS в разработке не участвует вообще. Боевой список origin'ов на
|
||||
бэкенде остаётся закрытым, а `CORS_ALLOW_LOCALHOST=true` в `backend/.env` для
|
||||
этого фронта включать не надо. (Раньше рецепт с `CORS_ALLOW_LOCALHOST` тут и не
|
||||
работал: прокси не было, и фронт по умолчанию уходил на боевой домен, а не на
|
||||
локальный бэкенд.)
|
||||
|
||||
Сервис-воркер в режиме разработки не регистрируется (`devOptions` у
|
||||
`vite-plugin-pwa` по умолчанию выключены) — старый кэш прод-версии в дев не
|
||||
лезет.
|
||||
|
||||
### Бэкенд не на `127.0.0.1:8000`
|
||||
|
||||
```bash
|
||||
VITE_API_TARGET=http://127.0.0.1:8090 npm run dev
|
||||
```
|
||||
|
||||
В PowerShell инлайн-префикса `VAR=... команда` нет — переменную ставят отдельно:
|
||||
|
||||
```powershell
|
||||
$env:VITE_API_TARGET="http://127.0.0.1:8090"; npm run dev
|
||||
```
|
||||
|
||||
Имя переменной то же, что в CRM (`crm2/frontend/vite.config.ts`), — рецепт один
|
||||
на оба фронта.
|
||||
|
||||
### Ходить в API напрямую, мимо прокси
|
||||
|
||||
`.env.local` (в git не попадает):
|
||||
|
||||
```
|
||||
VITE_API_BASE=http://127.0.0.1:8000/api
|
||||
```
|
||||
|
||||
Своя настройка приоритетнее дев-дефолта — специально, чтобы `npm run dev` её не
|
||||
перебивал. Для похода в чужой origin бэкенду понадобится
|
||||
`CORS_ALLOW_LOCALHOST=true` в `backend/.env` и рестарт.
|
||||
|
||||
> ⚠️ `crm.pptaxi.ru` — боевой терминал Т-Банка и боевая касса АТОЛ. Пополнение,
|
||||
> сделанное «просто посмотреть», — это реальные деньги водителя, реальный чек
|
||||
> 54-ФЗ и реальная проводка в леджере. Поэтому прокси по умолчанию смотрит в
|
||||
> локальный бэкенд, а не в прод.
|
||||
|
||||
## Проверки
|
||||
|
||||
```bash
|
||||
npx tsc -b # типы
|
||||
npm test # vitest run
|
||||
```
|
||||
|
||||
`npx tsc --noEmit` **без `-b`** здесь всегда зелёный и ничего не значит:
|
||||
корневой `tsconfig.json` — solution-файл (`files: []` + `references`), под него
|
||||
не попадает ни один файл (`tsc --noEmit --listFiles` печатает пустоту). Типы
|
||||
живут в `tsconfig.app.json` (`src`) и `tsconfig.node.json` (конфиги +
|
||||
`dev-proxy.test.ts`), и до обоих добирается только `tsc -b`.
|
||||
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
npm run build # tsc -b && vite build → dist/
|
||||
```
|
||||
|
||||
Дев-дефолт `VITE_API_BASE=/api` подставляется только при запущенном dev-сервере
|
||||
и в бандл не попадает: в собранном `assets/index-*.js` лежит
|
||||
`https://crm.pptaxi.ru/api` (проверено сборкой). Поэтому `VITE_API_BASE` боевой
|
||||
сборке задавать не обязательно — без неё `src/api/client.ts` подставляет ровно
|
||||
этот адрес.
|
||||
|
||||
> ⚠️ `npm run preview` поднимает **уже собранный** бандл, а база в нём
|
||||
> абсолютная: запросы уходят на боевой `crm.pptaxi.ru` мимо прокси dev-сервера.
|
||||
> Смотреть локальные правки — только `npm run dev`.
|
||||
|
||||
Куда выкладывается `dist/` — в плане проекта:
|
||||
`../../docs/superpowers/plans/2026-06-18-premium-driver-pwa.md`, раздел
|
||||
«Deployment notes»: статика на `app.pptaxi.ru`, Caddy с фолбэком на `index.html`.
|
||||
|
||||
## Кто может войти и как смотреть чужой экран
|
||||
|
||||
> **Запущено 22.08.2026: вход открыт всем водителям.** Список номеров ниже пуст,
|
||||
> то есть гейт выключен. Раздел оставлен как инструкция на случай, если вход
|
||||
> снова понадобится сузить — например под обкатку следующей крупной правки.
|
||||
|
||||
### 1. Вход только по списку номеров (сейчас выключено)
|
||||
|
||||
Переменная бэкенда (`crm2/backend/.env`, читается в `app/config.py`):
|
||||
|
||||
```
|
||||
DRIVER_APP_ALLOWED_PHONES=+7 (914) 123-45-67, 79990001122
|
||||
```
|
||||
|
||||
Разделители — запятая, точка с запятой, перенос строки (пробел разделителем не
|
||||
считается: номер вписывают в привычном виде). Сверяются последние 10 цифр.
|
||||
**Пусто — гейт выключен, вход открыт всем водителям.**
|
||||
|
||||
Проверка стоит в единственной воронке выдачи токена
|
||||
(`modules/payments/driver_auth.create_driver_token_for`), поэтому закрыты сразу
|
||||
все пять путей входа: код в бот, кнопка «Войти через Telegram», колбэки своего
|
||||
бота и два аварийных отката на n8n. Бот отвечает человеку текстом, остальные
|
||||
пути — 403. Логика гейта: `modules/payments/login_gate.py`.
|
||||
|
||||
### 2. Просмотр от лица водителя (сотрудник)
|
||||
|
||||
На экране входа внизу — «Вход для сотрудника» (`/dev`): логин и пароль **учётки
|
||||
CRM**, при включённой двухфакторке спросит код. Дальше поиск по ФИО, телефону
|
||||
или id — и приложение открывается от лица выбранного водителя.
|
||||
|
||||
* Токен помечен claim'ом `imp`, живёт 8 часов (обычный водительский — 30 дней).
|
||||
* Гейт — `is_admin` в CRM. Каждая выдача пишется в аудит (`driver_app.impersonate`).
|
||||
* Кнопка «Отозвать доступ» в CRM гасит и просмотр сотрудника — поколение общее.
|
||||
* Гейт обкатки на этот путь не распространяется: смотреть надо как раз на
|
||||
водителей, которых в allowlist нет.
|
||||
|
||||
Пока просмотр действует, сверху на всех экранах висит плашка «Смотрю как …» со
|
||||
ссылкой на вкладку **«Отладка»** (`/debug`, водителю недоступна):
|
||||
|
||||
* от чьего лица открыт экран и кнопка сменить водителя;
|
||||
* `driver_id`, режим, сотрудник, когда истекает токен, адрес API, коммит и время
|
||||
сборки, состояние сети;
|
||||
* журнал последних 40 ответивших запросов (метод, путь, статус, миллисекунды);
|
||||
* сброс кэша сервис-воркера с перезагрузкой — лечит «правка не приехала»;
|
||||
* выходы: из просмотра, из учётки сотрудника.
|
||||
|
||||
### Деньги под просмотром: учебный платёж
|
||||
|
||||
Пополнение под `imp`-токеном **не идёт в банк**. Проверки суммы, категории и
|
||||
расчёт комиссии настоящие, а вместо Init на терминале заводится учебный заказ в
|
||||
Redis (`modules/payments/mock_pay.py`, префикс `mock-`, час жизни). Экран
|
||||
`/mock-pay/:orderId` даёт выбрать, что «ответит» банк — оплачено, отказ,
|
||||
истёкший счёт, возврат, — и настоящий экран оплаты разбирает эти статусы своим
|
||||
боевым кодом. В `driver_payments` не попадает ни строки, чек 54-ФЗ не пробивается.
|
||||
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* Локальная разработка PWA водителя.
|
||||
*
|
||||
* Гейт CORS на бэкенде (CORS_ALLOW_LOCALHOST, по умолчанию выключен) закрыл
|
||||
* дыру, но заодно оставил этот фронт без рабочего дева: прокси не было, а
|
||||
* client.ts по умолчанию уходит на боевой https://crm.pptaxi.ru/api — то есть
|
||||
* «включи CORS_ALLOW_LOCALHOST» тут не помогало, запрос вообще не доходил до
|
||||
* локального бэкенда.
|
||||
*
|
||||
* Тесты держат обе половины договорённости:
|
||||
* 1) `npm run dev` доводит фронт до локального бэкенда сам, без правки .env;
|
||||
* 2) боевая сборка от этого не меняется — относительная дев-база в бандл
|
||||
* попасть не может (в проде он живёт на app.pptaxi.ru, где /api нет).
|
||||
*
|
||||
* Конфиг резолвится по-настоящему (resolveConfig читает vite.config.ts и
|
||||
* env-файлы) — иначе тест проверял бы не то, что увидит vite.
|
||||
*
|
||||
* Файл лежит рядом с vite.config.ts и НЕ называется vite.config.test.ts:
|
||||
* в дефолтном exclude vitest есть `**\/{...,vite,...}.config.*`, под который
|
||||
* такое имя попадает целиком — тест молча не запускался бы.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { resolveConfig } from "vite";
|
||||
|
||||
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Обе переменные — process.env, и dev-дефолт их же и трогает: сохраняем. */
|
||||
const SAVED = {
|
||||
base: process.env.VITE_API_BASE,
|
||||
target: process.env.VITE_API_TARGET,
|
||||
};
|
||||
|
||||
function restore(key: "VITE_API_BASE" | "VITE_API_TARGET", value: string | undefined): void {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.VITE_API_BASE;
|
||||
delete process.env.VITE_API_TARGET;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restore("VITE_API_BASE", SAVED.base);
|
||||
restore("VITE_API_TARGET", SAVED.target);
|
||||
});
|
||||
|
||||
const resolve = (command: "serve" | "build", mode: "development" | "production") =>
|
||||
resolveConfig({ root: ROOT, configFile: path.join(ROOT, "vite.config.ts"), logLevel: "silent" },
|
||||
command, mode, mode);
|
||||
|
||||
describe("dev-сервер доводит фронт до локального бэкенда", () => {
|
||||
it("подставляет относительную базу — запрос идёт со своего origin, CORS не участвует", async () => {
|
||||
const cfg = await resolve("serve", "development");
|
||||
expect(cfg.env.VITE_API_BASE).toBe("/api");
|
||||
});
|
||||
|
||||
it("проксирует /api на локальный бэкенд, а не на боевой домен", async () => {
|
||||
const cfg = await resolve("serve", "development");
|
||||
const proxy = cfg.server.proxy?.["/api"];
|
||||
expect(typeof proxy === "object" && proxy.target).toBe("http://127.0.0.1:8000");
|
||||
});
|
||||
|
||||
it("цель прокси переопределяется VITE_API_TARGET (стенд, тайлнет)", async () => {
|
||||
process.env.VITE_API_TARGET = "http://127.0.0.1:8090";
|
||||
const cfg = await resolve("serve", "development");
|
||||
const proxy = cfg.server.proxy?.["/api"];
|
||||
expect(typeof proxy === "object" && proxy.target).toBe("http://127.0.0.1:8090");
|
||||
});
|
||||
|
||||
it("база и путь прокси — одно и то же", async () => {
|
||||
// Две половины рецепта живут в разных местах конфига: базу подставляем в
|
||||
// env, путь перечисляем в server.proxy. Разъедутся (например, база станет
|
||||
// "/backend") — dev-сервер отдаст на этот путь index.html вместо ответа
|
||||
// бэкенда, и падать будет уже в разборе JSON, далеко от причины.
|
||||
const cfg = await resolve("serve", "development");
|
||||
expect(Object.keys(cfg.server.proxy ?? {})).toContain(cfg.env.VITE_API_BASE);
|
||||
});
|
||||
|
||||
it("не перебивает базу, заданную разработчиком", async () => {
|
||||
process.env.VITE_API_BASE = "https://stand.example/api";
|
||||
const cfg = await resolve("serve", "development");
|
||||
expect(cfg.env.VITE_API_BASE).toBe("https://stand.example/api");
|
||||
});
|
||||
});
|
||||
|
||||
describe("боевая сборка остаётся боевой", () => {
|
||||
it("дев-дефолт в сборку не течёт: base пустая → client.ts берёт прод-адрес", async () => {
|
||||
const cfg = await resolve("build", "production");
|
||||
expect(cfg.env.VITE_API_BASE).toBeUndefined();
|
||||
expect(process.env.VITE_API_BASE).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ни один env-файл не подсовывает сборке относительную базу", async () => {
|
||||
// Относительный /api в проде означал бы app.pptaxi.ru/api — такого нет.
|
||||
// Ловит и коммит .env с дев-значением, и локальный .env на машине сборки.
|
||||
const cfg = await resolve("build", "production");
|
||||
expect(cfg.env.VITE_API_BASE).not.toBe("/api");
|
||||
});
|
||||
});
|
||||
@@ -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,30 +1,85 @@
|
||||
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";
|
||||
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, либо
|
||||
* прямо сейчас смотрит от лица водителя. Обычному водителю адрес /debug ничего
|
||||
* не даёт — его уводит на главную. */
|
||||
function RequireStaff({ children }: { children: ReactNode }) {
|
||||
const impersonated = useAuth((s) => s.impersonated);
|
||||
const staffToken = useStaff((s) => s.token);
|
||||
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 />} />
|
||||
<Route path="/tg-wait" element={<TgWaitPage />} />
|
||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||
<Route path="/" element={<RequireAuth><BalancePage /></RequireAuth>} />
|
||||
<Route path="/topup" element={<RequireAuth><TopupPage /></RequireAuth>} />
|
||||
<Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></RequireAuth>} />
|
||||
<Route path="/history" element={<RequireAuth><HistoryPage /></RequireAuth>} />
|
||||
<Route path="/mock-pay/:orderId" element={<RequireAuth><MockPayPage /></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>
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,34 @@
|
||||
import ky, { type KyInstance } from "ky";
|
||||
import { pushEntry, shortPath } from "@/lib/reqlog";
|
||||
import { useAuth } from "@/store/auth";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? "https://crm.pptaxi.ru/api";
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE ?? "https://crm.pptaxi.ru/api";
|
||||
|
||||
/** Засечка времени на конкретный запрос. 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,
|
||||
@@ -10,10 +37,25 @@ export const api: KyInstance = ky.create({
|
||||
hooks: {
|
||||
beforeRequest: [
|
||||
(request) => {
|
||||
started.set(request, performance.now());
|
||||
const token = useAuth.getState().token;
|
||||
if (token) request.headers.set("Authorization", `Bearer ${token}`);
|
||||
},
|
||||
],
|
||||
// В журнал попадают ОТВЕТИВШИЕ запросы, включая 4xx/5xx. Запрос, оборвавшийся
|
||||
// без ответа (таймаут, пропавшая сеть), ky сюда не приносит — на экране
|
||||
// отладки об этом сказано прямо, чтобы пустая строка не читалась как «дошло».
|
||||
afterResponse: [
|
||||
(request, _options, response) => {
|
||||
pushEntry({
|
||||
method: request.method,
|
||||
path: shortPath(request.url),
|
||||
status: response.status,
|
||||
ms: Math.round(performance.now() - (started.get(request) ?? performance.now())),
|
||||
});
|
||||
adoptRenewedToken(request, response);
|
||||
},
|
||||
],
|
||||
beforeError: [
|
||||
(error) => {
|
||||
if (error.response?.status === 401) useAuth.getState().clear();
|
||||
|
||||
@@ -12,10 +12,11 @@ describe("driver api schemas", () => {
|
||||
});
|
||||
it("parses auth request onboarding + code_sent", () => {
|
||||
expect(AuthRequestSchema.parse({ status: "code_sent", channel: "tg" }).status).toBe("code_sent");
|
||||
const o = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t", max: "m" } });
|
||||
const pair = { pair_session: "S", poll_secret: "SEC", confirm_code: "4217" };
|
||||
const o = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t", max: "m" }, ...pair });
|
||||
expect(o.status === "onboarding" && o.deep_links.tg).toBe("t");
|
||||
// prod: MAX bot not configured → backend omits `max`, must still parse (regression guard)
|
||||
const tgOnly = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t" } });
|
||||
const tgOnly = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t" }, ...pair });
|
||||
expect(tgOnly.status === "onboarding" && tgOnly.deep_links.tg).toBe("t");
|
||||
});
|
||||
it("parses payments history", () => {
|
||||
@@ -27,8 +28,12 @@ describe("driver api schemas", () => {
|
||||
|
||||
describe("tg verified-login schemas", () => {
|
||||
it("parses tg session", () => {
|
||||
const v = TgSessionSchema.parse({ pair_session: "S", deep_link: "https://t.me/x?start=S" });
|
||||
const v = TgSessionSchema.parse({ pair_session: "S", poll_secret: "SEC", confirm_code: "4217",
|
||||
deep_link: "https://t.me/x?start=S" });
|
||||
expect(v.pair_session).toBe("S");
|
||||
expect(v.poll_secret).toBe("SEC");
|
||||
// вторая половина ключа в deep-link не уходит
|
||||
expect(v.deep_link).not.toContain("SEC");
|
||||
});
|
||||
it("parses tg poll states", () => {
|
||||
expect(TgPollSchema.parse({ status: "pending" }).status).toBe("pending");
|
||||
|
||||
@@ -1,18 +1,53 @@
|
||||
import { HTTPError } from "ky";
|
||||
import { z } from "zod";
|
||||
import { api } from "./client";
|
||||
|
||||
/** Pairing-сессия входа. `poll_secret` не покидает приложение (в deep-link уходит
|
||||
* только `pair_session`), `confirm_code` человек сверяет с сообщением бота. */
|
||||
export const TgSessionSchema = z.object({
|
||||
pair_session: z.string(),
|
||||
poll_secret: z.string(),
|
||||
confirm_code: z.string(),
|
||||
deep_link: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const AuthRequestSchema = z.union([
|
||||
z.object({ status: z.literal("code_sent"), channel: z.string() }),
|
||||
z.object({ status: z.literal("onboarding"), deep_links: z.object({ tg: z.string().optional(), max: z.string().optional() }) }),
|
||||
z.object({
|
||||
status: z.literal("onboarding"),
|
||||
deep_links: z.object({ tg: z.string().optional(), max: z.string().optional() }),
|
||||
pair_session: z.string(),
|
||||
poll_secret: z.string(),
|
||||
confirm_code: z.string(),
|
||||
}),
|
||||
]);
|
||||
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),
|
||||
});
|
||||
export const BucketsSchema = z.object({
|
||||
buckets: z.array(z.string()),
|
||||
with_debt: z.array(z.string()),
|
||||
});
|
||||
export const TopupSchema = z.object({
|
||||
order_id: z.string(), pay_url: z.string(), commission: z.number(), total: z.number(),
|
||||
// Учебный платёж: сотрудник смотрит от лица водителя, банка в этой ветке нет
|
||||
// (см. backend modules/payments/mock_pay.py). У обычного водителя всегда false.
|
||||
mock: z.boolean().default(false),
|
||||
});
|
||||
export const PaymentStateSchema = z.object({ order_id: z.string(), status: z.string() });
|
||||
export const CommissionSchema = z.object({ rate: z.number() });
|
||||
@@ -21,23 +56,58 @@ export const PaymentsSchema = z.object({
|
||||
order_id: z.string(), bucket: z.string(), amount: z.number(), commission: z.number(),
|
||||
method: z.string(), status: z.string(),
|
||||
created_at: z.string().nullable(), paid_at: z.string().nullable(),
|
||||
reversed_at: z.string().nullable().default(null),
|
||||
})),
|
||||
});
|
||||
|
||||
export const TgSessionSchema = z.object({
|
||||
pair_session: z.string(),
|
||||
deep_link: z.string().nullable(),
|
||||
});
|
||||
export const TgPollSchema = z.union([
|
||||
z.object({ status: z.literal("pending") }),
|
||||
z.object({ status: z.literal("expired") }),
|
||||
z.object({ status: z.literal("authenticated"), token: z.string(), driver_id: z.number() }),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Текст ошибки от бэкенда.
|
||||
*
|
||||
* Раньше все отказы схлопывались в «Не удалось создать платёж», и осмысленные
|
||||
* сообщения — про минимум СБП, про потолок суммы, про неизвестную категорию —
|
||||
* до водителя не доходили: экран просто не реагировал.
|
||||
*/
|
||||
export async function apiErrorText(err: unknown, fallback: string): Promise<string> {
|
||||
if (err instanceof HTTPError) {
|
||||
try {
|
||||
const body = await err.response.json();
|
||||
const detail = (body as { detail?: unknown })?.detail;
|
||||
if (typeof detail === "string" && detail.trim()) return detail;
|
||||
} catch {
|
||||
/* тело не JSON — отдадим запасной текст */
|
||||
}
|
||||
}
|
||||
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());
|
||||
export const tgPoll = async (pairSession: string) =>
|
||||
TgPollSchema.parse(await api.get("driver/auth/tg/poll", { searchParams: { pair_session: pairSession } }).json());
|
||||
/** POST, а не GET: в query-параметре pair_session оседал в access-логах. */
|
||||
export const tgPoll = async (pairSession: string, pollSecret: string) =>
|
||||
TgPollSchema.parse(
|
||||
await api.post("driver/auth/tg/poll", {
|
||||
json: { pair_session: pairSession, poll_secret: pollSecret },
|
||||
}).json(),
|
||||
);
|
||||
|
||||
export const authRequest = async (phone: string) =>
|
||||
AuthRequestSchema.parse(await api.post("driver/auth/request", { json: { phone } }).json());
|
||||
@@ -45,11 +115,124 @@ export const authVerify = async (phone: string, code: string) =>
|
||||
VerifySchema.parse(await api.post("driver/auth/verify", { json: { phone, code } }).json());
|
||||
export const getBalance = async () =>
|
||||
BalanceSchema.parse(await api.get("driver/balance").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 getBuckets = async () =>
|
||||
BucketsSchema.parse(await api.get("driver/buckets").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 () =>
|
||||
PaymentsSchema.parse(await api.get("driver/payments").json());
|
||||
export const getCommission = async () =>
|
||||
CommissionSchema.parse(await api.get("driver/commission").json());
|
||||
|
||||
/** Исход учебного платежа. Работает только под просмотром сотрудника. */
|
||||
export const mockPayOutcome = async (
|
||||
orderId: string, outcome: "paid" | "rejected" | "expired" | "reversed",
|
||||
) => 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();
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import ky from "ky";
|
||||
import { z } from "zod";
|
||||
import { pushEntry, shortPath } from "@/lib/reqlog";
|
||||
import { API_BASE } from "./client";
|
||||
import { useStaff } from "@/store/staff";
|
||||
|
||||
/**
|
||||
* Учётка CRM внутри приложения водителя — только ради просмотра от чужого лица.
|
||||
*
|
||||
* Свой экземпляр ky, а не общий `api`: тот подставляет ВОДИТЕЛЬСКИЙ токен, и на
|
||||
* запросе к /driver/staff/* заголовок оказался бы чужим. Плюс 401 здесь означает
|
||||
* «протух сеанс сотрудника», а не «водителя выкинуло».
|
||||
*/
|
||||
const started = new WeakMap<Request, number>();
|
||||
|
||||
const staffApi = ky.create({
|
||||
prefixUrl: API_BASE,
|
||||
timeout: 30_000,
|
||||
hooks: {
|
||||
beforeRequest: [
|
||||
(request) => {
|
||||
started.set(request, performance.now());
|
||||
const token = useStaff.getState().token;
|
||||
if (token) request.headers.set("Authorization", `Bearer ${token}`);
|
||||
},
|
||||
],
|
||||
afterResponse: [
|
||||
(request, _o, response) => {
|
||||
pushEntry({
|
||||
method: request.method, path: shortPath(request.url), status: response.status,
|
||||
ms: Math.round(performance.now() - (started.get(request) ?? performance.now())),
|
||||
});
|
||||
},
|
||||
],
|
||||
beforeError: [
|
||||
(error) => {
|
||||
if (error.response?.status === 401) useStaff.getState().clear();
|
||||
return error;
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const LoginSchema = z.object({
|
||||
access_token: z.string().nullable().default(null),
|
||||
requires_2fa: z.boolean().default(false),
|
||||
partial_token: z.string().nullable().default(null),
|
||||
});
|
||||
|
||||
export const DriverBriefSchema = z.object({
|
||||
id: z.number(),
|
||||
fio: z.string(),
|
||||
phone: z.string(),
|
||||
status: z.string(),
|
||||
driver_kind: z.string(),
|
||||
});
|
||||
export type DriverBrief = z.infer<typeof DriverBriefSchema>;
|
||||
|
||||
export const DriversSchema = z.object({ drivers: z.array(DriverBriefSchema) });
|
||||
export const ImpersonateSchema = z.object({
|
||||
token: z.string(),
|
||||
driver: DriverBriefSchema,
|
||||
});
|
||||
|
||||
/** Логин CRM. Форма, а не JSON: эндпоинт принимает OAuth2PasswordRequestForm. */
|
||||
export const staffLogin = async (username: string, password: string) => {
|
||||
const body = new URLSearchParams({ username, password });
|
||||
return LoginSchema.parse(await staffApi.post("auth/login", { body }).json());
|
||||
};
|
||||
|
||||
/** Второй шаг, если у учётки включена двухфакторка. */
|
||||
export const staffLogin2fa = async (partialToken: string, code: string) =>
|
||||
LoginSchema.parse(await staffApi.post("auth/2fa", {
|
||||
json: { partial_token: partialToken, code, remember_device: false },
|
||||
}).json());
|
||||
|
||||
export const searchDrivers = async (q: string) =>
|
||||
DriversSchema.parse(await staffApi.get("driver/staff/drivers", {
|
||||
searchParams: { q, limit: 30 },
|
||||
}).json());
|
||||
|
||||
export const impersonate = async (driverId: number) =>
|
||||
ImpersonateSchema.parse(await staffApi.post("driver/staff/impersonate", {
|
||||
json: { driver_id: driverId },
|
||||
}).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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { apiErrorText } from "@/api/driver";
|
||||
import { impersonate, searchDrivers, type DriverBrief } from "@/api/staff";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
|
||||
/**
|
||||
* Выбор водителя, от лица которого смотреть. Один компонент на два места:
|
||||
* первый вход сотрудника (/dev) и смена водителя на вкладке «Отладка».
|
||||
*/
|
||||
export function DriverPicker({ onPicked }: { onPicked: (d: DriverBrief) => void }) {
|
||||
const startImpersonation = useAuth((s) => s.startImpersonation);
|
||||
const [q, setQ] = useState("");
|
||||
const [list, setList] = useState<DriverBrief[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
// Пауза перед запросом: иначе поиск уходит на каждую букву, и ответы
|
||||
// возвращаются вперемешку — список прыгает.
|
||||
const t = setTimeout(() => {
|
||||
searchDrivers(q)
|
||||
.then((r) => { if (alive) setList(r.drivers); })
|
||||
.catch(async (e) => { if (alive) toast.error(await apiErrorText(e, "Не удалось найти водителей")); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
}, 300);
|
||||
return () => { alive = false; clearTimeout(t); };
|
||||
}, [q]);
|
||||
|
||||
async function pick(d: DriverBrief) {
|
||||
setBusyId(d.id);
|
||||
try {
|
||||
const r = await impersonate(d.id);
|
||||
startImpersonation(r.token, r.driver.id, r.driver.fio);
|
||||
onPicked(r.driver);
|
||||
} catch (e: unknown) {
|
||||
toast.error(await apiErrorText(e, "Не удалось открыть приложение за водителя"));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="ФИО, телефон или id"
|
||||
className="w-full text-base bg-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8"><Spinner /></div>
|
||||
) : list.length === 0 ? (
|
||||
<p className="text-muted text-sm text-center py-8">Никого не нашли</p>
|
||||
) : (
|
||||
<div className="card divide-y divide-line">
|
||||
{list.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
disabled={busyId !== null}
|
||||
onClick={() => pick(d)}
|
||||
className="w-full text-left px-4 py-3 flex items-center justify-between gap-3 disabled:opacity-50"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold truncate">{d.fio}</span>
|
||||
<span className="block text-muted text-xs truncate">
|
||||
#{d.id}{d.phone ? ` · ${d.phone}` : ""}{d.status ? ` · ${d.status}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
{busyId === d.id ? <Spinner /> : <span className="text-muted text-xs shrink-0">смотреть</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Eye } from "lucide-react";
|
||||
import { useAuth } from "@/store/auth";
|
||||
|
||||
/**
|
||||
* Плашка «вы смотрите чужой экран». Видна на каждом экране и всегда, пока
|
||||
* действует токен просмотра.
|
||||
*
|
||||
* Она не украшение: интерфейс в этом режиме неотличим от водительского — те же
|
||||
* суммы, те же долги, та же история. Без постоянного напоминания легко принять
|
||||
* чужой баланс за свой и, например, позвонить водителю с чужими цифрами.
|
||||
*/
|
||||
export function ImpersonationBar() {
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
const { impersonated, driverName, driverId } = useAuth();
|
||||
if (!impersonated) return null;
|
||||
const onDebug = loc.pathname === "/debug";
|
||||
|
||||
return (
|
||||
<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>
|
||||
</span>
|
||||
<button
|
||||
className="ml-auto shrink-0 underline underline-offset-2"
|
||||
onClick={() => nav(onDebug ? "/" : "/debug")}
|
||||
>
|
||||
{onDebug ? "к приложению" : "отладка"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -10,20 +10,34 @@ beforeEach(() => { useAuth.getState().clear(); vi.restoreAllMocks(); });
|
||||
describe("usePairPoll", () => {
|
||||
it("sets session on authenticated", async () => {
|
||||
vi.spyOn(driver, "tgPoll").mockResolvedValue({ status: "authenticated", token: "T", driver_id: 7 } as any);
|
||||
const { result } = renderHook(() => usePairPoll("SESS"));
|
||||
const { result } = renderHook(() => usePairPoll("SESS", "SEC"));
|
||||
await waitFor(() => expect(result.current.status).toBe("authenticated"));
|
||||
expect(useAuth.getState().token).toBe("T");
|
||||
expect(useAuth.getState().driverId).toBe(7);
|
||||
});
|
||||
it("stays pending then expired", async () => {
|
||||
vi.spyOn(driver, "tgPoll").mockResolvedValue({ status: "expired" } as any);
|
||||
const { result } = renderHook(() => usePairPoll("SESS"));
|
||||
const { result } = renderHook(() => usePairPoll("SESS", "SEC"));
|
||||
await waitFor(() => expect(result.current.status).toBe("expired"));
|
||||
});
|
||||
it("does nothing when session is null", () => {
|
||||
const spy = vi.spyOn(driver, "tgPoll");
|
||||
const { result } = renderHook(() => usePairPoll(null));
|
||||
const { result } = renderHook(() => usePairPoll(null, "SEC"));
|
||||
expect(result.current.status).toBe("pending");
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not poll without the secret half of the key", () => {
|
||||
// pair_session уходит боту в deep-link и оседает в логах; одной его половины
|
||||
// для получения токена быть недостаточно.
|
||||
const spy = vi.spyOn(driver, "tgPoll");
|
||||
const { result } = renderHook(() => usePairPoll("SESS", null));
|
||||
expect(result.current.status).toBe("pending");
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
it("sends both halves to the backend", async () => {
|
||||
const spy = vi.spyOn(driver, "tgPoll").mockResolvedValue({ status: "expired" } as any);
|
||||
const { result } = renderHook(() => usePairPoll("SESS", "SEC"));
|
||||
await waitFor(() => expect(result.current.status).toBe("expired"));
|
||||
expect(spy).toHaveBeenCalledWith("SESS", "SEC");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,13 +7,19 @@ type Status = "pending" | "authenticated" | "expired";
|
||||
const INTERVAL_MS = 2000;
|
||||
const TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
export function usePairPoll(pairSession: string | null): { status: Status } {
|
||||
/**
|
||||
* Опрос pairing-сессии. Нужны ОБЕ половины ключа: `pairSession` уходит боту в
|
||||
* deep-link, `pollSecret` остаётся только здесь — знать одну сессию (например,
|
||||
* подсмотрев её в access-логе) для получения токена уже недостаточно.
|
||||
*/
|
||||
export function usePairPoll(pairSession: string | null,
|
||||
pollSecret: string | null): { status: Status } {
|
||||
const [status, setStatus] = useState<Status>("pending");
|
||||
const setSession = useAuth((s) => s.setSession);
|
||||
const startedAt = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pairSession) return;
|
||||
if (!pairSession || !pollSecret) return;
|
||||
let alive = true;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
startedAt.current = Date.now();
|
||||
@@ -21,7 +27,7 @@ export function usePairPoll(pairSession: string | null): { status: Status } {
|
||||
const tick = async () => {
|
||||
if (!alive) return;
|
||||
try {
|
||||
const r = await tgPoll(pairSession);
|
||||
const r = await tgPoll(pairSession, pollSecret);
|
||||
if (!alive) return;
|
||||
if (r.status === "authenticated") {
|
||||
setSession(r.token, r.driver_id);
|
||||
@@ -37,7 +43,7 @@ export function usePairPoll(pairSession: string | null): { status: Status } {
|
||||
};
|
||||
tick();
|
||||
return () => { alive = false; clearTimeout(timer); };
|
||||
}, [pairSession, setSession]);
|
||||
}, [pairSession, pollSecret, setSession]);
|
||||
|
||||
return { status };
|
||||
}
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getPaymentState } from "@/api/driver";
|
||||
|
||||
const TERMINAL = new Set(["PAID", "CONFIRMED", "REJECTED", "REVERSED", "REFUNDED", "CANCELED", "AUTH_FAIL"]);
|
||||
// финальные статусы: дальше опрашивать нечего, экран показывает итог —
|
||||
// оплачено, отказ, истёкший срок или ошибка
|
||||
const TERMINAL = new Set([
|
||||
"PAID", "CONFIRMED", "REJECTED", "REVERSED", "REFUNDED",
|
||||
"PARTIAL_REVERSED", "PARTIAL_REFUNDED",
|
||||
"CANCELED", "AUTH_FAIL", "DEADLINE_EXPIRED", "ATTEMPTS_EXPIRED", "ERROR",
|
||||
]);
|
||||
|
||||
export function usePaymentStatus(orderId: string) {
|
||||
return useQuery({
|
||||
const query = useQuery({
|
||||
queryKey: ["payment", orderId],
|
||||
queryFn: () => getPaymentState(orderId),
|
||||
refetchInterval: (q) => (q.state.data && TERMINAL.has(q.state.data.status) ? false : 1500),
|
||||
// повтором здесь работает сам опрос через 1.5 с, а внутренние ретраи с backoff
|
||||
// только оттягивают момент, когда сбой станет виден на экране
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// серию неудач считаем сами: failureCount обнуляется в начале каждого опроса
|
||||
// и о том, сколько раз подряд не ответил бэкенд, ничего не говорит
|
||||
const [failStreak, setFailStreak] = useState(0);
|
||||
useEffect(() => {
|
||||
if (query.errorUpdatedAt) setFailStreak((n) => n + 1);
|
||||
}, [query.errorUpdatedAt]);
|
||||
useEffect(() => {
|
||||
if (query.dataUpdatedAt) setFailStreak(0);
|
||||
}, [query.dataUpdatedAt]);
|
||||
|
||||
// пока ни одного удачного ответа не было, на время запроса isError снова гаснет
|
||||
// (react-query возвращает статус в pending) — экран бы моргал ожиданием на каждом опросе.
|
||||
// отметки времени такого провала не дают и гаснут ровно тогда, когда пришли данные
|
||||
const lastFailed = query.errorUpdatedAt > query.dataUpdatedAt;
|
||||
|
||||
return { ...query, failStreak, lastFailed };
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { keypadReduce, withCommission } from "./amount";
|
||||
import { keypadReduce, parseAmount, withCommission } from "./amount";
|
||||
|
||||
describe("amount keypad", () => {
|
||||
it("appends digits, ignores leading zero, caps length", () => {
|
||||
@@ -16,4 +16,18 @@ describe("amount keypad", () => {
|
||||
expect(withCommission(1000, 0.052)).toEqual({ commission: 52, total: 1052 });
|
||||
expect(withCommission(333, 0.052)).toEqual({ commission: 17.32, total: 350.32 });
|
||||
});
|
||||
it("separator key builds kopecks — раньше клавиша была нарисована, но не работала", () => {
|
||||
expect(keypadReduce("100", ",")).toBe("100,");
|
||||
expect(keypadReduce("100,", "5")).toBe("100,5");
|
||||
expect(keypadReduce("100,5", "2")).toBe("100,52");
|
||||
expect(keypadReduce("100,52", "9")).toBe("100,52"); // копеек ровно две
|
||||
expect(keypadReduce("100,52", ",")).toBe("100,52"); // второй разделитель не нужен
|
||||
expect(keypadReduce("", ",")).toBe("0,");
|
||||
expect(keypadReduce("100,5", "back")).toBe("100,");
|
||||
});
|
||||
it("parseAmount reads the keypad string", () => {
|
||||
expect(parseAmount("100,52")).toBe(100.52);
|
||||
expect(parseAmount("100,")).toBe(100);
|
||||
expect(parseAmount("")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,38 @@
|
||||
const MAX_DIGITS = 6;
|
||||
const MAX_DIGITS = 6; // целая часть
|
||||
const SEP = ","; // русский разделитель — как на клавише
|
||||
|
||||
/**
|
||||
* Одно нажатие клавиатуры суммы.
|
||||
*
|
||||
* Разделитель раньше был нарисован на клавише, но ничего не делал: `press`
|
||||
* молча игнорировал его, а `keypadReduce` принимал только цифры. Заплатить
|
||||
* точный долг вроде 1234,56 было нечем.
|
||||
*/
|
||||
export function keypadReduce(current: string, key: string): string {
|
||||
if (key === "back") return current.slice(0, -1);
|
||||
if (key === SEP || key === "." || key === "·") {
|
||||
if (current.includes(SEP)) return current; // второй разделитель не нужен
|
||||
return current === "" ? "0" + SEP : current + SEP;
|
||||
}
|
||||
if (!/^\d$/.test(key)) return current;
|
||||
const [whole, frac] = current.split(SEP);
|
||||
if (frac !== undefined) {
|
||||
if (frac.length >= 2) return current; // копеек ровно две
|
||||
return `${whole}${SEP}${frac}${key}`;
|
||||
}
|
||||
if (current === "0") return key === "0" ? "0" : key; // replace leading zero
|
||||
if (current === "" && key === "0") return ""; // no leading zero from empty
|
||||
if (current.length >= MAX_DIGITS) return current;
|
||||
return current + key;
|
||||
}
|
||||
|
||||
/** Строка с клавиатуры → число рублей. Незавершённое «100,» читается как 100. */
|
||||
export function parseAmount(raw: string): number {
|
||||
if (!raw) return 0;
|
||||
const n = parseFloat(raw.replace(SEP, "."));
|
||||
return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0;
|
||||
}
|
||||
|
||||
export function withCommission(base: number, rate: number): { commission: number; total: number } {
|
||||
const commission = Math.round(base * rate * 100) / 100;
|
||||
const total = Math.round((base + commission) * 100) / 100;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -7,6 +7,12 @@ describe("format", () => {
|
||||
expect(formatMoney(-3200)).toBe("−3 200 ₽");
|
||||
expect(formatMoney(0)).toBe("0 ₽");
|
||||
});
|
||||
it("formatMoney keeps kopecks — раньше Math.round() показывал не ту сумму, что спишет банк", () => {
|
||||
expect(formatMoney(350.32)).toBe("350,32 ₽");
|
||||
expect(formatMoney(1052)).toBe("1 052 ₽");
|
||||
expect(formatMoney(10.5)).toBe("10,50 ₽");
|
||||
expect(formatMoney(-350.32)).toBe("−350,32 ₽");
|
||||
});
|
||||
it("digitsOnly strips non-digits", () => {
|
||||
expect(digitsOnly("+7 (914) 305-44-00")).toBe("79143054400");
|
||||
});
|
||||
|
||||
@@ -2,11 +2,22 @@ export function digitsOnly(s: string): string {
|
||||
return (s || "").replace(/\D/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Деньги для показа. Копейки печатаем ТОЛЬКО когда они есть.
|
||||
*
|
||||
* Раньше здесь было Math.round(), и водителю показывали одну сумму, а банк
|
||||
* списывал другую: комиссия 5.2% почти всегда даёт копейки (1000 → 1052,00, но
|
||||
* 333 → 350,32), и «к оплате 350 ₽» расходилось и со списанием, и с чеком.
|
||||
*/
|
||||
export function formatMoney(rub: number): string {
|
||||
const neg = rub < 0;
|
||||
const abs = Math.abs(Math.round(rub));
|
||||
const grouped = abs.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
return `${neg ? "−" : ""}${grouped} ₽`;
|
||||
const abs = Math.abs(rub);
|
||||
const kop = Math.round(abs * 100);
|
||||
const whole = Math.floor(kop / 100);
|
||||
const rest = kop % 100;
|
||||
const grouped = whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
const tail = rest ? `,${rest.toString().padStart(2, "0")}` : "";
|
||||
return `${neg ? "−" : ""}${grouped}${tail} ₽`;
|
||||
}
|
||||
|
||||
/** Render a Russian mobile from up-to-10 national digits as +7 914 305-44-00. */
|
||||
@@ -19,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,40 @@
|
||||
/**
|
||||
* Чтение полезной нагрузки JWT без проверки подписи — для экрана отладки.
|
||||
*
|
||||
* Проверять подпись здесь нечем и не нужно: решение о доступе принимает бэкенд,
|
||||
* а приложению эти поля нужны ровно для того, чтобы показать человеку, чем он
|
||||
* сейчас держится — до какого времени живёт токен и чей это сеанс.
|
||||
*/
|
||||
export interface TokenInfo {
|
||||
sub: string | null;
|
||||
exp: number | null;
|
||||
impersonated: boolean;
|
||||
}
|
||||
|
||||
export function readToken(token: string | null): TokenInfo | null {
|
||||
if (!token) return null;
|
||||
const part = token.split(".")[1];
|
||||
if (!part) return null;
|
||||
try {
|
||||
const json = atob(part.replace(/-/g, "+").replace(/_/g, "/"));
|
||||
const claims = JSON.parse(json) as Record<string, unknown>;
|
||||
return {
|
||||
sub: typeof claims.sub === "string" ? claims.sub : null,
|
||||
exp: typeof claims.exp === "number" ? claims.exp : null,
|
||||
impersonated: claims.imp === true,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** «через 7 ч 20 мин» / «истёк» — человеку, а не в секундах. */
|
||||
export function expiresIn(exp: number | null): string {
|
||||
if (exp == null) return "—";
|
||||
const left = exp * 1000 - Date.now();
|
||||
if (left <= 0) return "истёк";
|
||||
const h = Math.floor(left / 3_600_000);
|
||||
const m = Math.floor((left % 3_600_000) / 60_000);
|
||||
if (h >= 24) return `через ${Math.floor(h / 24)} дн ${h % 24} ч`;
|
||||
return h > 0 ? `через ${h} ч ${m} мин` : `через ${m} мин`;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Pairing-сессия входа через Telegram, пока приложение ждёт бота.
|
||||
*
|
||||
* Держим в sessionStorage: экран ожидания переживает уход в Telegram и возврат,
|
||||
* но сессия не должна пережить закрытие вкладки — это половина ключа к JWT.
|
||||
* Раньше ключи раскладывались по три штуки в каждом экране; теперь одно место,
|
||||
* чтобы `poll_secret` нельзя было забыть положить или забыть стереть.
|
||||
*/
|
||||
const K_SESSION = "pp-pair-session";
|
||||
const K_SECRET = "pp-pair-secret";
|
||||
const K_CONFIRM = "pp-pair-confirm";
|
||||
const K_LINK = "pp-pair-deeplink";
|
||||
|
||||
export interface Pairing {
|
||||
pairSession: string;
|
||||
pollSecret: string;
|
||||
confirmCode: string;
|
||||
deepLink: string | null;
|
||||
}
|
||||
|
||||
export function savePairing(p: {
|
||||
pair_session: string; poll_secret: string; confirm_code: string; deep_link?: string | null;
|
||||
}): void {
|
||||
sessionStorage.setItem(K_SESSION, p.pair_session);
|
||||
sessionStorage.setItem(K_SECRET, p.poll_secret);
|
||||
sessionStorage.setItem(K_CONFIRM, p.confirm_code);
|
||||
if (p.deep_link) sessionStorage.setItem(K_LINK, p.deep_link);
|
||||
else sessionStorage.removeItem(K_LINK);
|
||||
}
|
||||
|
||||
export function loadPairing(): Pairing | null {
|
||||
const pairSession = sessionStorage.getItem(K_SESSION);
|
||||
const pollSecret = sessionStorage.getItem(K_SECRET);
|
||||
if (!pairSession || !pollSecret) return null;
|
||||
return {
|
||||
pairSession,
|
||||
pollSecret,
|
||||
confirmCode: sessionStorage.getItem(K_CONFIRM) ?? "",
|
||||
deepLink: sessionStorage.getItem(K_LINK),
|
||||
};
|
||||
}
|
||||
|
||||
export function clearPairing(): void {
|
||||
[K_SESSION, K_SECRET, K_CONFIRM, K_LINK].forEach((k) => sessionStorage.removeItem(k));
|
||||
}
|
||||
@@ -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,28 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { clearLog, getLog, pushEntry, shortPath, subscribe } from "./reqlog";
|
||||
|
||||
describe("журнал запросов", () => {
|
||||
beforeEach(() => clearLog());
|
||||
|
||||
it("новые записи сверху, старые вытесняются", () => {
|
||||
for (let i = 0; i < 45; i++) pushEntry({ method: "GET", path: `/api/${i}`, status: 200, ms: 1 });
|
||||
const log = getLog();
|
||||
expect(log.length).toBe(40);
|
||||
expect(log[0].path).toBe("/api/44");
|
||||
});
|
||||
|
||||
it("подписчик узнаёт о записи — на этом держится экран отладки", () => {
|
||||
let calls = 0;
|
||||
const off = subscribe(() => { calls += 1; });
|
||||
pushEntry({ method: "GET", path: "/api/x", status: 200, ms: 1 });
|
||||
expect(calls).toBe(1);
|
||||
off();
|
||||
pushEntry({ method: "GET", path: "/api/y", status: 200, ms: 1 });
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("query отбрасывается: в нём ездят идентификаторы", () => {
|
||||
expect(shortPath("https://crm.pptaxi.ru/api/driver/payment/x?token=secret"))
|
||||
.toBe("/api/driver/payment/x");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Кольцевой журнал запросов приложения — для вкладки «Отладка».
|
||||
*
|
||||
* Зачем он, если есть DevTools: приложение живёт на телефоне, и половина
|
||||
* интересных случаев (протухший токен, отвалившийся Wi-Fi, 429 от лимита) видна
|
||||
* только там, где DevTools нет. Журнал даёт те же четыре факта — метод, путь,
|
||||
* статус, длительность — прямо на экране.
|
||||
*
|
||||
* Живёт в памяти и умирает вместе с вкладкой: это отладочный след, а не история.
|
||||
* В нём НЕТ ни тел запросов, ни заголовков — токен водителя в отладочный экран
|
||||
* попасть не должен.
|
||||
*/
|
||||
export interface ReqEntry {
|
||||
id: number;
|
||||
method: string;
|
||||
path: string;
|
||||
status: number | null;
|
||||
ms: number;
|
||||
at: number;
|
||||
}
|
||||
|
||||
const LIMIT = 40;
|
||||
let seq = 0;
|
||||
let entries: ReqEntry[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function pushEntry(e: Omit<ReqEntry, "id" | "at">): void {
|
||||
seq += 1;
|
||||
entries = [{ ...e, id: seq, at: Date.now() }, ...entries].slice(0, LIMIT);
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function clearLog(): void {
|
||||
entries = [];
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
/** Снимок для useSyncExternalStore: ссылка меняется только при новой записи. */
|
||||
export function getLog(): ReqEntry[] {
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Путь без origin и без query: в query ездят идентификаторы, а экран публичный. */
|
||||
export function shortPath(url: string): string {
|
||||
try {
|
||||
return new URL(url).pathname;
|
||||
} catch {
|
||||
return url.split("?")[0];
|
||||
}
|
||||
}
|
||||
@@ -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,60 +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 totalDebt = accounts.filter((a) => a.balance < 0).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 ${totalDebt < 0 ? "text-neg" : ""}`}>{formatMoney(totalDebt)}</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>
|
||||
{main.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-line">
|
||||
{main.map((a) => (
|
||||
// Аренда ведёт к оплате — за тем её и открывают. Штрафы включают
|
||||
// фильтр в списке ниже: «−5 156 ₽» не отвечает на вопрос «за что»,
|
||||
// а отдельного экрана штрафов нет — два списка одного и того же в
|
||||
// разных местах сбивали с толку.
|
||||
<button
|
||||
key={a.bucket}
|
||||
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-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>
|
||||
|
||||
{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) => (
|
||||
<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"
|
||||
>
|
||||
<span className="text-sm">{a.bucket}</span>
|
||||
<b className={a.balance < 0 ? "text-neg" : "text-pos"}>{formatMoney(a.balance)}</b>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button className="btn-primary flex items-center justify-center gap-2 mb-5"
|
||||
onClick={() => setSheet(true)}>
|
||||
<ArrowUp size={18} /> Пополнить
|
||||
</button>
|
||||
|
||||
<button className="btn-primary" onClick={() => nav("/topup")}>Пополнить</button>
|
||||
{/* ── За что списано ─────────────────────────────────────── */}
|
||||
<Statement from={from} to={to} kind={kind}
|
||||
onFrom={setFrom} onTo={setTo} onKind={setKind} />
|
||||
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach } 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/staff", () => ({
|
||||
searchDrivers: vi.fn(async () => ({ drivers: [] })),
|
||||
impersonate: vi.fn(),
|
||||
}));
|
||||
// Экран баланса, на который уводит редирект, тянет свои запросы — отдаём пустышки.
|
||||
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";
|
||||
import { DebugPage } from "./DebugPage";
|
||||
import { clearLog, pushEntry } from "@/lib/reqlog";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useStaff } from "@/store/staff";
|
||||
|
||||
function renderAt(path: string) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={[path]}><App /></MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("вкладка отладки", () => {
|
||||
beforeEach(() => { useAuth.getState().clear(); useStaff.getState().clear(); clearLog(); });
|
||||
|
||||
it("водителя на /debug не пускает — уводит в приложение", () => {
|
||||
useAuth.getState().setSession("t", 42);
|
||||
renderAt("/debug");
|
||||
expect(screen.queryByRole("heading", { name: "Отладка" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("сотруднику показывает, от чьего лица открыт экран", () => {
|
||||
useAuth.getState().setSession("t", 42, { impersonated: true, driverName: "Петров Пётр" });
|
||||
renderAt("/debug");
|
||||
expect(screen.getByRole("heading", { name: "Отладка" })).toBeInTheDocument();
|
||||
// Плашка живёт над маршрутами и видна на любом экране, включая этот.
|
||||
expect(screen.getByText(/Смотрю как/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Петров Пётр/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("плашки нет у обычного водителя", () => {
|
||||
useAuth.getState().setSession("t", 42);
|
||||
renderAt("/");
|
||||
expect(screen.queryByText(/Смотрю как/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("журнал запросов виден со статусами и временем", () => {
|
||||
useAuth.getState().setSession("t", 42, { impersonated: true, driverName: "Петров" });
|
||||
pushEntry({ method: "GET", path: "/api/driver/balance", status: 401, ms: 123 });
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<MemoryRouter><DebugPage /></MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
expect(screen.getByText("/api/driver/balance")).toBeInTheDocument();
|
||||
expect(screen.getByText("401")).toBeInTheDocument();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState, useSyncExternalStore } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LogOut, RefreshCw, Trash2, UserCog } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { API_BASE } from "@/api/client";
|
||||
import { clearLog, getLog, subscribe } from "@/lib/reqlog";
|
||||
import { expiresIn, readToken } from "@/lib/jwt";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useStaff } from "@/store/staff";
|
||||
import { DriverPicker } from "@/components/DriverPicker";
|
||||
|
||||
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 break-all">{v}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Вкладка «Отладка» — то, чего в приложении водителя нет и быть не должно:
|
||||
* от чьего лица открыт экран, куда ходят запросы, чем они отвечают.
|
||||
*
|
||||
* Показывается только сотруднику (есть учётка CRM или действует просмотр). У
|
||||
* водителя маршрут ведёт на главную — см. App.
|
||||
*/
|
||||
export function DebugPage() {
|
||||
const nav = useNavigate();
|
||||
const { token, driverId, driverName, impersonated, own, clear, stopImpersonation } = useAuth();
|
||||
const staff = useStaff();
|
||||
const log = useSyncExternalStore(subscribe, getLog);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const info = readToken(token);
|
||||
|
||||
async function hardReload() {
|
||||
// Кэш сервис-воркера переживает деплой и показывает вчерашний бандл — на
|
||||
// отладке это стоит часа «почему правка не приехала».
|
||||
try {
|
||||
const regs = await navigator.serviceWorker?.getRegistrations?.();
|
||||
await Promise.all((regs ?? []).map((r) => r.unregister()));
|
||||
const keys = await caches?.keys?.();
|
||||
await Promise.all((keys ?? []).map((k) => caches.delete(k)));
|
||||
toast.success("Кэш сброшен, перезагружаю");
|
||||
} catch {
|
||||
toast.error("Не удалось сбросить кэш");
|
||||
}
|
||||
location.reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-3">
|
||||
<h1 className="text-lg font-extrabold mb-4">Отладка</h1>
|
||||
|
||||
<div className="card p-4 mb-4">
|
||||
<p className="text-muted text-xs mb-1">Сейчас смотрю</p>
|
||||
<p className="text-sm font-bold mb-3">
|
||||
{impersonated ? (driverName ?? `Водитель #${driverId}`) : "Свой аккаунт водителя"}
|
||||
</p>
|
||||
<button className="btn-ghost flex items-center justify-center gap-2"
|
||||
onClick={() => setSwitching((s) => !s)}>
|
||||
<UserCog size={16} /> {switching ? "Свернуть" : "Сменить водителя"}
|
||||
</button>
|
||||
{switching && (
|
||||
<div className="mt-4">
|
||||
<DriverPicker onPicked={(d) => { setSwitching(false); toast.success(`Смотрю как ${d.fio}`); }} />
|
||||
</div>
|
||||
)}
|
||||
</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} />
|
||||
<Row k="сборка" v={`${__BUILD_COMMIT__} · ${__BUILD_TIME__}`} />
|
||||
<Row k="сеть" v={navigator.onLine ? "онлайн" : "офлайн"} />
|
||||
</div>
|
||||
|
||||
<div className="card p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-muted text-xs">Последние запросы</p>
|
||||
<button className="text-muted text-xs flex items-center gap-1" onClick={clearLog}>
|
||||
<Trash2 size={12} /> очистить
|
||||
</button>
|
||||
</div>
|
||||
{log.length === 0 ? (
|
||||
<p className="text-muted text-xs py-2">Пока пусто</p>
|
||||
) : (
|
||||
<div className="font-mono text-[11px] leading-5">
|
||||
{log.map((e) => (
|
||||
<div key={e.id} className="flex gap-2 border-b border-line last:border-0 py-1">
|
||||
<span className={e.status && e.status >= 400 ? "text-neg" : "text-pos"}>
|
||||
{e.status ?? "—"}
|
||||
</span>
|
||||
<span className="text-muted">{e.method}</span>
|
||||
<span className="truncate">{e.path}</span>
|
||||
<span className="ml-auto text-muted shrink-0">{e.ms} мс</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted text-[11px] mt-2">
|
||||
Запрос, оборвавшийся без ответа, сюда не попадает.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button className="btn-ghost flex items-center justify-center gap-2 mb-2" onClick={hardReload}>
|
||||
<RefreshCw size={16} /> Сбросить кэш и перезагрузить
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost flex items-center justify-center gap-2"
|
||||
onClick={() => {
|
||||
// Свой вход, отложенный на время просмотра, возвращаем на место: иначе
|
||||
// «посмотреть чужой экран» означало бы заново входить через Telegram.
|
||||
if (impersonated && stopImpersonation()) { nav("/"); return; }
|
||||
clear();
|
||||
nav(staff.token ? "/dev" : "/login");
|
||||
}}
|
||||
>
|
||||
<LogOut size={16} />{" "}
|
||||
{impersonated ? (own ? "Вернуться к своему аккаунту" : "Выйти из просмотра") : "Выйти"}
|
||||
</button>
|
||||
{staff.token && (
|
||||
<button className="text-muted text-xs w-full mt-4"
|
||||
onClick={() => { clear(); staff.clear(); nav("/login"); }}>
|
||||
Выйти и из учётки сотрудника
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
vi.mock("@/api/staff", () => ({
|
||||
staffLogin: vi.fn(async () => ({ access_token: "staff-tok", requires_2fa: false, partial_token: null })),
|
||||
staffLogin2fa: vi.fn(async () => ({ access_token: "staff-tok", requires_2fa: false, partial_token: null })),
|
||||
searchDrivers: vi.fn(async () => ({
|
||||
drivers: [{ id: 42, fio: "Петров Пётр", phone: "+79141234567", status: "Работает", driver_kind: "park" }],
|
||||
})),
|
||||
impersonate: vi.fn(async () => ({
|
||||
token: "driver-tok",
|
||||
driver: { id: 42, fio: "Петров Пётр", phone: "+79141234567", status: "Работает", driver_kind: "park" },
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/api/driver", () => ({ apiErrorText: vi.fn(async (_e: unknown, f: string) => f) }));
|
||||
|
||||
import { DevLoginPage } from "./DevLoginPage";
|
||||
import * as staffApi from "@/api/staff";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useStaff } from "@/store/staff";
|
||||
|
||||
const wrap = () => render(<MemoryRouter><DevLoginPage /></MemoryRouter>);
|
||||
|
||||
describe("DevLoginPage", () => {
|
||||
beforeEach(() => {
|
||||
useAuth.getState().clear();
|
||||
useStaff.getState().clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("логин учёткой CRM, выбор водителя — и сеанс помечен как просмотр", async () => {
|
||||
wrap();
|
||||
await userEvent.type(screen.getByPlaceholderText("Логин"), "vlad");
|
||||
await userEvent.type(screen.getByPlaceholderText("Пароль"), "secret");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
|
||||
|
||||
expect(staffApi.staffLogin).toHaveBeenCalledWith("vlad", "secret");
|
||||
expect(useStaff.getState().token).toBe("staff-tok");
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Петров Пётр/ }));
|
||||
expect(staffApi.impersonate).toHaveBeenCalledWith(42);
|
||||
const auth = useAuth.getState();
|
||||
// Метка просмотра — то, на чём держатся и плашка, и запрет боевой оплаты.
|
||||
expect(auth.token).toBe("driver-tok");
|
||||
expect(auth.driverId).toBe(42);
|
||||
expect(auth.impersonated).toBe(true);
|
||||
expect(auth.driverName).toBe("Петров Пётр");
|
||||
});
|
||||
|
||||
it("двухфакторка: пароль сам по себе сеанса не открывает", async () => {
|
||||
vi.mocked(staffApi.staffLogin).mockResolvedValueOnce({
|
||||
access_token: null, requires_2fa: true, partial_token: "partial",
|
||||
});
|
||||
wrap();
|
||||
await userEvent.type(screen.getByPlaceholderText("Логин"), "vlad");
|
||||
await userEvent.type(screen.getByPlaceholderText("Пароль"), "secret");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
|
||||
expect(useStaff.getState().token).toBeNull();
|
||||
|
||||
// Шестая цифра отправляет код сама: раньше форма ждала нажатия, а «Готово» на
|
||||
// клавиатуре телефона ничего не делало — выглядело как «код не принят».
|
||||
await userEvent.type(await screen.findByPlaceholderText("000000"), "123456");
|
||||
expect(staffApi.staffLogin2fa).toHaveBeenCalledWith("partial", "123456");
|
||||
expect(useStaff.getState().token).toBe("staff-tok");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { apiErrorText } from "@/api/driver";
|
||||
import { staffLogin, staffLogin2fa } from "@/api/staff";
|
||||
import { useStaff } from "@/store/staff";
|
||||
import { DriverPicker } from "@/components/DriverPicker";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
|
||||
/**
|
||||
* Вход сотрудника — тот же логин и пароль, что в CRM.
|
||||
*
|
||||
* Экран нужен, пока приложение обкатывается: интерфейс проверяется на живых
|
||||
* данных, а живые данные лежат у настоящих водителей. Отдельного входа «за
|
||||
* водителя» здесь нет: сотрудник входит СОБОЙ, а дальше выбирает, чей экран
|
||||
* смотреть, — и это записывается в аудит CRM.
|
||||
*/
|
||||
export function DevLoginPage() {
|
||||
const nav = useNavigate();
|
||||
const { token: staffToken, username: staffName, setStaff, clear } = useStaff();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [partial, setPartial] = useState<string | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit() {
|
||||
if (!username || !password || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await staffLogin(username, password);
|
||||
if (r.requires_2fa && r.partial_token) {
|
||||
setPartial(r.partial_token);
|
||||
return;
|
||||
}
|
||||
if (!r.access_token) throw new Error("no token");
|
||||
setStaff(r.access_token, username);
|
||||
} catch (e: unknown) {
|
||||
toast.error(await apiErrorText(e, "Неверный логин или пароль"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit2fa(value = code) {
|
||||
if (!partial || value.length < 6 || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await staffLogin2fa(partial, value);
|
||||
if (!r.access_token) throw new Error("no token");
|
||||
setStaff(r.access_token, username);
|
||||
setPartial(null);
|
||||
} catch (e: unknown) {
|
||||
toast.error(await apiErrorText(e, "Код не подошёл"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (staffToken) {
|
||||
return (
|
||||
<div className="pt-10">
|
||||
<h1 className="text-lg font-extrabold mb-1">Кого смотрим</h1>
|
||||
<p className="text-muted text-xs mb-5">
|
||||
Сотрудник: {staffName}. Приложение откроется от лица выбранного водителя,
|
||||
деньги в этом режиме не двигаются.
|
||||
</p>
|
||||
<DriverPicker onPicked={() => nav("/")} />
|
||||
<button className="btn-ghost mt-5" onClick={clear}>Выйти из учётки сотрудника</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-16">
|
||||
<h1 className="text-xl font-extrabold mb-1">Вход для сотрудника</h1>
|
||||
<p className="text-muted text-sm mb-8">Учётная запись Premium CRM</p>
|
||||
|
||||
{partial ? (
|
||||
<>
|
||||
<p className="text-sm mb-2">Код из приложения-аутентификатора.</p>
|
||||
<input
|
||||
autoFocus
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={code}
|
||||
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-surface"
|
||||
/>
|
||||
<button className="btn-primary" disabled={busy || code.length < 6} onClick={() => submit2fa()}>
|
||||
{busy ? <Spinner /> : "Подтвердить"}
|
||||
</button>
|
||||
<button className="btn-ghost mt-2" onClick={() => { setPartial(null); setCode(""); }}>
|
||||
Назад
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Логин"
|
||||
className="card w-full px-4 py-3 mb-2 text-base bg-surface"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
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-surface"
|
||||
/>
|
||||
<button className="btn-primary" disabled={busy || !username || !password} onClick={submit}>
|
||||
{busy ? <Spinner /> : "Войти"}
|
||||
</button>
|
||||
<button className="btn-ghost mt-2" onClick={() => nav("/login")}>Я водитель</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,41 +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";
|
||||
|
||||
const STATUS_RU: Record<string, string> = {
|
||||
PAID: "Оплачено", NEW: "Ожидает", FORM: "Ожидает", CONFIRMED: "Оплачено", REJECTED: "Отклонён",
|
||||
};
|
||||
|
||||
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) => (
|
||||
<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>{formatMoney(p.amount)}</b>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { 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);
|
||||
@@ -15,40 +27,67 @@ 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.
|
||||
const [tg, setTg] = useState<{ pair_session: string; poll_secret: string;
|
||||
confirm_code: string; deep_link: string | null } | null>(null);
|
||||
const e164 = "7" + phone;
|
||||
|
||||
async function loginViaTelegram() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await tgSession();
|
||||
sessionStorage.setItem("pp-pair-session", r.pair_session);
|
||||
if (r.deep_link) { window.location.href = r.deep_link; }
|
||||
nav("/tg-wait");
|
||||
} catch {
|
||||
toast.error("Не удалось начать вход. Попробуйте «по коду».");
|
||||
setBusy(false);
|
||||
}
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
tgSession().then((r) => { if (alive) setTg(r); }).catch(() => {});
|
||||
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);
|
||||
nav("/tg-wait");
|
||||
}
|
||||
|
||||
async function requestCode() {
|
||||
if (phone.length < 10) return;
|
||||
if (phone.length < 10 || cooldown > 0) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await authRequest(e164);
|
||||
if (r.status === "onboarding") {
|
||||
// Онбординг теперь идёт той же pairing-сессией: бот попросит поделиться
|
||||
// контактом, Telegram подтвердит номер, приложение заберёт токен опросом.
|
||||
// Второй раз номер вводить не нужно.
|
||||
savePairing({ ...r, deep_link: r.deep_links.tg ?? null });
|
||||
sessionStorage.setItem("pp-onboarding", JSON.stringify({ ...r.deep_links, phone }));
|
||||
nav("/onboarding");
|
||||
return;
|
||||
}
|
||||
setChannel(r.channel);
|
||||
setStep("code");
|
||||
// Запускаем отсчёт до повторной отправки — чтобы водитель не жал повторно
|
||||
// раньше времени и не упирался в «слишком часто».
|
||||
setCooldown(RESEND_COOLDOWN_SEC);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
const msg =
|
||||
status === 404 ? "Номер не найден. Обратитесь в парк." :
|
||||
status === 429 ? "Слишком часто. Попробуйте позже." :
|
||||
"Не удалось отправить код";
|
||||
toast.error(msg);
|
||||
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 ? "Номер не найден. Обратитесь в парк." :
|
||||
"Не удалось отправить код";
|
||||
toast.error(await apiErrorText(err, fallback));
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -60,9 +99,10 @@ export function LoginPage() {
|
||||
try {
|
||||
const r = await authVerify(e164, code);
|
||||
setSession(r.token, r.driver_id);
|
||||
nav("/");
|
||||
} catch {
|
||||
toast.error("Неверный или просроченный код");
|
||||
// Возврат туда, откуда пришёл (напр. ссылка на оплату), иначе на баланс.
|
||||
nav(takeReturnTo(), { replace: true });
|
||||
} catch (err: unknown) {
|
||||
toast.error(await apiErrorText(err, "Неверный или просроченный код"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -75,11 +115,28 @@ export function LoginPage() {
|
||||
|
||||
{mode === "choose" ? (
|
||||
<>
|
||||
<button className="btn-primary" disabled={busy} onClick={loginViaTelegram}>
|
||||
{busy ? <Spinner /> : "Войти через Telegram"}
|
||||
</button>
|
||||
{tg?.deep_link ? (
|
||||
<a
|
||||
href={tg.deep_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-primary block text-center"
|
||||
onClick={startTg}
|
||||
>
|
||||
Войти через Telegram
|
||||
</a>
|
||||
) : (
|
||||
<button className="btn-primary" disabled>
|
||||
<Spinner />
|
||||
</button>
|
||||
)}
|
||||
<p className="text-muted text-xs mt-3">Telegram подтвердит ваш номер автоматически.</p>
|
||||
<button className="btn-ghost mt-4" onClick={() => setMode("code")}>Войти по коду</button>
|
||||
{/* Пока идёт обкатка, сотруднику надо попадать внутрь без карточки
|
||||
водителя и без бота — учёткой CRM. Ссылка неприметная намеренно. */}
|
||||
<button className="text-muted text-xs w-full mt-6" onClick={() => nav("/dev")}>
|
||||
Вход для сотрудника
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -90,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>
|
||||
</>
|
||||
@@ -105,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,42 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
|
||||
vi.mock("@/api/driver", () => ({
|
||||
mockPayOutcome: vi.fn(async () => ({ order_id: "mock-1", status: "PAID" })),
|
||||
apiErrorText: vi.fn(async (_e: unknown, f: string) => f),
|
||||
}));
|
||||
|
||||
import { MockPayPage } from "./MockPayPage";
|
||||
import * as driver from "@/api/driver";
|
||||
import { useAuth } from "@/store/auth";
|
||||
|
||||
const wrap = () =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/mock-pay/mock-1"]}>
|
||||
<Routes>
|
||||
<Route path="/mock-pay/:orderId" element={<MockPayPage />} />
|
||||
<Route path="/pay/:orderId" element={<p>экран оплаты</p>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe("MockPayPage", () => {
|
||||
beforeEach(() => { useAuth.getState().clear(); vi.clearAllMocks(); });
|
||||
|
||||
it("исход уходит на бэкенд и возвращает на настоящий экран оплаты", async () => {
|
||||
useAuth.getState().setSession("t", 42, { impersonated: true, driverName: "Петров" });
|
||||
wrap();
|
||||
await userEvent.click(screen.getByRole("button", { name: /Банк отклонил/ }));
|
||||
expect(driver.mockPayOutcome).toHaveBeenCalledWith("mock-1", "rejected");
|
||||
expect(await screen.findByText("экран оплаты")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("водителю тут делать нечего", async () => {
|
||||
useAuth.getState().setSession("t", 42);
|
||||
wrap();
|
||||
expect(screen.getByText(/Учебная оплата недоступна/)).toBeInTheDocument();
|
||||
expect(driver.mockPayOutcome).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { apiErrorText, mockPayOutcome } from "@/api/driver";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
|
||||
/**
|
||||
* Учебная «касса»: чем закончится платёж, который сотрудник создал под просмотром.
|
||||
*
|
||||
* Это подмена банка, а не экрана приложения. Настоящий экран оплаты
|
||||
* (`/pay/:orderId`) остаётся тем же самым и разбирает те же статусы — иначе
|
||||
* отладка проверяла бы поведение, которого у водителя не будет.
|
||||
*/
|
||||
const OUTCOMES = [
|
||||
{ key: "paid", label: "Оплата прошла", hint: "PAID — экран «Оплачено»" },
|
||||
{ key: "rejected", label: "Банк отклонил", hint: "REJECTED — «Оплата не прошла»" },
|
||||
{ key: "expired", label: "Счёт протух", hint: "DEADLINE_EXPIRED — «Срок оплаты истёк»" },
|
||||
{ key: "reversed", label: "Деньги вернулись", hint: "REVERSED — «Платёж возвращён»" },
|
||||
] as const;
|
||||
|
||||
export function MockPayPage() {
|
||||
const { orderId = "" } = useParams();
|
||||
const nav = useNavigate();
|
||||
const impersonated = useAuth((s) => s.impersonated);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
async function choose(outcome: (typeof OUTCOMES)[number]["key"]) {
|
||||
setBusy(outcome);
|
||||
try {
|
||||
await mockPayOutcome(orderId, outcome);
|
||||
nav(`/pay/${orderId}`, { replace: true });
|
||||
} catch (e: unknown) {
|
||||
toast.error(await apiErrorText(e, "Не удалось разыграть исход"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!impersonated) {
|
||||
return (
|
||||
<div className="pt-24 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-8">
|
||||
<button className="text-muted text-xs mb-3" onClick={() => nav(-1)}>‹ Назад</button>
|
||||
<h1 className="text-lg font-extrabold mb-1">Учебная оплата</h1>
|
||||
<p className="text-muted text-xs mb-5">
|
||||
Банка в этом платеже нет: ни списания, ни чека, ни проводки. Выберите, что
|
||||
«ответит» банк — приложение покажет ровно то же, что увидел бы водитель.
|
||||
</p>
|
||||
<div className="card divide-y divide-line">
|
||||
{OUTCOMES.map((o) => (
|
||||
<button
|
||||
key={o.key}
|
||||
disabled={busy !== null}
|
||||
onClick={() => choose(o.key)}
|
||||
className="w-full text-left px-4 py-3 flex items-center justify-between gap-3 disabled:opacity-50"
|
||||
>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold">{o.label}</span>
|
||||
<span className="block text-muted text-xs">{o.hint}</span>
|
||||
</span>
|
||||
{busy === o.key && <Spinner />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted text-xs mt-4">Заказ: {orderId}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,22 +2,41 @@ import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { OnboardingPage } from "./OnboardingPage";
|
||||
import { savePairing, clearPairing } from "@/lib/pairing";
|
||||
|
||||
describe("OnboardingPage", () => {
|
||||
beforeEach(() =>
|
||||
sessionStorage.setItem("pp-onboarding", JSON.stringify({ tg: "https://t.me/b?start=x", max: "https://max.ru/b?start=x", phone: "9143054400" }))
|
||||
);
|
||||
it("shows both bot links + back-to-code action", () => {
|
||||
beforeEach(() => {
|
||||
clearPairing();
|
||||
sessionStorage.setItem("pp-onboarding", JSON.stringify({ tg: "https://t.me/b?start=x", max: "https://max.ru/b?start=x", phone: "9143054400" }));
|
||||
});
|
||||
|
||||
it("shows both bot links + back to login", () => {
|
||||
render(<MemoryRouter><OnboardingPage /></MemoryRouter>);
|
||||
expect(screen.getByText(/Подключите бот/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /Telegram/i })).toHaveAttribute("href", "https://t.me/b?start=x");
|
||||
expect(screen.getByRole("link", { name: /MAX/i })).toHaveAttribute("href", "https://max.ru/b?start=x");
|
||||
expect(screen.getByRole("button", { name: /запросить код/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Назад ко входу/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only Telegram when max is absent", () => {
|
||||
sessionStorage.setItem("pp-onboarding", JSON.stringify({ tg: "https://t.me/ppdriver_bot?start=x", phone: "9143054400" }));
|
||||
render(<MemoryRouter><OnboardingPage /></MemoryRouter>);
|
||||
expect(screen.getByRole("link", { name: /Telegram/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /MAX/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the confirm code the bot must repeat", () => {
|
||||
// Защита от фишинга: если ссылку прислал посторонний, у человека этого числа
|
||||
// на экране нет — значит и делиться номером не нужно.
|
||||
savePairing({ pair_session: "S", poll_secret: "SEC", confirm_code: "4217", deep_link: "https://t.me/b?start=S" });
|
||||
render(<MemoryRouter><OnboardingPage /></MemoryRouter>);
|
||||
expect(screen.getByText("4217")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("no longer asks the driver to come back for a code", () => {
|
||||
// Онбординг идёт той же pairing-сессией: Telegram подтверждает номер, и
|
||||
// приложение забирает токен опросом — второй раз номер вводить не нужно.
|
||||
render(<MemoryRouter><OnboardingPage /></MemoryRouter>);
|
||||
expect(screen.queryByRole("button", { name: /запросить код/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { loadPairing } from "@/lib/pairing";
|
||||
|
||||
export function OnboardingPage() {
|
||||
const nav = useNavigate();
|
||||
const raw = sessionStorage.getItem("pp-onboarding");
|
||||
const data = raw ? (JSON.parse(raw) as { tg?: string; max?: string; phone: string }) : null;
|
||||
const pairing = loadPairing();
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="pt-16">
|
||||
<h1 className="text-xl font-extrabold mb-1">Подключите бот</h1>
|
||||
<p className="text-muted text-sm mb-8">
|
||||
Чтобы получать код входа, откройте бот и нажмите «Старт». Затем вернитесь и запросите код снова.
|
||||
Откройте бот и поделитесь своим номером — Telegram подтвердит его, и вход
|
||||
произойдёт автоматически. Возвращаться за кодом не нужно.
|
||||
</p>
|
||||
{pairing?.confirmCode && (
|
||||
<div className="card p-4 mb-6 text-center">
|
||||
<p className="text-muted text-xs mb-1">Код подтверждения</p>
|
||||
<p className="text-2xl font-extrabold tracking-widest">{pairing.confirmCode}</p>
|
||||
<p className="text-muted text-xs mt-2">
|
||||
Бот назовёт это число. Не совпало — закройте бот и не делитесь номером.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{data.tg && (
|
||||
<a href={data.tg} target="_blank" rel="noopener" className="btn-primary mb-3 no-underline block">
|
||||
<a
|
||||
href={data.tg}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="btn-primary mb-3 no-underline block"
|
||||
onClick={() => nav("/tg-wait")}
|
||||
>
|
||||
Подключить Telegram
|
||||
</a>
|
||||
)}
|
||||
@@ -23,7 +41,7 @@ export function OnboardingPage() {
|
||||
</a>
|
||||
)}
|
||||
<button className="btn-ghost" onClick={() => nav("/login")}>
|
||||
Я подключил — запросить код
|
||||
Назад ко входу
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,41 +1,135 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Check } from "lucide-react";
|
||||
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";
|
||||
|
||||
// платёж не состоялся, деньги не ушли — помогает только новая попытка.
|
||||
// ATTEMPTS_EXPIRED — исчерпаны попытки ввода на форме банка, тот же исход для водителя
|
||||
const FAILED = new Set(["REJECTED", "AUTH_FAIL", "ERROR", "CANCELED", "ATTEMPTS_EXPIRED"]);
|
||||
// деньги дошли и вернулись обратно — новая попытка тут ни при чём, человеку нужен другой текст
|
||||
const RETURNED = new Set(["REVERSED", "REFUNDED", "PARTIAL_REVERSED", "PARTIAL_REFUNDED"]);
|
||||
// сколько неудач подряд терпим, прежде чем сказать вслух, что связи нет: ~15 секунд опроса.
|
||||
// одиночный сбой пережидаем молча — следующий опрос обычно всё чинит
|
||||
const FAIL_LIMIT = 10;
|
||||
|
||||
export function PaymentPage() {
|
||||
const { orderId = "" } = useParams();
|
||||
const nav = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const st = (useLocation().state as { payUrl?: string; total?: number; bucket?: string; base?: number } | null) ?? {};
|
||||
const { data } = usePaymentStatus(orderId);
|
||||
const paid = data?.status === "PAID";
|
||||
const st = (useLocation().state as
|
||||
{ payUrl?: string; total?: number; bucket?: string; base?: number; method?: string } | null) ?? {};
|
||||
const { data, lastFailed, failStreak, refetch } = usePaymentStatus(orderId);
|
||||
// Учебный платёж узнаётся по номеру заказа, а не по state роутера: state
|
||||
// теряется при перезагрузке, а префикс — нет (см. backend mock_pay).
|
||||
const mock = orderId.startsWith("mock-");
|
||||
const status = data?.status ?? "";
|
||||
const paid = status === "PAID";
|
||||
// пока метод не передан в state — узнаём по ссылке: СБП отдаёт платёжку qr.nspk.ru, карта — форму банка
|
||||
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 = (
|
||||
<button className="btn-primary mt-8" onClick={() => nav("/topup", { state: { bucket: st.bucket }, replace: true })}>
|
||||
Пополнить ещё раз
|
||||
</button>
|
||||
);
|
||||
|
||||
if (paid) {
|
||||
return (
|
||||
<div className="pt-24 flex flex-col items-center text-center">
|
||||
<span className="w-14 h-14 rounded-full bg-pos text-white flex items-center justify-center mb-3"><Check size={28} /></span>
|
||||
<p className="text-lg font-extrabold">Оплачено</p>
|
||||
<p className="text-muted text-xs mt-1">{st.bucket} пополнен на {formatMoney(st.base ?? 0)}</p>
|
||||
<p className="text-muted text-xs mt-1">
|
||||
{st.base != null ? `${st.bucket ?? "Баланс"} пополнен на ${formatMoney(st.base)}` : "Баланс пополнен"}
|
||||
</p>
|
||||
<button className="btn-primary mt-8" onClick={() => nav("/")}>На главную</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// без этих веток человек оставался на «Ожидаем оплату…» навсегда: опрос уже остановлен
|
||||
if (status === "DEADLINE_EXPIRED") {
|
||||
return (
|
||||
<div className="pt-24 flex flex-col items-center text-center">
|
||||
<span className="w-14 h-14 rounded-full bg-neg text-white flex items-center justify-center mb-3"><Clock size={28} /></span>
|
||||
<p className="text-lg font-extrabold">Срок оплаты истёк</p>
|
||||
<p className="text-muted text-xs mt-1">Счёт больше не действует. Деньги не списаны — создайте новый.</p>
|
||||
{retry}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (FAILED.has(status)) {
|
||||
return (
|
||||
<div className="pt-24 flex flex-col items-center text-center">
|
||||
<span className="w-14 h-14 rounded-full bg-neg text-white flex items-center justify-center mb-3"><X size={28} /></span>
|
||||
<p className="text-lg font-extrabold">Оплата не прошла</p>
|
||||
<p className="text-muted text-xs mt-1">Банк не провёл перевод. Деньги не списаны — попробуйте ещё раз.</p>
|
||||
{retry}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (RETURNED.has(status)) {
|
||||
return (
|
||||
<div className="pt-24 flex flex-col items-center text-center">
|
||||
<span className="w-14 h-14 rounded-full bg-neg text-white flex items-center justify-center mb-3"><Undo2 size={28} /></span>
|
||||
<p className="text-lg font-extrabold">Платёж возвращён</p>
|
||||
<p className="text-muted text-xs mt-1">Деньги вернулись на счёт, с которого платили. Баланс пересчитаем.</p>
|
||||
<button className="btn-primary mt-8" onClick={() => nav("/")}>На главную</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// раньше упавший бэкенд выглядел ровно как честное ожидание оплаты — крутился спиннер.
|
||||
// сам опрос не прекращаем: как только бэкенд ответит, экран вернётся к оплате
|
||||
if (lastFailed && failStreak >= FAIL_LIMIT) {
|
||||
return (
|
||||
<div className="pt-24 flex flex-col items-center text-center">
|
||||
<span className="w-14 h-14 rounded-full bg-muted text-white flex items-center justify-center mb-3"><WifiOff size={28} /></span>
|
||||
<p className="text-lg font-extrabold">Не удаётся проверить оплату</p>
|
||||
<p className="text-muted text-xs mt-1">Похоже, пропала связь. Если вы уже оплатили, деньги дойдут.</p>
|
||||
<button className="btn-primary mt-8" onClick={() => refetch()}>Проверить ещё раз</button>
|
||||
<button className="btn-ghost mt-3" onClick={() => nav("/")}>На главную</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-10 flex flex-col items-center text-center">
|
||||
<span className="w-12 h-12 rounded-xl2 bg-ink text-cream flex items-center justify-center font-extrabold mb-4">СБП</span>
|
||||
<p className="text-lg font-extrabold">{formatMoney(st.total ?? 0)}</p>
|
||||
<p className="text-muted text-xs mb-6">{st.bucket}</p>
|
||||
<a className="btn-primary no-underline" href={st.payUrl} target="_blank" rel="noopener">Оплатить</a>
|
||||
<p className="text-muted text-xs mt-2">Откроется ваше приложение банка</p>
|
||||
<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>}
|
||||
{st.bucket && <p className="text-muted text-xs">{st.bucket}</p>}
|
||||
</div>
|
||||
{mock ? (
|
||||
<>
|
||||
<button className="btn-primary" onClick={() => nav(`/mock-pay/${orderId}`)}>
|
||||
Выбрать исход
|
||||
</button>
|
||||
<p className="text-muted text-xs mt-2">Учебный платёж — банк не задействован</p>
|
||||
</>
|
||||
) : st.payUrl ? (
|
||||
<>
|
||||
<a className="btn-primary no-underline" href={st.payUrl} target="_blank" rel="noopener">Оплатить</a>
|
||||
<p className="text-muted text-xs mt-2">Откроется ваше приложение банка</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted text-xs">Завершите оплату в приложении банка — экран обновится сам</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-6 text-muted text-xs"><Spinner /> Ожидаем оплату…</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,26 +1,56 @@
|
||||
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() {
|
||||
const nav = useNavigate();
|
||||
const pairSession = sessionStorage.getItem("pp-pair-session");
|
||||
const { status } = usePairPoll(pairSession);
|
||||
const pairing = loadPairing();
|
||||
const { status } = usePairPoll(pairing?.pairSession ?? null, pairing?.pollSecret ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pairSession) { nav("/login", { replace: true }); }
|
||||
}, [pairSession, nav]);
|
||||
if (!pairing) { nav("/login", { replace: true }); }
|
||||
}, [pairing, nav]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") { sessionStorage.removeItem("pp-pair-session"); nav("/", { replace: true }); }
|
||||
if (status === "expired") { sessionStorage.removeItem("pp-pair-session"); nav("/login", { replace: true, state: { expired: true } }); }
|
||||
if (status === "authenticated") {
|
||||
clearPairing();
|
||||
// Возврат туда, откуда пришёл (напр. ссылка на оплату), иначе на баланс.
|
||||
nav(takeReturnTo(), { replace: true });
|
||||
}
|
||||
if (status === "expired") {
|
||||
clearPairing();
|
||||
nav("/login", { replace: true, state: { expired: true } });
|
||||
}
|
||||
}, [status, nav]);
|
||||
|
||||
return (
|
||||
<div className="pt-16 text-center">
|
||||
<h1 className="text-xl font-extrabold mb-2">Премиум Водитель</h1>
|
||||
<p className="text-muted text-sm mb-8">Подтвердите номер в Telegram — нажмите «Поделиться номером», затем вернитесь сюда.</p>
|
||||
<p className="text-muted text-sm mb-6">
|
||||
Бот назовёт число ниже и попросит подтвердить вход — нажмите «Это я, войти»
|
||||
(или поделитесь номером, если входите впервые). Затем вернитесь сюда, вход
|
||||
произойдёт автоматически.
|
||||
</p>
|
||||
{pairing?.confirmCode && (
|
||||
// Число сверки: бот называет его в своём сообщении. Если человек попал в
|
||||
// бот по чужой ссылке, у него на экране этого числа нет — подтверждать
|
||||
// нечего, и токен не выдаётся.
|
||||
<div className="card p-4 mb-6">
|
||||
<p className="text-muted text-xs mb-1">Код подтверждения</p>
|
||||
<p className="text-2xl font-extrabold tracking-widest">{pairing.confirmCode}</p>
|
||||
<p className="text-muted text-xs mt-2">
|
||||
Это же число должен назвать бот. Не совпало — нажмите в боте «Это не я».
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{pairing?.deepLink && (
|
||||
<a href={pairing.deepLink} target="_blank" rel="noopener noreferrer" className="btn-primary block text-center mb-6">
|
||||
Открыть Telegram
|
||||
</a>
|
||||
)}
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,14 +5,19 @@ 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: ["Долг аренда"],
|
||||
})),
|
||||
apiErrorText: vi.fn(async (_e: unknown, fallback: string) => fallback),
|
||||
}));
|
||||
import { TopupPage } from "./TopupPage";
|
||||
import * as driver from "@/api/driver";
|
||||
|
||||
const wrap = () => {
|
||||
const qc = new QueryClient();
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter><TopupPage /></MemoryRouter>
|
||||
@@ -30,6 +35,55 @@ 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 () => {
|
||||
// Клавиша разделителя была нарисована, но ничего не делала: заплатить точный
|
||||
// долг вроде 1234,56 было нечем.
|
||||
wrap();
|
||||
for (const k of ["1", "0", "0", ",", "5", "2"]) {
|
||||
await userEvent.click(screen.getByRole("button", { name: k }));
|
||||
}
|
||||
expect(screen.getByTestId("amount").textContent).toBe("100,52 ₽");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Оплатить/i }));
|
||||
expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 100.52, "qr", false);
|
||||
});
|
||||
|
||||
it("lets the driver choose the debt category instead of hardcoding rent", async () => {
|
||||
wrap();
|
||||
await screen.findByRole("button", { name: "Долг по штрафам" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Долг по штрафам" }));
|
||||
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).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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,41 +3,61 @@ import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Delete } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { topup, getCommission } from "@/api/driver";
|
||||
import { keypadReduce, withCommission } from "@/lib/amount";
|
||||
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";
|
||||
|
||||
const QUICK = ["500", "1000", "2000"];
|
||||
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "·", "0", "back"];
|
||||
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 bucket = (loc.state as { bucket?: string } | null)?.bucket ?? "Долг аренда";
|
||||
const fromBalance = (loc.state as { bucket?: string } | null)?.bucket;
|
||||
// Список категорий приходит с сервера — там же он и проверяется. Хардкод
|
||||
// «Долг аренда» отправлял платёж в аренду даже тем, у кого её нет.
|
||||
const { data: bucketData } = useQuery({ queryKey: ["buckets"], queryFn: getBuckets });
|
||||
const options = bucketData?.buckets ?? (fromBalance ? [fromBalance] : []);
|
||||
const [picked, setPicked] = useState<string | null>(null);
|
||||
const bucket = picked ?? fromBalance ?? bucketData?.with_debt[0] ?? options[0] ?? "";
|
||||
|
||||
const [raw, setRaw] = useState("");
|
||||
const [method, setMethod] = useState("qr");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { data: commission } = useQuery({ queryKey: ["commission"], queryFn: getCommission });
|
||||
const rate = commission?.rate ?? 0.052; // fallback only while loading
|
||||
|
||||
const base = raw ? parseInt(raw, 10) : 0;
|
||||
const base = parseAmount(raw);
|
||||
const { commission: fee, total } = withCommission(base, rate);
|
||||
|
||||
function press(k: string) {
|
||||
if (k === "·") return;
|
||||
setRaw((r) => keypadReduce(r, k));
|
||||
}
|
||||
|
||||
async function pay() {
|
||||
if (base <= 0) return;
|
||||
if (base <= 0 || !bucket || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await topup(bucket, base, method as "qr" | "card");
|
||||
nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base } });
|
||||
} catch {
|
||||
toast.error("Не удалось создать платёж");
|
||||
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) {
|
||||
// Текст с сервера, а не глухое «не удалось»: минимум СБП, потолок суммы и
|
||||
// неизвестная категория объясняются словами, иначе экран молча не реагирует.
|
||||
toast.error(await apiErrorText(err, "Не удалось создать платёж"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -45,7 +65,30 @@ export function TopupPage() {
|
||||
|
||||
return (
|
||||
<div className="pt-2">
|
||||
<button className="text-muted text-xs mb-3" onClick={() => nav(-1)}>‹ {bucket}</button>
|
||||
<button className="text-muted text-xs mb-3" onClick={() => nav(-1)}>‹ Назад</button>
|
||||
|
||||
{/* Категорию выбирают в шторке на главном экране. Пришли оттуда — показываем
|
||||
выбранное строкой, а не рядом чипов: второй выбор того же самого сбивает
|
||||
с толку. Чипы остаются для входа сюда напрямую (кнопка «Пополнить» без
|
||||
категории, возврат «пополнить ещё раз» с экрана оплаты). */}
|
||||
{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
|
||||
key={b}
|
||||
onClick={() => setPicked(b)}
|
||||
className={`shrink-0 px-3 py-2 rounded-xl2 text-xs border ${
|
||||
b === bucket ? "border-line bg-surface font-semibold" : "border-line text-muted"
|
||||
}`}
|
||||
>
|
||||
{b}
|
||||
</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">
|
||||
к оплате {formatMoney(total)} (комиссия {formatMoney(fee)})
|
||||
@@ -76,7 +119,14 @@ export function TopupPage() {
|
||||
<Segment value={method} onChange={setMethod} options={[{ value: "qr", label: "СБП" }, { value: "card", label: "Картой" }]} />
|
||||
</div>
|
||||
|
||||
<button className="btn-primary" disabled={busy || base <= 0} onClick={pay}>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,78 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
interface AuthState {
|
||||
/** Кем открыт сеанс: сам водитель или сотрудник, смотрящий от его лица.
|
||||
* Признак приезжает из токена (claim `imp`), здесь он только продублирован для
|
||||
* интерфейса — проверять его на бэкенде по этому полю нельзя и не нужно. */
|
||||
export interface ViewAs {
|
||||
impersonated: boolean;
|
||||
/** ФИО водителя — показывать в плашке. У обычного входа неизвестно и не нужно. */
|
||||
driverName: string | null;
|
||||
}
|
||||
|
||||
/** Собственный сеанс, отложенный на время просмотра чужого. */
|
||||
interface OwnSession {
|
||||
token: string;
|
||||
driverId: number | null;
|
||||
}
|
||||
|
||||
interface AuthState extends ViewAs {
|
||||
token: string | null;
|
||||
driverId: number | null;
|
||||
setSession: (token: string, driverId: number) => void;
|
||||
/** Сеанс, к которому вернёт «выйти из просмотра». 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,
|
||||
setSession: (token, driverId) => set({ token, driverId }),
|
||||
clear: () => set({ token: null, driverId: null }),
|
||||
impersonated: false,
|
||||
driverName: null,
|
||||
own: null,
|
||||
setSession: (token, driverId, view) =>
|
||||
set({
|
||||
token,
|
||||
driverId,
|
||||
impersonated: view?.impersonated ?? false,
|
||||
driverName: view?.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" }
|
||||
)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
/**
|
||||
* Сеанс СОТРУДНИКА — учётка CRM, которой берут просмотр от лица водителя.
|
||||
*
|
||||
* Хранится отдельно от водительского: это два разных токена с разным смыслом.
|
||||
* «Перестать смотреть за водителя» обязано оставлять сотрудника залогиненным,
|
||||
* иначе смена водителя каждый раз означала бы новый ввод пароля; и наоборот —
|
||||
* `clear()` водительского сеанса не должен трогать сотрудника.
|
||||
*/
|
||||
interface StaffState {
|
||||
token: string | null;
|
||||
username: string | null;
|
||||
/** Боевая оплата под просмотром: платёж уходит в банк и зачисляется водителю.
|
||||
* Выключено по умолчанию — под чужим лицом легко забыть, кого пополняешь. */
|
||||
livePay: boolean;
|
||||
setStaff: (token: string, username: string) => void;
|
||||
setLivePay: (on: boolean) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useStaff = create<StaffState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
username: null,
|
||||
livePay: false,
|
||||
setStaff: (token, username) => set({ token, username }),
|
||||
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);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
+5
@@ -7,3 +7,8 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
// Подставляются на сборке (vite.config.ts → define). Нужны экрану отладки:
|
||||
// показывают, какой именно бандл сейчас открыт.
|
||||
declare const __BUILD_COMMIT__: string;
|
||||
declare const __BUILD_TIME__: string;
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true,
|
||||
"noEmit": true, "strict": true
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||
"include": ["vite.config.ts", "vitest.config.ts", "dev-proxy.test.ts"]
|
||||
}
|
||||
|
||||
@@ -1,13 +1,83 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { execSync } from "child_process";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({ registerType: "autoUpdate", manifest: false, includeAssets: ["icons/*.png"] }),
|
||||
],
|
||||
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
|
||||
server: { port: 5174 },
|
||||
/**
|
||||
* Что именно сейчас открыто — видно на вкладке «Отладка».
|
||||
*
|
||||
* Без этого «правка не приехала» и «приехала, но кэш старый» выглядят
|
||||
* одинаково, а проверяются по-разному. Коммит берём из git; вне репозитория
|
||||
* (сборка из архива, CI без .git) — честный «unknown», а не падение сборки.
|
||||
*/
|
||||
function buildCommit(): string {
|
||||
try {
|
||||
return execSync("git rev-parse --short HEAD", { stdio: ["ignore", "pipe", "ignore"] })
|
||||
.toString().trim() || "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальная разработка ходит в бэкенд ЧЕРЕЗ ПРОКСИ dev-сервера, а не напрямую.
|
||||
*
|
||||
* Почему не «разрешить localhost в CORS бэкенда»: через прокси запрос уходит с
|
||||
* того же origin, что и страница (http://localhost:5174/api/...), поэтому CORS
|
||||
* в разработке не участвует вообще — ни preflight, ни credentials. Боевой
|
||||
* список origin'ов на бэкенде остаётся закрытым, и CORS_ALLOW_LOCALHOST ради
|
||||
* этого фронта включать не нужно.
|
||||
*
|
||||
* Дефолтная цель прокси — ЛОКАЛЬНЫЙ бэкенд, а не crm.pptaxi.ru. Контур боевой:
|
||||
* «по умолчанию проксировать в прод» означало бы, что первый же тычок в
|
||||
* «Пополнить» на машине разработчика создаёт платёж на живом терминале и живой
|
||||
* чек 54-ФЗ. Другой адрес задаётся VITE_API_TARGET — тем же именем, что в CRM
|
||||
* (crm2/frontend/vite.config.ts), чтобы рецепт был один на оба фронта.
|
||||
*/
|
||||
export default defineConfig(({ command, mode }) => {
|
||||
// База для ky (src/api/client.ts): в dev — свой же origin, дальше прокси.
|
||||
// Ставим ТОЛЬКО если разработчик не задал базу сам (.env.local, .env,
|
||||
// VITE_API_BASE=... перед командой): loadEnv отдаёт и файлы, и process.env,
|
||||
// причём process.env приоритетнее файлов — без этой проверки собственная
|
||||
// настройка молча перебивалась бы нашим дефолтом.
|
||||
// В сборку (command === "build") не лезем никогда: там база остаётся боевой —
|
||||
// из .env, либо из дефолта в client.ts.
|
||||
if (command === "serve" && !loadEnv(mode, __dirname, "VITE_").VITE_API_BASE) {
|
||||
process.env.VITE_API_BASE = "/api";
|
||||
}
|
||||
|
||||
return {
|
||||
define: {
|
||||
__BUILD_COMMIT__: JSON.stringify(buildCommit()),
|
||||
__BUILD_TIME__: JSON.stringify(new Date().toISOString().slice(0, 16).replace("T", " ")),
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
// 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: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
"/api": {
|
||||
// secure: false нужен только для VITE_API_TARGET на https-цель с
|
||||
// самоподписанным сертификатом; на дефолтную http-цель не влияет.
|
||||
target: process.env.VITE_API_TARGET || "http://127.0.0.1:8000",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -3,6 +3,12 @@ import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
// Те же подстановки, что и в боевой сборке (vite.config.ts): без них модуль с
|
||||
// __BUILD_COMMIT__ падает в тестах на ReferenceError, хотя в приложении работает.
|
||||
define: {
|
||||
__BUILD_COMMIT__: JSON.stringify("test"),
|
||||
__BUILD_TIME__: JSON.stringify("test"),
|
||||
},
|
||||
plugins: [react()],
|
||||
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
|
||||
test: { globals: true, environment: "jsdom", setupFiles: ["./src/test/setup.ts"] },
|
||||
|
||||
@@ -13,6 +13,7 @@ import RepairDetailPage from "@/pages/repairs/RepairDetailPage";
|
||||
import EditRepairPage from "@/pages/repairs/EditRepairPage";
|
||||
import CarSelectPage from "@/pages/repairs/CarSelectPage";
|
||||
import CreateRepairPage from "@/pages/repairs/CreateRepairPage";
|
||||
import RepairHistoryPage from "@/pages/repairs/RepairHistoryPage";
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuth((s) => s.token);
|
||||
@@ -40,6 +41,12 @@ export default function App() {
|
||||
<Route path="/repairs/new" element={<RequireAuth><CarSelectPage /></RequireAuth>} />
|
||||
<Route path="/repairs/new/:carId" element={<RequireAuth><CreateRepairPage /></RequireAuth>} />
|
||||
<Route path="/repairs/new-external" element={<RequireAuth><CreateRepairPage /></RequireAuth>} />
|
||||
{/* «История ремонта». Держим выше /repairs/:id по тому же принципу, что и
|
||||
/repairs/new: react-router ранжирует (статический сегмент весит больше
|
||||
динамического), так что перехвата не будет и при другом порядке, но
|
||||
глазами читается сразу — статика раньше :id. */}
|
||||
<Route path="/repairs/history" element={<RequireAuth><RepairHistoryPage /></RequireAuth>} />
|
||||
<Route path="/repairs/history/:carId" element={<RequireAuth><RepairHistoryPage /></RequireAuth>} />
|
||||
<Route path="/repairs/:id" element={<RequireAuth><RepairDetailPage /></RequireAuth>} />
|
||||
<Route path="/repairs/:id/edit" element={<RequireAuth><EditRepairPage /></RequireAuth>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -102,9 +102,19 @@ export interface CreateRepairResponse {
|
||||
tg_outbox_status: string;
|
||||
}
|
||||
|
||||
export async function searchCars(q: string): Promise<CarBrief[]> {
|
||||
const qs = q ? `?q=${encodeURIComponent(q)}` : "";
|
||||
return api.get(`cars${qs}`).json<CarBrief[]>();
|
||||
export interface SearchCarsOptions {
|
||||
// Искать по всему парку, а не только по активным машинам. Нужно «Истории
|
||||
// ремонта»: ремонты выведенной машины никуда не делись, их ищут и после.
|
||||
// Создание ремонта этим НЕ пользуется — там годятся только активные.
|
||||
includeInactive?: boolean;
|
||||
}
|
||||
|
||||
export async function searchCars(q: string, opts: SearchCarsOptions = {}): Promise<CarBrief[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (q) search.set("q", q);
|
||||
if (opts.includeInactive) search.set("include_inactive", "true");
|
||||
const qs = search.toString();
|
||||
return api.get(`cars${qs ? `?${qs}` : ""}`).json<CarBrief[]>();
|
||||
}
|
||||
|
||||
export async function getCarContext(carId: number): Promise<CarContext> {
|
||||
|
||||
@@ -17,6 +17,9 @@ export interface FeedItem {
|
||||
works_extra: number;
|
||||
works_total: number; // сумма работ без запчастей
|
||||
photo_url: string | null;
|
||||
// Пробег на момент ремонта. У старых ремонтов не заполнен — показываем
|
||||
// это честно, а не нулём (экран «История ремонта»).
|
||||
mileage?: number | null;
|
||||
}
|
||||
|
||||
export interface FeedResponse {
|
||||
@@ -27,12 +30,19 @@ export interface FeedResponse {
|
||||
export interface FeedParams {
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
// Фильтр по машине — лента становится историей ремонтов одного авто.
|
||||
car_id?: number;
|
||||
// Превью-фото. false — экран превью не рисует, и backend не ходит за
|
||||
// ссылками в хранилище. По умолчанию (не передаём) фото приходят.
|
||||
with_photos?: boolean;
|
||||
}
|
||||
|
||||
export async function getRepairsFeed(params: FeedParams = {}): Promise<FeedResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.limit) search.set("limit", String(params.limit));
|
||||
if (params.cursor) search.set("cursor", params.cursor);
|
||||
if (params.car_id) search.set("car_id", String(params.car_id));
|
||||
if (params.with_photos === false) search.set("with_photos", "false");
|
||||
const qs = search.toString();
|
||||
return api
|
||||
.get(`repairs${qs ? `?${qs}` : ""}`)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ClipboardCheck, History, Wrench } from "lucide-react";
|
||||
import { getMe } from "@/api/me";
|
||||
import { logout } from "@/api/auth";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -9,7 +11,8 @@ interface ActionTile {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
// Иконка из lucide-react (пак всего PWA); эмодзи в интерфейсе не используем.
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
comingSoon?: boolean;
|
||||
@@ -24,16 +27,23 @@ export default function ActionMenu() {
|
||||
key: "inspect",
|
||||
title: "Осмотр",
|
||||
description: "Создать или продолжить осмотр машины",
|
||||
emoji: "🔍",
|
||||
icon: ClipboardCheck,
|
||||
onClick: () => navigate("/vehicles?action=inspect"),
|
||||
},
|
||||
{
|
||||
key: "repair",
|
||||
title: "Ремонт",
|
||||
description: "Лента ремонтов парка + создать новый",
|
||||
emoji: "🔧",
|
||||
icon: Wrench,
|
||||
onClick: () => navigate("/repairs"),
|
||||
},
|
||||
{
|
||||
key: "repair-history",
|
||||
title: "История ремонта",
|
||||
description: "Что чинили на машине — поиск по госномеру",
|
||||
icon: History,
|
||||
onClick: () => navigate("/repairs/history"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -62,35 +72,38 @@ export default function ActionMenu() {
|
||||
Что делаем?
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{tiles.map((t) => (
|
||||
<Card
|
||||
key={t.key}
|
||||
onClick={t.disabled ? undefined : t.onClick}
|
||||
className={
|
||||
"p-5 transition-shadow " +
|
||||
(t.disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "cursor-pointer hover:shadow-md active:shadow-sm")
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-3xl leading-none">{t.emoji}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-base">{t.title}</h3>
|
||||
{t.comingSoon && (
|
||||
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
|
||||
скоро
|
||||
</span>
|
||||
)}
|
||||
{tiles.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<Card
|
||||
key={t.key}
|
||||
onClick={t.disabled ? undefined : t.onClick}
|
||||
className={
|
||||
"p-5 transition-shadow " +
|
||||
(t.disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "cursor-pointer hover:shadow-md active:shadow-sm")
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className="h-8 w-8 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-base">{t.title}</h3>
|
||||
{t.comingSoon && (
|
||||
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
|
||||
скоро
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t.description}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useParams, useNavigate, useSearchParams, useLocation } from "react-router-dom";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronRight, Wrench } from "lucide-react";
|
||||
import { getVehicle, getTtHistory } from "@/api/vehicles";
|
||||
import { createInspection, type InspectionType } from "@/api/inspections";
|
||||
import { getRepairsFeed, type FeedItem } from "@/api/repairsFeed";
|
||||
import { fmtMoney, pluralWorks } from "@/lib/repairsFeed";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
/** Сколько последних ремонтов показывать прямо в карточке — остальное
|
||||
* за кнопкой «История ремонта». */
|
||||
const RECENT_REPAIRS_LIMIT = 3;
|
||||
|
||||
const INSPECTION_TYPE_LABELS: Record<InspectionType, string> = {
|
||||
handover: "Выдача",
|
||||
return: "Приёмка",
|
||||
@@ -42,11 +49,16 @@ function timeAgo(iso: string): string {
|
||||
export default function VehicleCard() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const vehicleId = Number(id);
|
||||
|
||||
const action = searchParams.get("action") || undefined;
|
||||
|
||||
// Куда вернуть механика с экранов ремонта: сюда же, вместе с ?action —
|
||||
// иначе «Назад» уводит в общую ленту парка, и машину ищи заново.
|
||||
const backHere = `${location.pathname}${location.search}`;
|
||||
|
||||
const [pendingType, setPendingType] = useState<InspectionType | null>(null);
|
||||
const [mileageInput, setMileageInput] = useState("");
|
||||
const [mileageSource, setMileageSource] = useState<
|
||||
@@ -67,6 +79,29 @@ export default function VehicleCard() {
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
// Ремонты именно этой машины (№4439) — самый частый вопрос механика.
|
||||
// Берём ту же ленту ремонтов, но с фильтром по машине; в карточке
|
||||
// показываем верхушку, вся история — на отдельном экране.
|
||||
const repairsQuery = useQuery({
|
||||
queryKey: ["vehicle", vehicleId, "repairs"],
|
||||
queryFn: () =>
|
||||
getRepairsFeed({
|
||||
car_id: vehicleId,
|
||||
limit: RECENT_REPAIRS_LIMIT,
|
||||
// Превью фото здесь не рисуем — не гоняем backend в хранилище
|
||||
// (на каждую строку: выборка фото, проверка миниатюры, подпись ссылки).
|
||||
with_photos: false,
|
||||
}),
|
||||
enabled: Number.isFinite(vehicleId),
|
||||
staleTime: 60_000,
|
||||
// Запись ремонта происходит на другом экране и этот кэш не сбрасывает.
|
||||
// Поэтому обновляем на каждый вход в карточку: иначе механик, вернувшись
|
||||
// сразу после сохранения, видит блок без своего ремонта — как потерянный.
|
||||
// Старые строки при этом остаются на экране, скелетон не мигает.
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
const recentRepairs: FeedItem[] = repairsQuery.data?.items ?? [];
|
||||
|
||||
const startInspection = useMutation({
|
||||
mutationFn: (input: { type: InspectionType; mileage: number | undefined }) =>
|
||||
createInspection({ vehicle_id: vehicleId, type: input.type, mileage: input.mileage }),
|
||||
@@ -169,6 +204,86 @@ export default function VehicleCard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
Последние ремонты
|
||||
</h2>
|
||||
{repairsQuery.isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="h-16 rounded-lg bg-muted animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : recentRepairs.length === 0 ? (
|
||||
<Card className="p-4 text-sm text-muted-foreground">
|
||||
{repairsQuery.isError
|
||||
? "Не удалось загрузить ремонты."
|
||||
: "Ремонтов ещё не было."}
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentRepairs.map((r) => {
|
||||
const works = r.works_preview.map((w) => w.name).join(" · ");
|
||||
return (
|
||||
<Card
|
||||
key={r.id}
|
||||
className="p-3 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() =>
|
||||
navigate(`/repairs/${r.id}`, { state: { from: backHere } })
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{works || "Ремонт"}
|
||||
{r.works_extra > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· +{r.works_extra} {pluralWorks(r.works_extra)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-2 flex-wrap">
|
||||
<span>
|
||||
{new Date(r.created_at).toLocaleDateString("ru-RU")}
|
||||
</span>
|
||||
{r.mileage != null && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<span>{r.mileage.toLocaleString("ru-RU")} км</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0 tabular-nums">
|
||||
{fmtMoney(r.works_total || 0)} ₽
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Вход в историю оставляем и при ошибке загрузки — на отдельном
|
||||
экране механик хотя бы сможет попробовать ещё раз. */}
|
||||
{(recentRepairs.length > 0 || repairsQuery.isError) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full mt-2"
|
||||
onClick={() =>
|
||||
navigate(`/repairs/history/${vehicleId}`, {
|
||||
state: { from: backHere },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Wrench className="h-4 w-4 mr-2" />
|
||||
История ремонта
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
Прошлые осмотры
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* НЕ редактируются — для смены фото нужно дождаться PR-9.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { ChevronLeft, Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { searchWorks, suggestParts, type MechWork } from "@/api/repairsCreate";
|
||||
import WorksPickerModal from "@/components/repairs/WorksPickerModal";
|
||||
import MechanicsField, { type SelectedMechanic } from "@/components/repairs/MechanicsField";
|
||||
import { hasHistoryToGoBack } from "./RepairDetailPage";
|
||||
|
||||
interface WorkRow {
|
||||
rowKey: string;
|
||||
@@ -52,6 +53,7 @@ function fmt(n: number): string {
|
||||
export default function EditRepairPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [detail, setDetail] = useState<RepairDetailResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -111,6 +113,18 @@ export default function EditRepairPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
// Уход с формы — снятие её записи со стека, а не переход на карточку.
|
||||
// Раньше «Отмена» и «Сохранить» вели navigate(`/repairs/${id}`): запись
|
||||
// формы оставалась в истории, и «Назад» в карточке возвращал обратно в
|
||||
// форму правки (или в ту же карточку — нужен был второй тап).
|
||||
// Карточка при возврате монтируется заново и перечитывает ремонт, так что
|
||||
// после сохранения там свежие данные.
|
||||
const leaveForm = () => {
|
||||
if (hasHistoryToGoBack(location.key)) navigate(-1);
|
||||
// Форму открыли прямой ссылкой — шагать назад некуда, подменяем запись.
|
||||
else navigate(id ? `/repairs/${id}` : "/repairs", { replace: true });
|
||||
};
|
||||
|
||||
const totalSum = useMemo(() => works.reduce((s, w) => s + (w.price_applied || 0), 0), [works]);
|
||||
|
||||
// Works picker — поиск по каталогу.
|
||||
@@ -199,7 +213,7 @@ export default function EditRepairPage() {
|
||||
? { mechanic_ids: mechanics.map((m) => m.mechanic_id) }
|
||||
: {}),
|
||||
});
|
||||
navigate(`/repairs/${detail.id}`, { replace: true });
|
||||
leaveForm();
|
||||
} catch (e) {
|
||||
setError((e as Error).message || "Не удалось сохранить");
|
||||
setSaving(false);
|
||||
@@ -211,7 +225,7 @@ export default function EditRepairPage() {
|
||||
<header className="sticky top-0 z-10 border-b bg-background/95 backdrop-blur">
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
onClick={leaveForm}
|
||||
className="rounded-full p-2 hover:bg-accent"
|
||||
aria-label="Назад"
|
||||
>
|
||||
@@ -393,7 +407,7 @@ export default function EditRepairPage() {
|
||||
{/* Sticky footer */}
|
||||
{detail && (
|
||||
<footer className="fixed bottom-0 left-0 right-0 border-t bg-background p-3 flex gap-2 z-10">
|
||||
<Button variant="outline" onClick={() => navigate(`/repairs/${detail.id}`)} disabled={saving} className="flex-1">
|
||||
<Button variant="outline" onClick={leaveForm} disabled={saving} className="flex-1">
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={saving || !detail.can_edit} className="flex-1">
|
||||
|
||||
@@ -8,10 +8,32 @@
|
||||
* Endpoint: GET /api/v1/mechanic/repairs/{id}
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
||||
import { ChevronLeft, Pencil } from "lucide-react";
|
||||
import { getRepairDetail, type RepairDetailResponse } from "@/api/repairsFeed";
|
||||
|
||||
/**
|
||||
* Есть ли внутри приложения куда шагнуть назад. `idx` — номер записи, который
|
||||
* router держит в history.state: 0 значит, что экран открыли прямой ссылкой
|
||||
* (standalone-PWA стартует с пустым стеком) и navigate(-1) увёл бы наружу.
|
||||
* Под MemoryRouter (тесты) idx нет — там стартовую запись видно по ключу.
|
||||
*
|
||||
* Экспортируется: тем же вопросом задаётся форма правки перед уходом с неё.
|
||||
* Две копии проверки разъедутся при первой же правке.
|
||||
*/
|
||||
export function hasHistoryToGoBack(locationKey: string): boolean {
|
||||
const idx = (window.history.state as { idx?: number } | null)?.idx;
|
||||
return typeof idx === "number" ? idx > 0 : locationKey !== "default";
|
||||
}
|
||||
|
||||
/**
|
||||
* Бэкенд отдаёт в карточке `car_id` (null у ремонта «с улицы») — на нём держится
|
||||
* фолбэк возврата в историю машины. В общем типе ответа поля пока нет, а
|
||||
* api/repairsFeed.ts правится соседней задачей; объявляем локально, чтобы не
|
||||
* писать в чужой файл. Появится `car_id` в RepairDetailResponse — алиас убрать.
|
||||
*/
|
||||
type RepairDetail = RepairDetailResponse & { car_id?: number | null };
|
||||
|
||||
function fmt(n: number): string {
|
||||
return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }).format(Math.round(n));
|
||||
}
|
||||
@@ -27,7 +49,8 @@ function fmtDateTime(iso: string): string {
|
||||
export default function RepairDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<RepairDetailResponse | null>(null);
|
||||
const location = useLocation();
|
||||
const [data, setData] = useState<RepairDetail | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
@@ -61,12 +84,39 @@ export default function RepairDetailPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
// Карточку открывают из трёх мест: лента парка, история машины, карточка
|
||||
// авто — и каждое кладёт свой путь в `state.from` при переходе сюда (тем же
|
||||
// способом CarSelectPage передаёт данные в CreateRepairPage). Жёсткий
|
||||
// /repairs выбрасывал из истории машины в общую ленту: при разборе «что
|
||||
// делали за год» механик заново искал госномер на каждый ремонт.
|
||||
const fromState = location.state as { from?: string } | null;
|
||||
// Только внутренний путь: чужой state не должен уводить из приложения.
|
||||
// `//host` браузер считает ссылкой на другой сайт — такой «путь» отвергаем.
|
||||
const backTo =
|
||||
typeof fromState?.from === "string" &&
|
||||
fromState.from.startsWith("/") &&
|
||||
!fromState.from.startsWith("//")
|
||||
? fromState.from
|
||||
: null;
|
||||
|
||||
// Порядок: явный `from`; иначе шаг назад по стеку; иначе (прямая ссылка,
|
||||
// стека нет) — история этой машины: «что с ней делали» ближе к тому, зачем
|
||||
// открывали карточку, чем лента всего парка. У ремонта «с улицы» машины
|
||||
// парка нет — там остаётся лента. Возврат не наращивает стек, поэтому
|
||||
// переходы по адресу идут через replace.
|
||||
const goBack = () => {
|
||||
if (backTo) navigate(backTo, { replace: true });
|
||||
else if (hasHistoryToGoBack(location.key)) navigate(-1);
|
||||
else if (data?.car_id != null) navigate(`/repairs/history/${data.car_id}`, { replace: true });
|
||||
else navigate("/repairs", { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 z-10 border-b bg-background/95 backdrop-blur">
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<button
|
||||
onClick={() => navigate("/repairs")}
|
||||
onClick={goBack}
|
||||
className="rounded-full p-2 hover:bg-accent"
|
||||
aria-label="Назад"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* «История ремонта» (запрос механиков, задача 4439).
|
||||
*
|
||||
* Один компонент на два роута:
|
||||
* /repairs/history — поиск машины по госномеру;
|
||||
* /repairs/history/:carId — история ремонтов конкретной машины.
|
||||
*
|
||||
* Лента берётся тем же эндпоинтом, что и общая (`GET repairs`), только с
|
||||
* фильтром `car_id`, поэтому курсорная пагинация и порядок `created_at DESC`
|
||||
* работают как на главном экране раздела.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { HTTPError } from "ky";
|
||||
import { ChevronLeft, ChevronRight, Search, Wrench } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getCarContext, searchCars, type CarBrief } from "@/api/repairsCreate";
|
||||
import { getRepairsFeed, type FeedItem, type FeedResponse } from "@/api/repairsFeed";
|
||||
import { FEED_PAGE_SIZE } from "@/hooks/useRepairsFeed";
|
||||
import { fmtMoney, pluralWorks, toLocalDateKey } from "@/lib/repairsFeed";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** ДД.ММ.ГГГГ. Через toLocalDateKey — чтобы день совпадал с группировкой ленты (VLA). */
|
||||
function fmtDate(iso: string): string {
|
||||
const [y, m, d] = toLocalDateKey(iso).split("-");
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
export default function RepairHistoryPage() {
|
||||
const { carId } = useParams<{ carId?: string }>();
|
||||
const id = carId ? Number(carId) : NaN;
|
||||
return Number.isFinite(id) && id > 0 ? <CarHistory carId={id} /> : <PlateSearch />;
|
||||
}
|
||||
|
||||
// ── Поиск машины по госномеру ──────────────────────────────────────────────
|
||||
// Debounce 200 мс и тот же searchCars, что в CarSelectPage (релевантность по
|
||||
// номеру считает backend). Отличий два: ищем и по машинам, выведенным из парка
|
||||
// (Activity из 1С снялась, а ремонты никуда не делись — в осмотрах такая машина
|
||||
// видна, и в истории должна быть), но БЕЗ проданных — парк ими не занимается;
|
||||
// и не ходим в сеть, пока номер короче двух символов — на одном символе выдача
|
||||
// = почти весь парк, а тянуть его целиком незачем.
|
||||
|
||||
const MIN_QUERY_LEN = 2;
|
||||
|
||||
// Набранный номер переживает уход в историю машины и возврат назад: механик
|
||||
// сравнивает две похожие машины и не должен набирать номер заново на каждое
|
||||
// сравнение. Живёт в памяти вкладки — после перезапуска приложения поиск
|
||||
// открывается чистым.
|
||||
let lastQuery = "";
|
||||
|
||||
function PlateSearch() {
|
||||
const navigate = useNavigate();
|
||||
const [q, setQ] = useState(lastQuery);
|
||||
const [cars, setCars] = useState<CarBrief[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Ошибка — флаг, а не текст: сообщение ky английское и про HTTP, механику
|
||||
// оно ничего не объясняет.
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [retryTick, setRetryTick] = useState(0);
|
||||
|
||||
const query = q.trim();
|
||||
const tooShort = query.length < MIN_QUERY_LEN;
|
||||
|
||||
const onQueryChange = (value: string) => {
|
||||
lastQuery = value;
|
||||
setQ(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (tooShort) {
|
||||
setCars([]);
|
||||
setLoading(false);
|
||||
setFailed(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setFailed(false);
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const list = await searchCars(query, { includeInactive: true });
|
||||
if (!cancelled) setCars(list);
|
||||
} catch {
|
||||
// Выдачу прошлого запроса гасим: иначе под сообщением об ошибке
|
||||
// остаются кликабельные машины, и тап уводит в историю не той машины.
|
||||
if (!cancelled) {
|
||||
setCars([]);
|
||||
setFailed(true);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}, 200); // debounce
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [query, tooShort, retryTick]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate("/")} className="-ml-2" aria-label="Назад">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-base font-semibold">История ремонта</h1>
|
||||
</div>
|
||||
<div className="px-3 pb-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Госномер…"
|
||||
value={q}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
className="h-12 pl-10 text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Ветки взаимоисключающие: «сеть отвалилась» и «такой машины нет» —
|
||||
разные ответы, и рисовать их вместе нельзя. */}
|
||||
<main className="flex-1 container max-w-2xl py-3">
|
||||
{tooShort ? (
|
||||
<div className="flex flex-col items-center justify-center text-center py-16 px-4 space-y-3">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold">Найдите машину по госномеру</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
Введите хотя бы два символа — например, 123 или В45.
|
||||
</p>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-14 rounded-lg bg-muted animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : failed ? (
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm space-y-3">
|
||||
<p>
|
||||
Не удалось выполнить поиск — похоже, нет связи. Это сбой сети, а не
|
||||
«машины нет»: попробуйте ещё раз.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setRetryTick((n) => n + 1)}>
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
) : cars.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center text-center py-16 px-4 space-y-3">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold">Ничего не нашли по такому номеру</h2>
|
||||
{/* Текст обязан совпадать с фильтром бэкенда: выведенные из парка
|
||||
ищутся, проданные — нет. Иначе механик с опечаткой в номере
|
||||
сделает неверный вывод о том, есть ли история вообще. */}
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
Проверьте номер — проданные машины в истории не ищутся.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{cars.map((c) => (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/repairs/history/${c.id}`)}
|
||||
className="w-full flex items-center gap-3 rounded-lg border bg-card hover:bg-accent/40 active:bg-accent/60 transition-colors p-4 text-left"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base font-semibold font-mono">{c.plate}</div>
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{[c.brand, c.model].filter(Boolean).join(" ")}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── История ремонтов машины ────────────────────────────────────────────────
|
||||
|
||||
function CarHistory({ carId }: { carId: number }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Экран открывают и по прямой ссылке: из карточки авто, а в standalone-PWA
|
||||
// приложение может и перезапуститься прямо на нём. Тогда идти назад некуда
|
||||
// (key === "default" — первая запись в стеке), и navigate(-1) молча ничего
|
||||
// не делает — механик заперт. В этом случае уводим на поиск по номеру.
|
||||
const goBack = () => {
|
||||
if (location.key === "default") navigate("/repairs/history", { replace: true });
|
||||
else navigate(-1);
|
||||
};
|
||||
|
||||
// Карточка авто нужна ради заголовка: если ремонтов ноль, взять госномер
|
||||
// из ленты неоткуда.
|
||||
const carQuery = useQuery({
|
||||
queryKey: ["car-context", carId],
|
||||
queryFn: () => getCarContext(carId),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const feed = useInfiniteQuery<FeedResponse>({
|
||||
queryKey: ["repairs-feed", "car", carId],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getRepairsFeed({
|
||||
limit: FEED_PAGE_SIZE,
|
||||
cursor: (pageParam as string | null) ?? null,
|
||||
car_id: carId,
|
||||
// Превью в строках истории нет — не заставляем backend ходить в
|
||||
// хранилище за ссылками на фото.
|
||||
with_photos: false,
|
||||
}),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false, // свой visibilitychange ниже — как в useRepairsFeed
|
||||
});
|
||||
|
||||
const items: FeedItem[] = feed.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
const { hasNextPage, isFetchingNextPage, fetchNextPage, refetch: refetchFeed } = feed;
|
||||
const { refetch: refetchCar } = carQuery;
|
||||
|
||||
// Обновление руками и по возврату в приложение — как в ленте парка
|
||||
// (RepairsFeedPage + useRepairsFeed): свой visibilitychange вместо
|
||||
// refetchOnWindowFocus. Заголовок перезапрашиваем вместе с лентой — после
|
||||
// сбоя сети госномера на экране нет.
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetchFeed();
|
||||
void refetchCar();
|
||||
}, [refetchFeed, refetchCar]);
|
||||
|
||||
useEffect(() => {
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") handleRefresh();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => document.removeEventListener("visibilitychange", onVisible);
|
||||
}, [handleRefresh]);
|
||||
|
||||
// Ретраи ky лежат поверх ретраев react-query, поэтому до ошибки экран может
|
||||
// молчать минутами. Через 10 секунд говорим, что всё ещё грузим.
|
||||
const [slow, setSlow] = useState(false);
|
||||
const feedLoading = feed.isLoading;
|
||||
useEffect(() => {
|
||||
if (!feedLoading) return;
|
||||
const t = setTimeout(() => setSlow(true), 10_000);
|
||||
// Сброс — в уборке: загрузка кончилась, признак «долго» больше не наш.
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
setSlow(false);
|
||||
};
|
||||
}, [feedLoading]);
|
||||
|
||||
// Догрузка страниц — как в ленте парка (IntersectionObserver, запас 200px).
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el || !hasNextPage) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const car = carQuery.data;
|
||||
const first = items[0];
|
||||
// Ссылку на историю открывают из карточки авто и присылают в чат — carId
|
||||
// бывает битым. Госномер тогда взять неоткуда: слово «Машина» стояло на его
|
||||
// месте и читалось как номер («все ремонты Машина»).
|
||||
const plate = car?.plate ?? first?.car_plate ?? null;
|
||||
const carGone = carQuery.error instanceof HTTPError && carQuery.error.response.status === 404;
|
||||
const model = car
|
||||
? [car.brand, car.model].filter(Boolean).join(" ")
|
||||
: [first?.car_make, first?.car_model].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<Button variant="ghost" size="sm" onClick={goBack} className="-ml-2" aria-label="Назад">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Моноширинный — только для настоящего госномера. */}
|
||||
<h1 className={cn("text-base font-semibold truncate", plate && "font-mono")}>
|
||||
{plate ?? (carGone ? "Машина не найдена" : "История ремонта")}
|
||||
</h1>
|
||||
{plate && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
История ремонта{model ? ` · ${model}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 container max-w-2xl py-3 space-y-2">
|
||||
{/* Обновить — как в ленте парка: экран держат открытым, а ремонт мог
|
||||
записать другой механик. */}
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={feed.isFetching}
|
||||
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{feed.isFetching ? "Обновляем…" : "Обновить"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{feed.isLoading && (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-28 rounded-lg bg-muted animate-pulse" />
|
||||
))}
|
||||
{slow && (
|
||||
<p className="text-center text-xs text-muted-foreground py-2">
|
||||
Сеть отвечает медленно — история ещё грузится.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feed.isError && !feed.isLoading && (
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm space-y-3">
|
||||
<p>Не удалось загрузить историю ремонтов — похоже, нет связи.</p>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={feed.isFetching}>
|
||||
{feed.isFetching ? "Обновляем…" : "Повторить"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{carGone && !feed.isLoading && !feed.isError && items.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center text-center py-16 px-4 space-y-3">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold">Такой машины нет</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
Ссылка ведёт на машину, которой нет в базе. Найдите её по госномеру.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate("/repairs/history", { replace: true })}
|
||||
>
|
||||
Искать по номеру
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ждём и карточку авто: иначе на битой ссылке сначала мигнёт
|
||||
«ремонтов пока нет», и только потом придёт 404. */}
|
||||
{!feed.isLoading && !feed.isError && !carQuery.isLoading && !carGone && items.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center text-center py-16 px-4 space-y-3">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<Wrench className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold">У этой машины ремонтов пока нет</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
{plate
|
||||
? `Здесь появятся все ремонты ${plate}, как только их запишут.`
|
||||
: "Здесь появятся ремонты этой машины, как только их запишут."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<HistoryRow item={item} onOpen={() => navigate(`/repairs/${item.id}`)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} aria-hidden="true" />
|
||||
{isFetchingNextPage && (
|
||||
<div className="text-center text-xs text-muted-foreground py-4">Загружаем ещё…</div>
|
||||
)}
|
||||
{!hasNextPage && items.length > 0 && !isFetchingNextPage && (
|
||||
<div className="text-center text-xs text-muted-foreground py-4">Это вся история.</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryRow({ item, onOpen }: { item: FeedItem; onOpen: () => void }) {
|
||||
const model = [item.car_make, item.car_model].filter(Boolean).join(" ");
|
||||
const works = item.works_preview.map((w) => w.name);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="w-full flex items-center gap-3 rounded-lg border bg-card hover:bg-accent/40 active:bg-accent/60 transition-colors text-left p-4"
|
||||
>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-base font-semibold tabular-nums">{fmtDate(item.created_at)}</span>
|
||||
<span className="text-sm font-semibold tabular-nums flex-shrink-0">
|
||||
{fmtMoney(item.works_total || 0)} ₽
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
<span className="font-mono">{item.car_plate}</span>
|
||||
{model && ` · ${model}`}
|
||||
</div>
|
||||
{/* Пробег у старых ремонтов не заполняли — пишем это словами, «0 км» соврало бы. */}
|
||||
<div className="text-sm">
|
||||
{item.mileage != null ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">Пробег: </span>
|
||||
<span className="font-mono">{fmtMoney(item.mileage)} км</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Пробег не записан</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm leading-snug">
|
||||
{works.length > 0 ? (
|
||||
<>
|
||||
{works.join(" · ")}
|
||||
{item.works_extra > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· +{item.works_extra} {pluralWorks(item.works_extra)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Работы не указаны</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
* Пустая лента — плейсхолдер «В парке пока нет ремонтов» (У41).
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronLeft, Plus, Wrench } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -19,6 +19,7 @@ import { groupByLocalDay, formatDayLabel, fmtMoney, pluralRepairs, pluralWorks }
|
||||
|
||||
export default function RepairsFeedPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const {
|
||||
items,
|
||||
isLoading,
|
||||
@@ -66,7 +67,11 @@ export default function RepairsFeedPage() {
|
||||
}, [refetch]);
|
||||
|
||||
const handleAdd = () => navigate("/repairs/new");
|
||||
const handleOpen = (item: FeedItem) => navigate(`/repairs/${item.id}`);
|
||||
// Куда вернуть механика из карточки ремонта: сюда же. Без этого признака
|
||||
// «Назад» в карточке гадает по стеку истории (см. RepairDetailPage).
|
||||
const backHere = `${location.pathname}${location.search}`;
|
||||
const handleOpen = (item: FeedItem) =>
|
||||
navigate(`/repairs/${item.id}`, { state: { from: backHere } });
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
|
||||
Reference in New Issue
Block a user