From 5a65f8f1ca62d72cd81c1f1439e245512ceea934 Mon Sep 17 00:00:00 2001 From: vladtechno Date: Fri, 7 Aug 2026 22:04:02 +1000 Subject: [PATCH] =?UTF-8?q?feat(repairs):=20=D0=BF=D0=BE=D0=BB=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0=20=D0=BF=D1=80=D1=8F=D0=BC?= =?UTF-8?q?=D0=BE=20=D0=B2=20=D0=BB=D0=B5=D0=BD=D1=82=D0=B5=20=D1=80=D0=B5?= =?UTF-8?q?=D0=BC=D0=BE=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Владелец: «не хочу ещё один раздел, хочу чтобы в списке ремонтов просто появилось поле поиска». Поле встроено в шапку существующего экрана /repairs, нового раздела и роута нет. Поиск СЕРВЕРНЫЙ, и это принципиально. Лента грузится курсором по 20 строк: фильтрация «по уже загруженному» на старте видела бы последние 20 ремонтов парка и честно отвечала «ничего не найдено» на машину, которая была в ремонте позавчера. Отсутствие поиска хотя бы не врёт. Вторая ловушка того же варианта: маркер бесконечной прокрутки стоит сразу под списком, и схлопнутый фильтром список подтянул бы его в кадр — лента начала бы молча выкачивать всю таблицу по 20 строк за запрос. - q уходит на КАЖДОЙ странице и входит в ключ кэша: смена запроса создаёт свой список со своим курсором, поэтому курсор нефильтрованной ленты физически не может подмешать чужие ремонты во вторую страницу найденного. - Во время набора на экране остаётся прошлая выдача (приглушённой), а не пульсирующие скелетоны на каждое нажатие клавиши. - «Обновить» перезапрашивает одну страницу вместо всех загруженных: у бесконечного запроса refetch тянет их последовательно, и на слабой сети это десять round-trip'ов подряд с десятью шансами словить ошибку. При этом во время обновления догрузка молчит — иначе схлопывание ленты подтягивает маркер в кадр, fetchNextPage отменяет летящий запрос обновления, и новые ремонты сверху не появляются, сколько ни жми. - «Ничего не нашли по этому запросу» отличается от пустой ленты, а сбой сети — от ответа сервера с ошибкой: совет «проверьте сеть» на 500-ку отправляет механика перезагружать вайфай, когда чинить у него нечего. Строка ремонта, дневные разделители, месячный итог и поведение ленты без поиска не менялись. Co-Authored-By: Claude Opus 5 --- mechanic-pwa/frontend/src/api/repairsFeed.ts | 5 + .../frontend/src/hooks/useRepairsFeed.ts | 110 +++++++- .../src/pages/repairs/RepairsFeedPage.tsx | 236 ++++++++++++++---- 3 files changed, 291 insertions(+), 60 deletions(-) diff --git a/mechanic-pwa/frontend/src/api/repairsFeed.ts b/mechanic-pwa/frontend/src/api/repairsFeed.ts index ee22a86..bbd7941 100644 --- a/mechanic-pwa/frontend/src/api/repairsFeed.ts +++ b/mechanic-pwa/frontend/src/api/repairsFeed.ts @@ -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 { 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}` : ""}`) diff --git a/mechanic-pwa/frontend/src/hooks/useRepairsFeed.ts b/mechanic-pwa/frontend/src/hooks/useRepairsFeed.ts index 04716ae..4e8d6cf 100644 --- a/mechanic-pwa/frontend/src/hooks/useRepairsFeed.ts +++ b/mechanic-pwa/frontend/src/hooks/useRepairsFeed.ts @@ -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({ - queryKey: FEED_QUERY_KEY, +/** + * Ключ кэша ленты. Поисковый запрос обязан входить в ключ: иначе страницы + * разных запросов лягут в один кэш и в списке перемешаются чужие ремонты. + */ +export function feedQueryKey(q?: string | null): QueryKey { + return [...FEED_QUERY_KEY, q?.trim() || null]; +} + +/** Что лежит в кэше ленты: загруженные страницы и курсоры, по которым их брали. */ +type FeedPages = InfiniteData; + +export function useRepairsFeed(q?: string | null) { + // Нормализуем один раз: и ключ, и запрос должны видеть одно и то же + // значение, иначе кэш и фильтр разъедутся на пробелах. + const search = q?.trim() || null; + const queryClient = useQueryClient(); + + const query = useInfiniteQuery({ + 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(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, }; } diff --git a/mechanic-pwa/frontend/src/pages/repairs/RepairsFeedPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/RepairsFeedPage.tsx index 9536441..26b4b0d 100644 --- a/mechanic-pwa/frontend/src/pages/repairs/RepairsFeedPage.tsx +++ b/mechanic-pwa/frontend/src/pages/repairs/RepairsFeedPage.tsx @@ -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(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() { Добавить + {/* Поиск по ленте. Без autoFocus: экран открывают ради списка, а не + ради поиска — не выдёргиваем клавиатуру на каждом заходе. */} +
+
+ + setQueryInput(e.target.value)} + // Столько же, сколько принимает сервер. Меньше нельзя: на + // «тойота ленд крузер прадо а123вс125» поле молча перестало бы + // печатать, и это выглядит как залипшая клавиатура. + maxLength={64} + className="pl-8 pr-9" + /> + {queryInput && ( + + )} +
+
{/* Итого слева — сумма работ и количество ремонтов за текущий месяц - (с 1-го числа по now). Обновить справа. */} + (с 1-го числа по now). Обновить справа. + При активном поиске месячный итог прячем: он считается по всему + месяцу и к отфильтрованному списку отношения не имеет — рядом с + выдачей поиска читался бы как «итог найденного». */}
-
- За этот месяц:{" "} - - {fmtMoney(monthStatsQuery.data?.total ?? 0)} ₽ - - {(monthStatsQuery.data?.count ?? 0) > 0 && ( - - ({monthStatsQuery.data!.count}{" "} - {pluralRepairs(monthStatsQuery.data!.count)}) + {isSearching ? ( +
Результаты поиска
+ ) : ( +
+ За этот месяц:{" "} + + {fmtMoney(monthStatsQuery.data?.total ?? 0)} ₽ - )} -
+ {(monthStatsQuery.data?.count ?? 0) > 0 && ( + + ({monthStatsQuery.data!.count}{" "} + {pluralRepairs(monthStatsQuery.data!.count)}) + + )} +
+ )}
)} - {!isLoading && !isError && items.length === 0 && } + {/* Пока летит выдача нового запроса, показываем прошлую — приглушённой. + Устарело ≠ пропало: подмена списка скелетонами на каждую букву + читается как «всё исчезло» и мешает целиться в нужную строку. */} +
+ {!isLoading && !isError && items.length === 0 && ( + isSearching ? ( + + ) : ( + + ) + )} - {items.length > 0 && ( -
    - {grouped.map((group) => ( -
  • - -
      - {group.items.map((item) => ( -
    • - handleOpen(item)} /> -
    • - ))} -
    -
  • - ))} -
- )} + {items.length > 0 && ( +
    + {grouped.map((group) => ( +
  • + +
      + {group.items.map((item) => ( +
    • + handleOpen(item)} /> +
    • + ))} +
    +
  • + ))} +
+ )} +
@@ -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 (
{formatDayLabel(dateKey)}
- - {fmtMoney(total)} ₽ - ({count} {pluralRepairs(count)}) - + {showTotals && ( + + {fmtMoney(total)} ₽ + ({count} {pluralRepairs(count)}) + + )}
); } @@ -258,6 +377,29 @@ function WorksLine({ works, extra }: { works: string[]; extra: number }) { } +// Пустой поиск — это НЕ пустая лента: тут не «ремонтов ещё нет», а +// «под фильтр ничего не попало», и выход отсюда — сбросить поиск. +function NothingFoundState({ query, onReset }: { query: string; onReset: () => void }) { + return ( +
+
+ +
+
+

Ничего не найдено

+

+ По запросу «{query}» ремонтов нет. Проверьте номер или поищите по марке + и модели. +

+
+ +
+ ); +} + + function EmptyState({ onAdd }: { onAdd: () => void }) { return (