feat(pwa): главный экран из двух половин — деньги сверху, выписка снизу

По образцу приложения вендора: баланс в парке и «зачисляется», под ними крупная
кнопка «Пополнить», ниже — период с календарями, фильтры и список операций
(аренда, пени, штрафы, пополнения).

Разбивка по статьям переехала в шторку «Пополнить». Она нужна ровно в момент
выбора, куда платить; всё остальное время это шесть строк, отодвигающих выписку
за нижний край телефона. В шторке только те статьи, в которые платёж проводится.

Экран суммы, придя из шторки, показывает выбранную категорию строкой, а не рядом
чипов: второй выбор того же самого сбивает с толку.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 16:35:01 +10:00
co-authored by Claude Opus 5
parent 8b0ce4cf45
commit 2d4b66d51f
7 changed files with 323 additions and 85 deletions
@@ -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>
);
}