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,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>
);
}