feat(pwa): главный экран из двух половин — деньги сверху, выписка снизу
По образцу приложения вендора: баланс в парке и «зачисляется», под ними крупная кнопка «Пополнить», ниже — период с календарями, фильтры и список операций (аренда, пени, штрафы, пополнения). Разбивка по статьям переехала в шторку «Пополнить». Она нужна ровно в момент выбора, куда платить; всё остальное время это шесть строк, отодвигающих выписку за нижний край телефона. В шторке только те статьи, в которые платёж проводится. Экран суммы, придя из шторки, показывает выбранную категорию строкой, а не рядом чипов: второй выбор того же самого сбивает с толку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -119,3 +119,23 @@ export const mockPayOutcome = async (
|
|||||||
) => PaymentStateSchema.parse(
|
) => PaymentStateSchema.parse(
|
||||||
await api.post(`driver/mock-pay/${orderId}`, { json: { outcome } }).json(),
|
await api.post(`driver/mock-pay/${orderId}`, { json: { outcome } }).json(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const StatementSchema = z.object({
|
||||||
|
items: z.array(z.object({
|
||||||
|
date: z.string().nullable(),
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().default(""),
|
||||||
|
amount: z.number(),
|
||||||
|
kind: z.enum(["charges", "payments", "fines"]),
|
||||||
|
})),
|
||||||
|
from: z.string(),
|
||||||
|
to: z.string(),
|
||||||
|
kind: z.string(),
|
||||||
|
});
|
||||||
|
export type StatementKind = "all" | "charges" | "payments" | "fines";
|
||||||
|
|
||||||
|
/** Выписка по счёту за период. Даты — ISO (ГГГГ-ММ-ДД), как их понимает бэкенд. */
|
||||||
|
export const getStatement = async (from: string, to: string, kind: StatementKind) =>
|
||||||
|
StatementSchema.parse(await api.get("driver/statement", {
|
||||||
|
searchParams: { from, to, kind },
|
||||||
|
}).json());
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, type ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Шторка снизу — выбор, который не уводит с экрана.
|
||||||
|
*
|
||||||
|
* Отдельным экраном такой выбор стоит дороже, чем кажется: человек теряет из
|
||||||
|
* виду то, ради чего пришёл (баланс), и возвращается кнопкой «назад», которой на
|
||||||
|
* телефоне ещё надо попасть. Здесь выбор происходит поверх экрана, а закрывается
|
||||||
|
* тапом мимо, кнопкой и клавишей Escape.
|
||||||
|
*/
|
||||||
|
export function Sheet({ open, onClose, title, children }: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
// Фон не должен уезжать под шторкой: иначе список категорий листает страницу.
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center">
|
||||||
|
<button
|
||||||
|
aria-label="Закрыть"
|
||||||
|
className="absolute inset-0 bg-ink/40"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
className="relative w-full max-w-md bg-surface rounded-t-2xl pb-6 pt-2 max-h-[80vh] overflow-y-auto"
|
||||||
|
>
|
||||||
|
{/* Полоска-ручка: на телефоне она объясняет, что панель тянется, лучше любой подписи. */}
|
||||||
|
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-line" />
|
||||||
|
<p className="px-5 pb-2 text-sm font-bold">{title}</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getStatement, type StatementKind } from "@/api/driver";
|
||||||
|
import { formatDate, formatMoney } from "@/lib/format";
|
||||||
|
import { Spinner } from "@/components/Spinner";
|
||||||
|
|
||||||
|
const FILTERS: { key: StatementKind; label: string }[] = [
|
||||||
|
{ key: "all", label: "Все операции" },
|
||||||
|
{ key: "charges", label: "Начисления" },
|
||||||
|
{ key: "payments", label: "Пополнения" },
|
||||||
|
{ key: "fines", label: "Штрафы" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выписка по счёту за период — нижняя половина главного экрана.
|
||||||
|
*
|
||||||
|
* Это ответ на вопрос «за что списано», который иначе задают звонком в парк.
|
||||||
|
* Строки те же, по которым 1С считает долг: аренда, пени, штрафы, пополнения.
|
||||||
|
*/
|
||||||
|
export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
kind: StatementKind;
|
||||||
|
onFrom: (v: string) => void;
|
||||||
|
onTo: (v: string) => void;
|
||||||
|
onKind: (k: StatementKind) => void;
|
||||||
|
}) {
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ["statement", from, to, kind],
|
||||||
|
queryFn: () => getStatement(from, to, kind),
|
||||||
|
});
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
{/* Нативный date-input: календарь телефона знакомее любого нашего. */}
|
||||||
|
<input type="date" value={from} max={to} onChange={(e) => onFrom(e.target.value)}
|
||||||
|
aria-label="Начало периода"
|
||||||
|
className="card flex-1 px-3 py-2 text-xs bg-white" />
|
||||||
|
<span className="text-muted text-xs">—</span>
|
||||||
|
<input type="date" value={to} min={from} onChange={(e) => onTo(e.target.value)}
|
||||||
|
aria-label="Конец периода"
|
||||||
|
className="card flex-1 px-3 py-2 text-xs bg-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1.5 mb-3 overflow-x-auto">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
onClick={() => onKind(f.key)}
|
||||||
|
className={`shrink-0 px-3 py-2 rounded-xl2 text-xs border ${
|
||||||
|
f.key === kind ? "border-line bg-surface font-semibold" : "border-line text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-8"><Spinner /></div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<p className="text-muted text-sm mb-3">Не удалось загрузить операции</p>
|
||||||
|
<button className="btn-ghost" onClick={() => refetch()}>Повторить</button>
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="text-muted text-sm text-center py-8">Нет операций за выбранный период</p>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{items.map((op, idx) => (
|
||||||
|
<div key={`${op.date}-${idx}`}
|
||||||
|
className="flex justify-between items-start gap-3 py-3 border-b border-line last:border-0">
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm">{op.title}</span>
|
||||||
|
<span className="block text-muted text-xs">
|
||||||
|
{formatDate(op.date)}
|
||||||
|
{op.description ? ` · ${op.description}` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<b className={`shrink-0 ${op.amount < 0 ? "text-neg" : "text-pos"}`}>
|
||||||
|
{formatMoney(op.amount)}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,3 +30,21 @@ export function formatPhone(national10: string): string {
|
|||||||
if (d.length > 8) p.push("-" + d.slice(8, 10));
|
if (d.length > 8) p.push("-" + d.slice(8, 10));
|
||||||
return p.join("");
|
return p.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Дата человеку — ДД.ММ.ГГГГ. На проводе и в <input type="date"> остаётся ISO. */
|
||||||
|
export function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const [y, m, d] = iso.split("-");
|
||||||
|
return y && m && d ? `${d}.${m}.${y}` : iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO-дата на N дней назад от сегодня — для периода выписки по умолчанию. */
|
||||||
|
export function isoDaysAgo(days: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - days);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isoToday(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +1,37 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
// Ответ как у настоящего водителя: шесть статей 1С, итог отдельным числом.
|
// Ответ как у настоящего водителя: статьи 1С, итог отдельным числом.
|
||||||
vi.mock("@/api/driver", () => ({
|
vi.mock("@/api/driver", () => ({
|
||||||
getBalance: vi.fn(async () => ({
|
getBalance: vi.fn(async () => ({
|
||||||
accounts: [
|
accounts: [
|
||||||
{ bucket: "Долг аренда", balance: -3050, payable: true },
|
{ bucket: "Долг аренда", balance: -3050, payable: true },
|
||||||
{ bucket: "Долг по штрафам", balance: -5156, payable: true },
|
{ bucket: "Долг по штрафам", balance: -5156, payable: true },
|
||||||
{ bucket: "Прочее", balance: -770, payable: true },
|
{ bucket: "Страховка", balance: 200, payable: false },
|
||||||
{ bucket: "Страховка", balance: 0, payable: false },
|
|
||||||
],
|
],
|
||||||
total: -8976.25,
|
total: -8976.25,
|
||||||
synced_at: null,
|
synced_at: null,
|
||||||
pending_topup: 0,
|
pending_topup: 1500,
|
||||||
|
})),
|
||||||
|
getStatement: vi.fn(async () => ({
|
||||||
|
items: [
|
||||||
|
{ date: "2026-08-17", title: "Аренда автомобиля", description: "ТС А947УТ125",
|
||||||
|
amount: -2600, kind: "charges" },
|
||||||
|
{ date: "2026-08-17", title: "Пополнение баланса", description: "", amount: 1000,
|
||||||
|
kind: "payments" },
|
||||||
|
],
|
||||||
|
from: "2026-08-11", to: "2026-08-18", kind: "all",
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { BalancePage } from "./BalancePage";
|
import { BalancePage } from "./BalancePage";
|
||||||
|
import * as driver from "@/api/driver";
|
||||||
|
|
||||||
function wrap() {
|
function wrap() {
|
||||||
const qc = new QueryClient();
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<MemoryRouter><BalancePage /></MemoryRouter>
|
<MemoryRouter><BalancePage /></MemoryRouter>
|
||||||
@@ -29,32 +39,53 @@ function wrap() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("BalancePage", () => {
|
describe("главный экран", () => {
|
||||||
it("итог берётся с сервера, а не складывается из строк", async () => {
|
it("сверху деньги: итог с сервера и строка «зачисляется»", async () => {
|
||||||
wrap();
|
wrap();
|
||||||
expect(await screen.findByText("Долг аренда")).toBeInTheDocument();
|
// Сумма статей дала бы −8 006, а в CRM у водителя −8 976,25. Показываем её.
|
||||||
// Сумма статей дала бы −8 976, а в CRM у водителя −8 976,25. Показываем её.
|
expect(await screen.findByText("−8 976,25 ₽")).toBeInTheDocument();
|
||||||
expect(screen.getByText("−8 976,25 ₽")).toBeInTheDocument();
|
expect(screen.getByText("Зачисляется")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: /Пополнить/i })).toBeInTheDocument();
|
expect(screen.getByText("1 500 ₽")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("статья, в которую платить нечем, не притворяется кнопкой", async () => {
|
it("снизу выписка за период", async () => {
|
||||||
wrap();
|
wrap();
|
||||||
await screen.findByText("Страховка");
|
expect(await screen.findByText("Аренда автомобиля")).toBeInTheDocument();
|
||||||
const buckets = screen.getAllByRole("button").map((b) => b.textContent ?? "");
|
expect(screen.getAllByText(/17\.08\.2026/).length).toBe(2); // обе операции того дня
|
||||||
expect(buckets.some((t) => t.includes("Долг аренда"))).toBe(true);
|
expect(screen.getByRole("button", { name: "Все операции" })).toBeInTheDocument();
|
||||||
expect(buckets.some((t) => t.includes("Страховка"))).toBe(false);
|
expect(driver.getStatement).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows an error state when balance fails to load", async () => {
|
it("фильтр перезапрашивает операции выбранного вида", async () => {
|
||||||
const driver = await import("@/api/driver");
|
wrap();
|
||||||
(driver.getBalance as any).mockRejectedValueOnce(new Error("boom"));
|
await userEvent.click(await screen.findByRole("button", { name: "Штрафы" }));
|
||||||
// re-render fresh
|
const calls = vi.mocked(driver.getStatement).mock.calls;
|
||||||
const { QueryClient, QueryClientProvider } = await import("@tanstack/react-query");
|
expect(calls[calls.length - 1][2]).toBe("fines");
|
||||||
const { MemoryRouter } = await import("react-router-dom");
|
});
|
||||||
const { render, screen } = await import("@testing-library/react");
|
|
||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
it("категории живут в шторке «Пополнить», а не на экране", async () => {
|
||||||
render(<QueryClientProvider client={qc}><MemoryRouter><BalancePage /></MemoryRouter></QueryClientProvider>);
|
wrap();
|
||||||
expect(await screen.findByText(/Не удалось загрузить/i)).toBeInTheDocument();
|
await screen.findByText("−8 976,25 ₽");
|
||||||
|
expect(screen.queryByText("Долг аренда")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /Пополнить/ }));
|
||||||
|
expect(await screen.findByRole("dialog", { name: "Куда пополнить" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Долг аренда")).toBeInTheDocument();
|
||||||
|
// «Страховка» платежом не закрывается — в списке выбора ей делать нечего.
|
||||||
|
expect(screen.queryByText("Страховка")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("шторка закрывается тапом мимо", async () => {
|
||||||
|
wrap();
|
||||||
|
await screen.findByText("−8 976,25 ₽");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /Пополнить/ }));
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Закрыть" }));
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("баланс не загрузился — экран говорит об этом, а не рисует ноль", async () => {
|
||||||
|
vi.mocked(driver.getBalance).mockRejectedValueOnce(new Error("boom"));
|
||||||
|
wrap();
|
||||||
|
expect(await screen.findByRole("button", { name: /Обновить баланс/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,92 +1,113 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { History, LogOut } from "lucide-react";
|
import { ArrowUp, ChevronRight, History, LogOut } from "lucide-react";
|
||||||
import { getBalance } from "@/api/driver";
|
import { getBalance, type StatementKind } from "@/api/driver";
|
||||||
import { formatMoney } from "@/lib/format";
|
import { formatMoney, isoDaysAgo, isoToday } from "@/lib/format";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
import { AppHeader } from "@/components/AppHeader";
|
import { AppHeader } from "@/components/AppHeader";
|
||||||
|
import { Sheet } from "@/components/Sheet";
|
||||||
import { Spinner } from "@/components/Spinner";
|
import { Spinner } from "@/components/Spinner";
|
||||||
|
import { Statement } from "@/components/Statement";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Главный экран из двух половин: сверху деньги, снизу — за что они списаны.
|
||||||
|
*
|
||||||
|
* Разбивка по статьям живёт в шторке «Пополнить», а не на экране: водителю она
|
||||||
|
* нужна ровно в момент, когда он решает, куда платить, а всё остальное время
|
||||||
|
* это шесть строк, отодвигающих выписку за нижний край телефона.
|
||||||
|
*/
|
||||||
export function BalancePage() {
|
export function BalancePage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const clear = useAuth((s) => s.clear);
|
const clear = useAuth((s) => s.clear);
|
||||||
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["balance"], queryFn: getBalance });
|
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["balance"], queryFn: getBalance });
|
||||||
|
const [sheet, setSheet] = useState(false);
|
||||||
|
// Неделя — тот же период, что у вендора: столько человек помнит свои поездки.
|
||||||
|
const [from, setFrom] = useState(isoDaysAgo(7));
|
||||||
|
const [to, setTo] = useState(isoToday());
|
||||||
|
const [kind, setKind] = useState<StatementKind>("all");
|
||||||
|
|
||||||
const accounts = data?.accounts ?? [];
|
const accounts = data?.accounts ?? [];
|
||||||
// Итог — с сервера (это ровно то число, что видит сотрудник в CRM). Сумма строк
|
|
||||||
// тут не годится: 1С округляет статьи до рублей, а итог держит с копейками.
|
|
||||||
const total = data?.total ?? accounts.reduce((s, a) => s + a.balance, 0);
|
const total = data?.total ?? accounts.reduce((s, a) => s + a.balance, 0);
|
||||||
const pending = data?.pending_topup ?? 0;
|
const pending = data?.pending_topup ?? 0;
|
||||||
const noData = !isLoading && !isError && accounts.length === 0;
|
const noData = !isLoading && !isError && accounts.length === 0;
|
||||||
|
const payable = accounts.filter((a) => a.payable);
|
||||||
|
|
||||||
|
function pick(bucket?: string) {
|
||||||
|
setSheet(false);
|
||||||
|
nav("/topup", { state: bucket ? { bucket } : undefined });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<div className="card p-4 mb-4 text-center">
|
|
||||||
<p className="text-muted text-xs mb-1">Баланс</p>
|
|
||||||
{noData ? (
|
|
||||||
<p className="text-sm font-semibold">Уточняется</p>
|
|
||||||
) : (
|
|
||||||
<p className={`text-2xl font-extrabold ${total < 0 ? "text-neg" : "text-pos"}`}>
|
|
||||||
{formatMoney(total)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{pending > 0 && (
|
|
||||||
// Платёж принят нами, но 1С его ещё не провела. В сумму баланса он не
|
|
||||||
// входит намеренно: сложить оба источника значило бы показать
|
|
||||||
// пополнение дважды, как только расчётный лист принесёт тот же платёж.
|
|
||||||
<p className="text-muted text-xs mt-2">
|
|
||||||
{formatMoney(pending)} зачисляется — учтётся после проведения
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{/* ── Деньги ─────────────────────────────────────────────── */}
|
||||||
|
<div className="card p-4 mb-3">
|
||||||
|
<div className="flex justify-between items-baseline">
|
||||||
|
<span className="text-muted text-xs">Баланс в парке</span>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center py-10"><Spinner /></div>
|
<Spinner />
|
||||||
) : isError ? (
|
) : noData ? (
|
||||||
<div className="text-center py-8">
|
<span className="text-sm font-semibold">уточняется</span>
|
||||||
<p className="text-muted text-sm mb-3">Не удалось загрузить баланс</p>
|
|
||||||
<button className="btn-ghost" onClick={() => refetch()}>Повторить</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="mb-4">
|
<b className={`text-2xl ${total < 0 ? "text-neg" : "text-pos"}`}>{formatMoney(total)}</b>
|
||||||
{noData && (
|
|
||||||
<p className="text-muted text-sm text-center py-6">
|
|
||||||
Баланс ещё не пришёл из бухгалтерии. Если он нужен прямо сейчас — спросите в парке.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
{accounts.map((a) => {
|
</div>
|
||||||
const row = (
|
{pending > 0 && (
|
||||||
<>
|
// Платёж принят нами, но 1С его ещё не провела. В сумму не входит: сложить
|
||||||
<span className="text-sm">{a.bucket}</span>
|
// оба источника значило бы показать пополнение дважды после проведения.
|
||||||
<b className={a.balance < 0 ? "text-neg" : "text-pos"}>{formatMoney(a.balance)}</b>
|
<div className="flex justify-between items-baseline mt-2">
|
||||||
</>
|
<span className="text-muted text-xs">Зачисляется</span>
|
||||||
);
|
<span className="text-sm">{formatMoney(pending)}</span>
|
||||||
const cls = "w-full flex justify-between items-center py-3 border-b border-line last:border-0 text-left";
|
|
||||||
// В «Страховку» платёж провести нечем — строку показываем, нажатие не
|
|
||||||
// предлагаем: кнопка, ведущая в отказ, хуже отсутствующей кнопки.
|
|
||||||
return a.payable ? (
|
|
||||||
<button key={a.bucket} className={cls}
|
|
||||||
onClick={() => nav("/topup", { state: { bucket: a.bucket } })}>
|
|
||||||
{row}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div key={a.bucket} className={cls}>{row}</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isError && (
|
||||||
|
<button className="btn-ghost mt-3" onClick={() => refetch()}>Обновить баланс</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button className="btn-primary" onClick={() => nav("/topup")}>Пополнить</button>
|
<button className="btn-primary flex items-center justify-center gap-2 mb-5"
|
||||||
|
onClick={() => setSheet(true)}>
|
||||||
|
<ArrowUp size={18} /> Пополнить
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* ── За что списано ─────────────────────────────────────── */}
|
||||||
|
<Statement from={from} to={to} kind={kind}
|
||||||
|
onFrom={setFrom} onTo={setTo} onKind={setKind} />
|
||||||
|
|
||||||
<div className="flex justify-center gap-6 mt-6 text-muted text-xs">
|
<div className="flex justify-center gap-6 mt-6 text-muted text-xs">
|
||||||
<button className="flex items-center gap-1.5" onClick={() => nav("/history")}>
|
<button className="flex items-center gap-1.5" onClick={() => nav("/history")}>
|
||||||
<History size={15} /> История
|
<History size={15} /> Мои пополнения
|
||||||
</button>
|
</button>
|
||||||
<button className="flex items-center gap-1.5" onClick={() => { clear(); nav("/login"); }}>
|
<button className="flex items-center gap-1.5" onClick={() => { clear(); nav("/login"); }}>
|
||||||
<LogOut size={15} /> Выйти
|
<LogOut size={15} /> Выйти
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Sheet open={sheet} onClose={() => setSheet(false)} title="Куда пополнить">
|
||||||
|
{payable.length === 0 ? (
|
||||||
|
<p className="text-muted text-sm px-5 py-4">
|
||||||
|
Категории пока не загрузились. Попробуйте ещё раз через минуту.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
payable.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.bucket}
|
||||||
|
onClick={() => pick(a.bucket)}
|
||||||
|
className="w-full flex justify-between items-center gap-3 px-5 py-3.5 border-b border-line last:border-0 text-left"
|
||||||
|
>
|
||||||
|
<span className="text-sm">{a.bucket}</span>
|
||||||
|
<span className="flex items-center gap-2 shrink-0">
|
||||||
|
<b className={a.balance < 0 ? "text-neg" : a.balance > 0 ? "text-pos" : "text-muted"}>
|
||||||
|
{formatMoney(a.balance)}
|
||||||
|
</b>
|
||||||
|
<ChevronRight size={16} className="text-muted" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,13 @@ export function TopupPage() {
|
|||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<button className="text-muted text-xs mb-3" onClick={() => nav(-1)}>‹ Назад</button>
|
<button className="text-muted text-xs mb-3" onClick={() => nav(-1)}>‹ Назад</button>
|
||||||
|
|
||||||
{options.length > 1 && (
|
{/* Категорию выбирают в шторке на главном экране. Пришли оттуда — показываем
|
||||||
|
выбранное строкой, а не рядом чипов: второй выбор того же самого сбивает
|
||||||
|
с толку. Чипы остаются для входа сюда напрямую (кнопка «Пополнить» без
|
||||||
|
категории, возврат «пополнить ещё раз» с экрана оплаты). */}
|
||||||
|
{fromBalance ? (
|
||||||
|
<p className="text-muted text-xs mb-3">Пополняем: <b className="text-ink">{bucket}</b></p>
|
||||||
|
) : options.length > 1 ? (
|
||||||
<div className="flex gap-1.5 mb-3 overflow-x-auto">
|
<div className="flex gap-1.5 mb-3 overflow-x-auto">
|
||||||
{options.map((b) => (
|
{options.map((b) => (
|
||||||
<button
|
<button
|
||||||
@@ -75,7 +81,7 @@ export function TopupPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<p data-testid="amount" className="text-3xl font-extrabold text-center mt-2">{formatMoney(base)}</p>
|
<p data-testid="amount" className="text-3xl font-extrabold text-center mt-2">{formatMoney(base)}</p>
|
||||||
<p data-testid="total" className="text-muted text-xs text-center mb-3">
|
<p data-testid="total" className="text-muted text-xs text-center mb-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user