diff --git a/driver-pwa/frontend/.env.example b/driver-pwa/frontend/.env.example
index 2e931cc..926f96b 100644
--- a/driver-pwa/frontend/.env.example
+++ b/driver-pwa/frontend/.env.example
@@ -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-ФЗ и реальная проводка в леджере.
diff --git a/driver-pwa/frontend/README.md b/driver-pwa/frontend/README.md
new file mode 100644
index 0000000..2439219
--- /dev/null
+++ b/driver-pwa/frontend/README.md
@@ -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-ФЗ не пробивается.
diff --git a/driver-pwa/frontend/dev-proxy.test.ts b/driver-pwa/frontend/dev-proxy.test.ts
new file mode 100644
index 0000000..95d21f9
--- /dev/null
+++ b/driver-pwa/frontend/dev-proxy.test.ts
@@ -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");
+ });
+});
diff --git a/driver-pwa/frontend/src/App.tsx b/driver-pwa/frontend/src/App.tsx
index 55d0d3b..78bb616 100644
--- a/driver-pwa/frontend/src/App.tsx
+++ b/driver-pwa/frontend/src/App.tsx
@@ -1,6 +1,8 @@
import type { ReactNode } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { useAuth } from "@/store/auth";
+import { useStaff } from "@/store/staff";
+import { ImpersonationBar } from "@/components/ImpersonationBar";
import { LoginPage } from "@/pages/LoginPage";
import { TgWaitPage } from "@/pages/TgWaitPage";
import { OnboardingPage } from "@/pages/OnboardingPage";
@@ -8,23 +10,41 @@ 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";
function RequireAuth({ children }: { children: ReactNode }) {
const token = useAuth((s) => s.token);
return token ? <>{children}> : ;
}
+/** Отладка существует только для сотрудника: либо он вошёл учёткой CRM, либо
+ * прямо сейчас смотрит от лица водителя. Обычному водителю адрес /debug ничего
+ * не даёт — его уводит на главную. */
+function RequireStaff({ children }: { children: ReactNode }) {
+ const impersonated = useAuth((s) => s.impersonated);
+ const staffToken = useStaff((s) => s.token);
+ return impersonated || staffToken ? <>{children}> : ;
+}
+
export default function App() {
return (
+ {/* Плашка живёт над маршрутами: чей экран открыт, видно на каждом из них,
+ а не только там, где про неё вспомнили. Сама решает, показываться ли. */}
+ } />
+ } />
} />
} />
} />
} />
} />
+ } />
} />
+ } />
} />
diff --git a/driver-pwa/frontend/src/api/client.ts b/driver-pwa/frontend/src/api/client.ts
index 6e30f47..eaabc94 100644
--- a/driver-pwa/frontend/src/api/client.ts
+++ b/driver-pwa/frontend/src/api/client.ts
@@ -1,7 +1,11 @@
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();
export const api: KyInstance = ky.create({
prefixUrl: API_BASE,
@@ -10,10 +14,24 @@ 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())),
+ });
+ },
+ ],
beforeError: [
(error) => {
if (error.response?.status === 401) useAuth.getState().clear();
diff --git a/driver-pwa/frontend/src/api/driver.ts b/driver-pwa/frontend/src/api/driver.ts
index fb59e25..0b5529b 100644
--- a/driver-pwa/frontend/src/api/driver.ts
+++ b/driver-pwa/frontend/src/api/driver.ts
@@ -35,6 +35,9 @@ export const BucketsSchema = z.object({
});
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() });
@@ -99,3 +102,10 @@ 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(),
+);
diff --git a/driver-pwa/frontend/src/api/staff.ts b/driver-pwa/frontend/src/api/staff.ts
new file mode 100644
index 0000000..3f67c32
--- /dev/null
+++ b/driver-pwa/frontend/src/api/staff.ts
@@ -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();
+
+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;
+
+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());
diff --git a/driver-pwa/frontend/src/components/DriverPicker.tsx b/driver-pwa/frontend/src/components/DriverPicker.tsx
new file mode 100644
index 0000000..741aa43
--- /dev/null
+++ b/driver-pwa/frontend/src/components/DriverPicker.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+ const [busyId, setBusyId] = useState(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 (
+
+
+
+ setQ(e.target.value)}
+ placeholder="ФИО, телефон или id"
+ className="w-full text-base bg-transparent outline-none"
+ />
+
+
+ {loading ? (
+
+ ) : list.length === 0 ? (
+
Никого не нашли
+ ) : (
+
+ {list.map((d) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/driver-pwa/frontend/src/components/ImpersonationBar.tsx b/driver-pwa/frontend/src/components/ImpersonationBar.tsx
new file mode 100644
index 0000000..e14e74c
--- /dev/null
+++ b/driver-pwa/frontend/src/components/ImpersonationBar.tsx
@@ -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 (
+
+ {/* Пока идёт обкатка, сотруднику надо попадать внутрь без карточки
+ водителя и без бота — учёткой CRM. Ссылка неприметная намеренно. */}
+
>
) : (
<>
diff --git a/driver-pwa/frontend/src/pages/MockPayPage.test.tsx b/driver-pwa/frontend/src/pages/MockPayPage.test.tsx
new file mode 100644
index 0000000..1188071
--- /dev/null
+++ b/driver-pwa/frontend/src/pages/MockPayPage.test.tsx
@@ -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(
+
+
+ } />
+ экран оплаты} />
+
+
+ );
+
+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();
+ });
+});
diff --git a/driver-pwa/frontend/src/pages/MockPayPage.tsx b/driver-pwa/frontend/src/pages/MockPayPage.tsx
new file mode 100644
index 0000000..69d254c
--- /dev/null
+++ b/driver-pwa/frontend/src/pages/MockPayPage.tsx
@@ -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(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 (
+
+
Учебная оплата недоступна
+
Этот экран работает только при просмотре сотрудником.
+
+
+ );
+ }
+
+ return (
+
+
+
Учебная оплата
+
+ Банка в этом платеже нет: ни списания, ни чека, ни проводки. Выберите, что
+ «ответит» банк — приложение покажет ровно то же, что увидел бы водитель.
+
+
+ {OUTCOMES.map((o) => (
+
+ ))}
+
+
Заказ: {orderId}
+
+ );
+}
diff --git a/driver-pwa/frontend/src/pages/PaymentPage.tsx b/driver-pwa/frontend/src/pages/PaymentPage.tsx
index e59acbb..0c91869 100644
--- a/driver-pwa/frontend/src/pages/PaymentPage.tsx
+++ b/driver-pwa/frontend/src/pages/PaymentPage.tsx
@@ -22,6 +22,9 @@ export function PaymentPage() {
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, карта — форму банка
@@ -106,7 +109,14 @@ export function PaymentPage() {
{st.total != null &&