feat(repairs): поле поиска прямо в ленте ремонтов
Владелец: «не хочу ещё один раздел, хочу чтобы в списке ремонтов просто появилось поле поиска». Поле встроено в шапку существующего экрана /repairs, нового раздела и роута нет. Поиск СЕРВЕРНЫЙ, и это принципиально. Лента грузится курсором по 20 строк: фильтрация «по уже загруженному» на старте видела бы последние 20 ремонтов парка и честно отвечала «ничего не найдено» на машину, которая была в ремонте позавчера. Отсутствие поиска хотя бы не врёт. Вторая ловушка того же варианта: маркер бесконечной прокрутки стоит сразу под списком, и схлопнутый фильтром список подтянул бы его в кадр — лента начала бы молча выкачивать всю таблицу по 20 строк за запрос. - q уходит на КАЖДОЙ странице и входит в ключ кэша: смена запроса создаёт свой список со своим курсором, поэтому курсор нефильтрованной ленты физически не может подмешать чужие ремонты во вторую страницу найденного. - Во время набора на экране остаётся прошлая выдача (приглушённой), а не пульсирующие скелетоны на каждое нажатие клавиши. - «Обновить» перезапрашивает одну страницу вместо всех загруженных: у бесконечного запроса refetch тянет их последовательно, и на слабой сети это десять round-trip'ов подряд с десятью шансами словить ошибку. При этом во время обновления догрузка молчит — иначе схлопывание ленты подтягивает маркер в кадр, fetchNextPage отменяет летящий запрос обновления, и новые ремонты сверху не появляются, сколько ни жми. - «Ничего не нашли по этому запросу» отличается от пустой ленты, а сбой сети — от ответа сервера с ошибкой: совет «проверьте сеть» на 500-ку отправляет механика перезагружать вайфай, когда чинить у него нечего. Строка ремонта, дневные разделители, месячный итог и поведение ленты без поиска не менялись. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,12 +27,17 @@ export interface FeedResponse {
|
||||
export interface FeedParams {
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
// Поисковая подстрока: госномер, марка или модель.
|
||||
q?: string | null;
|
||||
}
|
||||
|
||||
export async function getRepairsFeed(params: FeedParams = {}): Promise<FeedResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.limit) search.set("limit", String(params.limit));
|
||||
if (params.cursor) search.set("cursor", params.cursor);
|
||||
// Пустой/пробельный запрос не шлём — это обычная лента, а не поиск.
|
||||
const q = params.q?.trim();
|
||||
if (q) search.set("q", q);
|
||||
const qs = search.toString();
|
||||
return api
|
||||
.get(`repairs${qs ? `?${qs}` : ""}`)
|
||||
|
||||
@@ -3,42 +3,122 @@
|
||||
*
|
||||
* Использует `useInfiniteQuery` для бесконечной пагинации по 20 (У38).
|
||||
* На `visibilitychange` (когда вкладка/приложение получают фокус) —
|
||||
* автоматический рефреш первой страницы. Pull-to-refresh вызывается
|
||||
* напрямую из компонента через `refetch()`.
|
||||
* автоматическое обновление через `refresh()`. Кнопка «Обновить» в шапке
|
||||
* вызывает тот же `refresh()`.
|
||||
*
|
||||
* Поддерживает поиск по подстроке (госномер/марка/модель): `q` уходит
|
||||
* в каждый запрос страницы и входит в ключ кэша. При смене запроса на
|
||||
* экране остаётся прошлая выдача (`keepPreviousData`), а не скелетоны.
|
||||
*
|
||||
* SWR-кэш живёт в react-query (память + localStorage persist уже
|
||||
* сконфигурирован в QueryClient на уровне приложения, если такого нет —
|
||||
* первая отрисовка идёт без кэша, но дальше работает).
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { useInfiniteQuery, type QueryKey } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useQueryClient,
|
||||
type InfiniteData,
|
||||
type QueryKey,
|
||||
} from "@tanstack/react-query";
|
||||
import { getRepairsFeed, type FeedItem, type FeedResponse } from "@/api/repairsFeed";
|
||||
|
||||
export const FEED_QUERY_KEY: QueryKey = ["repairs-feed"];
|
||||
export const FEED_PAGE_SIZE = 20;
|
||||
|
||||
export function useRepairsFeed() {
|
||||
const query = useInfiniteQuery<FeedResponse>({
|
||||
queryKey: FEED_QUERY_KEY,
|
||||
/**
|
||||
* Ключ кэша ленты. Поисковый запрос обязан входить в ключ: иначе страницы
|
||||
* разных запросов лягут в один кэш и в списке перемешаются чужие ремонты.
|
||||
*/
|
||||
export function feedQueryKey(q?: string | null): QueryKey {
|
||||
return [...FEED_QUERY_KEY, q?.trim() || null];
|
||||
}
|
||||
|
||||
/** Что лежит в кэше ленты: загруженные страницы и курсоры, по которым их брали. */
|
||||
type FeedPages = InfiniteData<FeedResponse, string | null>;
|
||||
|
||||
export function useRepairsFeed(q?: string | null) {
|
||||
// Нормализуем один раз: и ключ, и запрос должны видеть одно и то же
|
||||
// значение, иначе кэш и фильтр разъедутся на пробелах.
|
||||
const search = q?.trim() || null;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useInfiniteQuery<FeedResponse, Error, FeedPages, QueryKey, string | null>({
|
||||
queryKey: feedQueryKey(search),
|
||||
queryFn: ({ pageParam }) =>
|
||||
getRepairsFeed({ limit: FEED_PAGE_SIZE, cursor: (pageParam as string | null) ?? null }),
|
||||
initialPageParam: null as string | null,
|
||||
// q передаём на КАЖДОЙ странице — иначе догрузка вернёт нефильтрованный
|
||||
// хвост и подмешает в результаты поиска чужие ремонты.
|
||||
getRepairsFeed({
|
||||
limit: FEED_PAGE_SIZE,
|
||||
cursor: pageParam,
|
||||
q: search,
|
||||
}),
|
||||
initialPageParam: null,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
staleTime: 30_000, // 30 секунд считаем «свежим» — потом фон обновляет
|
||||
refetchOnWindowFocus: false, // делаем свой visibilitychange-листенер ниже
|
||||
// Каждая буква в поиске — новый ключ, а у нового ключа данных ещё нет.
|
||||
// Без этого список на каждое нажатие подменялся бы скелетонами и мигал.
|
||||
// Держим прошлую выдачу до прихода актуальной; что она устарела —
|
||||
// показываем через isPreviousResults.
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
// Листенер вешаем один раз, а refetch держим в ref — после смены поискового
|
||||
// запроса обновлять надо актуальную выдачу, а не ту, что была на монтировании.
|
||||
const refetchRef = useRef(query.refetch);
|
||||
useEffect(() => {
|
||||
refetchRef.current = query.refetch;
|
||||
}, [query.refetch]);
|
||||
|
||||
/**
|
||||
* Обновление ленты. `refetch()` у бесконечного запроса перезапрашивает ВСЕ
|
||||
* загруженные страницы, причём последовательно (курсор следующей приходит
|
||||
* из предыдущей). У механика, пролиставшего ленту вглубь, это десяток
|
||||
* запросов на слабой сети и десять шансов словить ошибку вместо одного.
|
||||
* Поэтому сначала схлопываем ленту до первой страницы, а потом обновляем —
|
||||
* ровно один запрос. Плата: догруженный хвост сбрасывается, лента
|
||||
* возвращается к началу (как у обычного pull-to-refresh).
|
||||
*/
|
||||
// Пока обновление летит, догрузка следующей страницы обязана молчать.
|
||||
// Схлопывание укорачивает список, браузер прижимает скролл к новому низу, и
|
||||
// маркер бесконечной прокрутки попадает в зону видимости. Его fetchNextPage
|
||||
// по умолчанию ОТМЕНЯЕТ запрос в полёте — обновление не доезжает вовсе, а
|
||||
// вместо него подтягивается вторая страница по старому курсору. Новых
|
||||
// ремонтов сверху человек так и не увидит, сколько ни жми.
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
queryClient.setQueryData<FeedPages>(feedQueryKey(search), (old) =>
|
||||
old && old.pages.length > 1
|
||||
? { pages: old.pages.slice(0, 1), pageParams: old.pageParams.slice(0, 1) }
|
||||
: old,
|
||||
);
|
||||
await refetchRef.current();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [queryClient, search]);
|
||||
|
||||
// Тот же приём с ref: refresh меняется вместе с поисковым запросом,
|
||||
// а листенер ниже вешается один раз.
|
||||
const refreshRef = useRef(refresh);
|
||||
useEffect(() => {
|
||||
refreshRef.current = refresh;
|
||||
}, [refresh]);
|
||||
|
||||
// У38: обновление при возврате в приложение/вкладку.
|
||||
useEffect(() => {
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
// Рефрешим только первую страницу, бесконечный скролл сбрасывается.
|
||||
void query.refetch();
|
||||
void refreshRef.current();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => document.removeEventListener("visibilitychange", onVisible);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Удобный flatten для рендера в одну ленту.
|
||||
@@ -48,11 +128,15 @@ export function useRepairsFeed() {
|
||||
items,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
// На экране выдача предыдущего запроса, актуальная ещё летит —
|
||||
// повод приглушить список, но не повод его прятать.
|
||||
isPreviousResults: query.isPlaceholderData,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
hasNextPage: !!query.hasNextPage,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
refetch: query.refetch,
|
||||
refresh,
|
||||
isRefreshing,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,13 +5,18 @@
|
||||
* `created_at DESC`. Бесконечный скролл по 20. Pull-to-refresh,
|
||||
* обновление при возврате в приложение (через useRepairsFeed).
|
||||
*
|
||||
* В шапке — поиск по госномеру, марке и модели (отдельного экрана нет:
|
||||
* это фильтр той же ленты, со своим кэшем и своей пагинацией).
|
||||
*
|
||||
* Пустая лента — плейсхолдер «В парке пока нет ремонтов» (У41).
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronLeft, Plus, Wrench } from "lucide-react";
|
||||
import { HTTPError } from "ky";
|
||||
import { ChevronLeft, Plus, Search, Wrench, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useRepairsFeed } from "@/hooks/useRepairsFeed";
|
||||
import { formatFeedTimestamp } from "@/lib/timeFormat";
|
||||
import { getRepairsMonthStats, type FeedItem } from "@/api/repairsFeed";
|
||||
@@ -19,16 +24,35 @@ import { groupByLocalDay, formatDayLabel, fmtMoney, pluralRepairs, pluralWorks }
|
||||
|
||||
export default function RepairsFeedPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Поле ввода и запрос разведены: в сеть уходит только «устоявшееся»
|
||||
// значение, иначе каждая буква — отдельный запрос и отдельный кэш.
|
||||
const [queryInput, setQueryInput] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setQ(queryInput.trim()), 200); // debounce
|
||||
return () => clearTimeout(t);
|
||||
}, [queryInput]);
|
||||
const isSearching = q.length > 0;
|
||||
|
||||
const {
|
||||
items,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
refetch,
|
||||
refresh,
|
||||
isRefreshing,
|
||||
isFetching,
|
||||
} = useRepairsFeed();
|
||||
isPreviousResults,
|
||||
} = useRepairsFeed(q);
|
||||
|
||||
const resetSearch = useCallback(() => {
|
||||
setQueryInput("");
|
||||
setQ("");
|
||||
}, []);
|
||||
|
||||
// Сумма и количество ремонтов за текущий месяц (отдельный endpoint —
|
||||
// не зависит от пагинации ленты).
|
||||
@@ -45,10 +69,14 @@ export default function RepairsFeedPage() {
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el || !hasNextPage) return;
|
||||
if (!el || !hasNextPage || isRefreshing) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
|
||||
// Во время обновления не догружаем: refresh схлопывает ленту, скролл
|
||||
// прижимается к новому низу, маркер попадает в кадр — и fetchNextPage
|
||||
// отменил бы летящий запрос обновления. Человек жал бы «Обновить»
|
||||
// сколько угодно, а новые ремонты сверху так и не появлялись бы.
|
||||
if (entries[0]?.isIntersecting && !isFetchingNextPage && !isRefreshing) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
},
|
||||
@@ -56,14 +84,15 @@ export default function RepairsFeedPage() {
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage, isRefreshing]);
|
||||
|
||||
// Pull-to-refresh: простой UX через тач-скролл сверху.
|
||||
// Делаем минимально-инвазивно: на тап-кнопку «обновить» вызываем refetch.
|
||||
// Делаем минимально-инвазивно: на тап-кнопку «обновить» вызываем refresh
|
||||
// (он схлопывает ленту до первой страницы — см. useRepairsFeed).
|
||||
// Полноценный pull-to-refresh с жестом — отдельная задача.
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const handleAdd = () => navigate("/repairs/new");
|
||||
const handleOpen = (item: FeedItem) => navigate(`/repairs/${item.id}`);
|
||||
@@ -93,24 +122,59 @@ export default function RepairsFeedPage() {
|
||||
<span className="hidden sm:inline">Добавить</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Поиск по ленте. Без autoFocus: экран открывают ради списка, а не
|
||||
ради поиска — не выдёргиваем клавиатуру на каждом заходе. */}
|
||||
<div className="px-3 pb-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Госномер, марка или модель…"
|
||||
aria-label="Поиск по ремонтам"
|
||||
value={queryInput}
|
||||
onChange={(e) => setQueryInput(e.target.value)}
|
||||
// Столько же, сколько принимает сервер. Меньше нельзя: на
|
||||
// «тойота ленд крузер прадо а123вс125» поле молча перестало бы
|
||||
// печатать, и это выглядит как залипшая клавиатура.
|
||||
maxLength={64}
|
||||
className="pl-8 pr-9"
|
||||
/>
|
||||
{queryInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetSearch}
|
||||
aria-label="Очистить поиск"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 container max-w-2xl py-3 space-y-2">
|
||||
{/* Итого слева — сумма работ и количество ремонтов за текущий месяц
|
||||
(с 1-го числа по now). Обновить справа. */}
|
||||
(с 1-го числа по now). Обновить справа.
|
||||
При активном поиске месячный итог прячем: он считается по всему
|
||||
месяцу и к отфильтрованному списку отношения не имеет — рядом с
|
||||
выдачей поиска читался бы как «итог найденного». */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">За этот месяц:</span>{" "}
|
||||
<span className="font-semibold tabular-nums">
|
||||
{fmtMoney(monthStatsQuery.data?.total ?? 0)} ₽
|
||||
</span>
|
||||
{(monthStatsQuery.data?.count ?? 0) > 0 && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({monthStatsQuery.data!.count}{" "}
|
||||
{pluralRepairs(monthStatsQuery.data!.count)})
|
||||
{isSearching ? (
|
||||
<div className="text-sm text-muted-foreground">Результаты поиска</div>
|
||||
) : (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">За этот месяц:</span>{" "}
|
||||
<span className="font-semibold tabular-nums">
|
||||
{fmtMoney(monthStatsQuery.data?.total ?? 0)} ₽
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(monthStatsQuery.data?.count ?? 0) > 0 && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({monthStatsQuery.data!.count}{" "}
|
||||
{pluralRepairs(monthStatsQuery.data!.count)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
@@ -129,30 +193,56 @@ export default function RepairsFeedPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Сбой — отдельная ветка ДО пустого состояния: «не дозвонились»
|
||||
не должно выглядеть как «ничего не нашлось». */}
|
||||
{isError && !isLoading && (
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm">
|
||||
Не удалось загрузить ленту ремонтов. Проверьте сеть и попробуйте снова.
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm space-y-2">
|
||||
<div>{feedErrorMessage(error, isSearching)}</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isFetching}>
|
||||
{isFetching ? "Повторяем…" : "Повторить"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && items.length === 0 && <EmptyState onAdd={handleAdd} />}
|
||||
{/* Пока летит выдача нового запроса, показываем прошлую — приглушённой.
|
||||
Устарело ≠ пропало: подмена списка скелетонами на каждую букву
|
||||
читается как «всё исчезло» и мешает целиться в нужную строку. */}
|
||||
<div
|
||||
className={`transition-opacity ${isPreviousResults ? "opacity-50" : ""}`}
|
||||
aria-busy={isPreviousResults}
|
||||
>
|
||||
{!isLoading && !isError && items.length === 0 && (
|
||||
isSearching ? (
|
||||
<NothingFoundState query={q} onReset={resetSearch} />
|
||||
) : (
|
||||
<EmptyState onAdd={handleAdd} />
|
||||
)
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{grouped.map((group) => (
|
||||
<li key={group.key}>
|
||||
<DaySeparator dateKey={group.key} total={group.total} count={group.items.length} />
|
||||
<ul className="space-y-2 mt-2">
|
||||
{group.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<RepairRow item={item} onOpen={() => handleOpen(item)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{grouped.map((group) => (
|
||||
<li key={group.key}>
|
||||
<DaySeparator
|
||||
dateKey={group.key}
|
||||
total={group.total}
|
||||
count={group.items.length}
|
||||
// При поиске дневные итоги молчат: они считаются по строкам
|
||||
// на экране, а это уже не «итог дня», а «итог совпадений».
|
||||
showTotals={!isSearching}
|
||||
/>
|
||||
<ul className="space-y-2 mt-2">
|
||||
{group.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<RepairRow item={item} onOpen={() => handleOpen(item)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={sentinelRef} aria-hidden="true" />
|
||||
{isFetchingNextPage && (
|
||||
@@ -162,7 +252,7 @@ export default function RepairsFeedPage() {
|
||||
)}
|
||||
{!hasNextPage && items.length > 0 && !isFetchingNextPage && (
|
||||
<div className="text-center text-xs text-muted-foreground py-4">
|
||||
Это все ремонты.
|
||||
{isSearching ? "Это все совпадения." : "Это все ремонты."}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
@@ -171,18 +261,47 @@ export default function RepairsFeedPage() {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Текст сбоя. «Проверьте сеть» уместно только когда до сервера не дозвонились:
|
||||
* если сервер ответил 500, этот совет отправляет механика чинить не то —
|
||||
* он перезагружает вайфай, а чинить нечего, надо просто позвать нас.
|
||||
*/
|
||||
function feedErrorMessage(error: unknown, isSearching: boolean): string {
|
||||
const what = isSearching ? "выполнить поиск" : "загрузить ленту ремонтов";
|
||||
// HTTPError = связь была, ответ пришёл — виноват сервер, а не сеть.
|
||||
if (error instanceof HTTPError) {
|
||||
const code = error.response.status;
|
||||
return `Не удалось ${what}: сервер ответил ошибкой (${code}). Попробуйте позже или сообщите в поддержку.`;
|
||||
}
|
||||
// Всё остальное (обрыв, таймаут, самолётный режим) — ответа не было вовсе.
|
||||
return `Не удалось ${what}: сервер не ответил. Проверьте связь и попробуйте снова.`;
|
||||
}
|
||||
|
||||
|
||||
// Группировка по локальной дате (VLA), форматирование сумм/склонений —
|
||||
// в @/lib/repairsFeed (покрыто юнит-тестами).
|
||||
|
||||
function DaySeparator({ dateKey, total, count }: { dateKey: string; total: number; count: number }) {
|
||||
function DaySeparator({
|
||||
dateKey,
|
||||
total,
|
||||
count,
|
||||
showTotals,
|
||||
}: {
|
||||
dateKey: string;
|
||||
total: number;
|
||||
count: number;
|
||||
showTotals: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 pt-3 pb-1 text-xs">
|
||||
<span className="font-medium text-foreground">{formatDayLabel(dateKey)}</span>
|
||||
<div className="flex-1 border-t border-dashed border-border" />
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{fmtMoney(total)} ₽
|
||||
<span className="ml-1">({count} {pluralRepairs(count)})</span>
|
||||
</span>
|
||||
{showTotals && (
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{fmtMoney(total)} ₽
|
||||
<span className="ml-1">({count} {pluralRepairs(count)})</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -258,6 +377,29 @@ function WorksLine({ works, extra }: { works: string[]; extra: number }) {
|
||||
}
|
||||
|
||||
|
||||
// Пустой поиск — это НЕ пустая лента: тут не «ремонтов ещё нет», а
|
||||
// «под фильтр ничего не попало», и выход отсюда — сбросить поиск.
|
||||
function NothingFoundState({ query, onReset }: { query: string; onReset: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center py-16 px-4 space-y-4">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-base font-semibold">Ничего не найдено</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
По запросу «{query}» ремонтов нет. Проверьте номер или поищите по марке
|
||||
и модели.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={onReset}>
|
||||
Сбросить поиск
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function EmptyState({ onAdd }: { onAdd: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center py-16 px-4 space-y-4">
|
||||
|
||||
Reference in New Issue
Block a user