feat(pwa): вход для сотрудника, просмотр от лица водителя и вкладка «Отладка»
Приложение обкатывается на боевом контуре, поэтому интерфейс надо проверять на живых данных — а живые данные лежат у настоящих водителей. * «Вход для сотрудника» (/dev) — логин учётки CRM, поддержан второй шаг 2FA; дальше поиск водителя по ФИО/телефону/id и просмотр его экранов. * Плашка «Смотрю как …» над всеми экранами: интерфейс в этом режиме неотличим от водительского, и чужой баланс легко принять за свой. * Вкладка «Отладка» (/debug, водителю недоступна): смена водителя, driver_id, режим, срок токена, адрес API, коммит и время сборки, журнал последних 40 ответивших запросов, сброс кэша сервис-воркера. * Пополнение под просмотром идёт в учебный контур: экран /mock-pay/:orderId выбирает, что «ответит» банк, а настоящий экран оплаты разбирает эти статусы своим боевым кодом. Ни рубля, ни чека 54-ФЗ. Заодно доезжает незакоммиченная работа прошлой сессии: дев-прокси в vite.config.ts, его тест и разобранный .env.example. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,147 @@
|
|||||||
|
# Премиум Водитель — 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`.
|
||||||
|
|
||||||
|
## Обкатка: кто может войти и как смотреть чужой экран
|
||||||
|
|
||||||
|
Приложение выложено на боевой домен, а обкатывается интерфейс. Поэтому на время
|
||||||
|
обкатки включены две вещи.
|
||||||
|
|
||||||
|
### 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
|
import { useStaff } from "@/store/staff";
|
||||||
|
import { ImpersonationBar } from "@/components/ImpersonationBar";
|
||||||
import { LoginPage } from "@/pages/LoginPage";
|
import { LoginPage } from "@/pages/LoginPage";
|
||||||
import { TgWaitPage } from "@/pages/TgWaitPage";
|
import { TgWaitPage } from "@/pages/TgWaitPage";
|
||||||
import { OnboardingPage } from "@/pages/OnboardingPage";
|
import { OnboardingPage } from "@/pages/OnboardingPage";
|
||||||
@@ -8,23 +10,41 @@ import { BalancePage } from "@/pages/BalancePage";
|
|||||||
import { TopupPage } from "@/pages/TopupPage";
|
import { TopupPage } from "@/pages/TopupPage";
|
||||||
import { PaymentPage } from "@/pages/PaymentPage";
|
import { PaymentPage } from "@/pages/PaymentPage";
|
||||||
import { HistoryPage } from "@/pages/HistoryPage";
|
import { HistoryPage } from "@/pages/HistoryPage";
|
||||||
|
import { DevLoginPage } from "@/pages/DevLoginPage";
|
||||||
|
import { DebugPage } from "@/pages/DebugPage";
|
||||||
|
import { MockPayPage } from "@/pages/MockPayPage";
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: ReactNode }) {
|
function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
const token = useAuth((s) => s.token);
|
const token = useAuth((s) => s.token);
|
||||||
return token ? <>{children}</> : <Navigate to="/login" replace />;
|
return token ? <>{children}</> : <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 />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-md min-h-full px-4 pt-3 pb-8">
|
<div className="mx-auto max-w-md min-h-full px-4 pt-3 pb-8">
|
||||||
|
{/* Плашка живёт над маршрутами: чей экран открыт, видно на каждом из них,
|
||||||
|
а не только там, где про неё вспомнили. Сама решает, показываться ли. */}
|
||||||
|
<ImpersonationBar />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/dev" element={<DevLoginPage />} />
|
||||||
<Route path="/tg-wait" element={<TgWaitPage />} />
|
<Route path="/tg-wait" element={<TgWaitPage />} />
|
||||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||||
<Route path="/" element={<RequireAuth><BalancePage /></RequireAuth>} />
|
<Route path="/" element={<RequireAuth><BalancePage /></RequireAuth>} />
|
||||||
<Route path="/topup" element={<RequireAuth><TopupPage /></RequireAuth>} />
|
<Route path="/topup" element={<RequireAuth><TopupPage /></RequireAuth>} />
|
||||||
<Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></RequireAuth>} />
|
<Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></RequireAuth>} />
|
||||||
|
<Route path="/mock-pay/:orderId" element={<RequireAuth><MockPayPage /></RequireAuth>} />
|
||||||
<Route path="/history" element={<RequireAuth><HistoryPage /></RequireAuth>} />
|
<Route path="/history" element={<RequireAuth><HistoryPage /></RequireAuth>} />
|
||||||
|
<Route path="/debug" element={<RequireAuth><RequireStaff><DebugPage /></RequireStaff></RequireAuth>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import ky, { type KyInstance } from "ky";
|
import ky, { type KyInstance } from "ky";
|
||||||
|
import { pushEntry, shortPath } from "@/lib/reqlog";
|
||||||
import { useAuth } from "@/store/auth";
|
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>();
|
||||||
|
|
||||||
export const api: KyInstance = ky.create({
|
export const api: KyInstance = ky.create({
|
||||||
prefixUrl: API_BASE,
|
prefixUrl: API_BASE,
|
||||||
@@ -10,10 +14,24 @@ export const api: KyInstance = ky.create({
|
|||||||
hooks: {
|
hooks: {
|
||||||
beforeRequest: [
|
beforeRequest: [
|
||||||
(request) => {
|
(request) => {
|
||||||
|
started.set(request, performance.now());
|
||||||
const token = useAuth.getState().token;
|
const token = useAuth.getState().token;
|
||||||
if (token) request.headers.set("Authorization", `Bearer ${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())),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
],
|
||||||
beforeError: [
|
beforeError: [
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401) useAuth.getState().clear();
|
if (error.response?.status === 401) useAuth.getState().clear();
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export const BucketsSchema = z.object({
|
|||||||
});
|
});
|
||||||
export const TopupSchema = z.object({
|
export const TopupSchema = z.object({
|
||||||
order_id: z.string(), pay_url: z.string(), commission: z.number(), total: z.number(),
|
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 PaymentStateSchema = z.object({ order_id: z.string(), status: z.string() });
|
||||||
export const CommissionSchema = z.object({ rate: z.number() });
|
export const CommissionSchema = z.object({ rate: z.number() });
|
||||||
@@ -99,3 +102,10 @@ export const getPayments = async () =>
|
|||||||
PaymentsSchema.parse(await api.get("driver/payments").json());
|
PaymentsSchema.parse(await api.get("driver/payments").json());
|
||||||
export const getCommission = async () =>
|
export const getCommission = async () =>
|
||||||
CommissionSchema.parse(await api.get("driver/commission").json());
|
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(),
|
||||||
|
);
|
||||||
|
|||||||
@@ -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());
|
||||||
@@ -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 setSession = useAuth((s) => s.setSession);
|
||||||
|
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);
|
||||||
|
setSession(r.token, r.driver.id, { impersonated: true, driverName: 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-white">
|
||||||
|
<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 bg-ink text-cream 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,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,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,67 @@
|
|||||||
|
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 })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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, clear } = 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>
|
||||||
|
|
||||||
|
<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)" : "обычный вход водителя"} />
|
||||||
|
<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={() => { clear(); nav(staff.token ? "/dev" : "/login"); }}
|
||||||
|
>
|
||||||
|
<LogOut size={16} /> {impersonated ? "Выйти из просмотра" : "Выйти"}
|
||||||
|
</button>
|
||||||
|
{staff.token && (
|
||||||
|
<button className="text-muted text-xs w-full mt-4"
|
||||||
|
onClick={() => { clear(); staff.clear(); nav("/login"); }}>
|
||||||
|
Выйти и из учётки сотрудника
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Подтвердить" }));
|
||||||
|
expect(staffApi.staffLogin2fa).toHaveBeenCalledWith("partial", "123456");
|
||||||
|
expect(useStaff.getState().token).toBe("staff-tok");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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() {
|
||||||
|
if (!partial || code.length < 6 || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const r = await staffLogin2fa(partial, code);
|
||||||
|
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
|
||||||
|
inputMode="numeric"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
|
placeholder="000000"
|
||||||
|
className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-white"
|
||||||
|
/>
|
||||||
|
<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-white"
|
||||||
|
/>
|
||||||
|
<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-white"
|
||||||
|
/>
|
||||||
|
<button className="btn-primary" disabled={busy || !username || !password} onClick={submit}>
|
||||||
|
{busy ? <Spinner /> : "Войти"}
|
||||||
|
</button>
|
||||||
|
<button className="btn-ghost mt-2" onClick={() => nav("/login")}>Я водитель</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -101,6 +101,11 @@ export function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
<p className="text-muted text-xs mt-3">Telegram подтвердит ваш номер автоматически.</p>
|
<p className="text-muted text-xs mt-3">Telegram подтвердит ваш номер автоматически.</p>
|
||||||
<button className="btn-ghost mt-4" onClick={() => setMode("code")}>Войти по коду</button>
|
<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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ export function PaymentPage() {
|
|||||||
const st = (useLocation().state as
|
const st = (useLocation().state as
|
||||||
{ payUrl?: string; total?: number; bucket?: string; base?: number; method?: string } | null) ?? {};
|
{ payUrl?: string; total?: number; bucket?: string; base?: number; method?: string } | null) ?? {};
|
||||||
const { data, lastFailed, failStreak, refetch } = usePaymentStatus(orderId);
|
const { data, lastFailed, failStreak, refetch } = usePaymentStatus(orderId);
|
||||||
|
// Учебный платёж узнаётся по номеру заказа, а не по state роутера: state
|
||||||
|
// теряется при перезагрузке, а префикс — нет (см. backend mock_pay).
|
||||||
|
const mock = orderId.startsWith("mock-");
|
||||||
const status = data?.status ?? "";
|
const status = data?.status ?? "";
|
||||||
const paid = status === "PAID";
|
const paid = status === "PAID";
|
||||||
// пока метод не передан в state — узнаём по ссылке: СБП отдаёт платёжку qr.nspk.ru, карта — форму банка
|
// пока метод не передан в state — узнаём по ссылке: СБП отдаёт платёжку qr.nspk.ru, карта — форму банка
|
||||||
@@ -106,7 +109,14 @@ export function PaymentPage() {
|
|||||||
{st.total != null && <p className="text-lg font-extrabold">{formatMoney(st.total)}</p>}
|
{st.total != null && <p className="text-lg font-extrabold">{formatMoney(st.total)}</p>}
|
||||||
{st.bucket && <p className="text-muted text-xs">{st.bucket}</p>}
|
{st.bucket && <p className="text-muted text-xs">{st.bucket}</p>}
|
||||||
</div>
|
</div>
|
||||||
{st.payUrl ? (
|
{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>
|
<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 mt-2">Откроется ваше приложение банка</p>
|
||||||
|
|||||||
@@ -41,7 +41,13 @@ export function TopupPage() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const r = await topup(bucket, base, method as "qr" | "card");
|
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 } });
|
// Учебный платёж (сотрудник смотрит от чужого лица): банка нет, платить
|
||||||
|
// негде — сразу на экран выбора исхода, дальше всё как у водителя.
|
||||||
|
if (r.mock) {
|
||||||
|
nav(`/mock-pay/${r.order_id}`, { state: { total: r.total, bucket, base, method } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base, method } });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// Текст с сервера, а не глухое «не удалось»: минимум СБП, потолок суммы и
|
// Текст с сервера, а не глухое «не удалось»: минимум СБП, потолок суммы и
|
||||||
// неизвестная категория объясняются словами, иначе экран молча не реагирует.
|
// неизвестная категория объясняются словами, иначе экран молча не реагирует.
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
interface AuthState {
|
/** Кем открыт сеанс: сам водитель или сотрудник, смотрящий от его лица.
|
||||||
|
* Признак приезжает из токена (claim `imp`), здесь он только продублирован для
|
||||||
|
* интерфейса — проверять его на бэкенде по этому полю нельзя и не нужно. */
|
||||||
|
export interface ViewAs {
|
||||||
|
impersonated: boolean;
|
||||||
|
/** ФИО водителя — показывать в плашке. У обычного входа неизвестно и не нужно. */
|
||||||
|
driverName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState extends ViewAs {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
driverId: number | null;
|
driverId: number | null;
|
||||||
setSession: (token: string, driverId: number) => void;
|
setSession: (token: string, driverId: number, view?: Partial<ViewAs>) => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,8 +22,16 @@ export const useAuth = create<AuthState>()(
|
|||||||
(set) => ({
|
(set) => ({
|
||||||
token: null,
|
token: null,
|
||||||
driverId: null,
|
driverId: null,
|
||||||
setSession: (token, driverId) => set({ token, driverId }),
|
impersonated: false,
|
||||||
clear: () => set({ token: null, driverId: null }),
|
driverName: null,
|
||||||
|
setSession: (token, driverId, view) =>
|
||||||
|
set({
|
||||||
|
token,
|
||||||
|
driverId,
|
||||||
|
impersonated: view?.impersonated ?? false,
|
||||||
|
driverName: view?.driverName ?? null,
|
||||||
|
}),
|
||||||
|
clear: () => set({ token: null, driverId: null, impersonated: false, driverName: null }),
|
||||||
}),
|
}),
|
||||||
{ name: "pp-driver-auth" }
|
{ name: "pp-driver-auth" }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сеанс СОТРУДНИКА — учётка CRM, которой берут просмотр от лица водителя.
|
||||||
|
*
|
||||||
|
* Хранится отдельно от водительского: это два разных токена с разным смыслом.
|
||||||
|
* «Перестать смотреть за водителя» обязано оставлять сотрудника залогиненным,
|
||||||
|
* иначе смена водителя каждый раз означала бы новый ввод пароля; и наоборот —
|
||||||
|
* `clear()` водительского сеанса не должен трогать сотрудника.
|
||||||
|
*/
|
||||||
|
interface StaffState {
|
||||||
|
token: string | null;
|
||||||
|
username: string | null;
|
||||||
|
setStaff: (token: string, username: string) => void;
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStaff = create<StaffState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
token: null,
|
||||||
|
username: null,
|
||||||
|
setStaff: (token, username) => set({ token, username }),
|
||||||
|
clear: () => set({ token: null, username: null }),
|
||||||
|
}),
|
||||||
|
{ name: "pp-driver-staff" }
|
||||||
|
)
|
||||||
|
);
|
||||||
+5
@@ -7,3 +7,8 @@ interface ImportMetaEnv {
|
|||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv;
|
readonly env: ImportMetaEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Подставляются на сборке (vite.config.ts → define). Нужны экрану отладки:
|
||||||
|
// показывают, какой именно бандл сейчас открыт.
|
||||||
|
declare const __BUILD_COMMIT__: string;
|
||||||
|
declare const __BUILD_TIME__: string;
|
||||||
|
|||||||
@@ -4,5 +4,5 @@
|
|||||||
"moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true,
|
"moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true,
|
||||||
"noEmit": true, "strict": 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,73 @@
|
|||||||
import { defineConfig } from "vite";
|
import { execSync } from "child_process";
|
||||||
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { VitePWA } from "vite-plugin-pwa";
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
export default defineConfig({
|
/**
|
||||||
plugins: [
|
* Что именно сейчас открыто — видно на вкладке «Отладка».
|
||||||
react(),
|
*
|
||||||
VitePWA({ registerType: "autoUpdate", manifest: false, includeAssets: ["icons/*.png"] }),
|
* Без этого «правка не приехала» и «приехала, но кэш старый» выглядят
|
||||||
],
|
* одинаково, а проверяются по-разному. Коммит берём из git; вне репозитория
|
||||||
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
|
* (сборка из архива, CI без .git) — честный «unknown», а не падение сборки.
|
||||||
server: { port: 5174 },
|
*/
|
||||||
|
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(),
|
||||||
|
VitePWA({ registerType: "autoUpdate", manifest: false, includeAssets: ["icons/*.png"] }),
|
||||||
|
],
|
||||||
|
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";
|
import path from "path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
// Те же подстановки, что и в боевой сборке (vite.config.ts): без них модуль с
|
||||||
|
// __BUILD_COMMIT__ падает в тестах на ReferenceError, хотя в приложении работает.
|
||||||
|
define: {
|
||||||
|
__BUILD_COMMIT__: JSON.stringify("test"),
|
||||||
|
__BUILD_TIME__: JSON.stringify("test"),
|
||||||
|
},
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
|
resolve: { alias: { "@": path.resolve(__dirname, "src") } },
|
||||||
test: { globals: true, environment: "jsdom", setupFiles: ["./src/test/setup.ts"] },
|
test: { globals: true, environment: "jsdom", setupFiles: ["./src/test/setup.ts"] },
|
||||||
|
|||||||
Reference in New Issue
Block a user