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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user