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:
2026-08-18 13:15:02 +10:00
co-authored by Claude Opus 5
parent aff1a45006
commit ceec3db364
27 changed files with 1308 additions and 17 deletions
@@ -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>
);
}