feat(pwa): главный экран из двух половин — деньги сверху, выписка снизу
По образцу приложения вендора: баланс в парке и «зачисляется», под ними крупная кнопка «Пополнить», ниже — период с календарями, фильтры и список операций (аренда, пени, штрафы, пополнения). Разбивка по статьям переехала в шторку «Пополнить». Она нужна ровно в момент выбора, куда платить; всё остальное время это шесть строк, отодвигающих выписку за нижний край телефона. В шторке только те статьи, в которые платёж проводится. Экран суммы, придя из шторки, показывает выбранную категорию строкой, а не рядом чипов: второй выбор того же самого сбивает с толку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user