feat(repairs): month-to-date header + per-day separators

Header 'Итого' (sum of loaded items) -> 'За этот месяц' (sum + count
from 1st VLAT to now) via new /v1/mechanic/repairs/stats/month endpoint.

List now grouped by local day with a dashed separator showing date
('Сегодня' / 'Вчера' / '26 мая') + day total + repair count.

Bundle: index-BKRX_okx.js
This commit is contained in:
2026-05-26 23:27:54 +10:00
parent fd0ec64da9
commit 10492e52a4
2 changed files with 105 additions and 10 deletions
@@ -38,6 +38,15 @@ export async function getRepairsFeed(params: FeedParams = {}): Promise<FeedRespo
.json<FeedResponse>(); .json<FeedResponse>();
} }
export interface RepairsMonthStats {
total: number;
count: number;
}
export async function getRepairsMonthStats(): Promise<RepairsMonthStats> {
return api.get("repairs/stats/month").json<RepairsMonthStats>();
}
// ── Детальная карточка ремонта ───────────────────────────────────────────── // ── Детальная карточка ремонта ─────────────────────────────────────────────
export interface RepairDetailWork { export interface RepairDetailWork {
@@ -7,13 +7,14 @@
* *
* Пустая лента — плейсхолдер «В парке пока нет ремонтов» (У41). * Пустая лента — плейсхолдер «В парке пока нет ремонтов» (У41).
*/ */
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, Plus, Wrench } from "lucide-react"; import { ChevronLeft, Plus, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useRepairsFeed } from "@/hooks/useRepairsFeed"; import { useRepairsFeed } from "@/hooks/useRepairsFeed";
import { formatFeedTimestamp } from "@/lib/timeFormat"; import { formatFeedTimestamp } from "@/lib/timeFormat";
import type { FeedItem } from "@/api/repairsFeed"; import { getRepairsMonthStats, type FeedItem } from "@/api/repairsFeed";
export default function RepairsFeedPage() { export default function RepairsFeedPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -28,6 +29,17 @@ export default function RepairsFeedPage() {
isFetching, isFetching,
} = useRepairsFeed(); } = useRepairsFeed();
// Сумма и количество ремонтов за текущий месяц (отдельный endpoint —
// не зависит от пагинации ленты).
const monthStatsQuery = useQuery({
queryKey: ["repairs-month-stats"],
queryFn: getRepairsMonthStats,
staleTime: 60_000,
});
// Группировка items по локальной дате (Asia/Vladivostok).
const grouped = useMemo(() => groupByLocalDay(items), [items]);
// IntersectionObserver для бесконечной прокрутки. // IntersectionObserver для бесконечной прокрутки.
const sentinelRef = useRef<HTMLDivElement | null>(null); const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
@@ -83,16 +95,18 @@ export default function RepairsFeedPage() {
</header> </header>
<main className="flex-1 container max-w-2xl py-3 space-y-2"> <main className="flex-1 container max-w-2xl py-3 space-y-2">
{/* Итого слева — сумма работ загруженных ремонтов; Обновить справа. */} {/* Итого слева — сумма работ и количество ремонтов за текущий месяц
(с 1-го числа по now). Обновить справа. */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-sm"> <div className="text-sm">
<span className="text-muted-foreground">Итого:</span>{" "} <span className="text-muted-foreground">За этот месяц:</span>{" "}
<span className="font-semibold tabular-nums"> <span className="font-semibold tabular-nums">
{fmtMoney(items.reduce((s, it) => s + (it.works_total || 0), 0))} {fmtMoney(monthStatsQuery.data?.total ?? 0)}
</span> </span>
{items.length > 0 && ( {(monthStatsQuery.data?.count ?? 0) > 0 && (
<span className="text-xs text-muted-foreground ml-2"> <span className="text-xs text-muted-foreground ml-2">
({items.length} {pluralRepairs(items.length)}) ({monthStatsQuery.data!.count}{" "}
{pluralRepairs(monthStatsQuery.data!.count)})
</span> </span>
)} )}
</div> </div>
@@ -124,12 +138,19 @@ export default function RepairsFeedPage() {
{items.length > 0 && ( {items.length > 0 && (
<ul className="space-y-2"> <ul className="space-y-2">
{items.map((item) => ( {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}> <li key={item.id}>
<RepairRow item={item} onOpen={() => handleOpen(item)} /> <RepairRow item={item} onOpen={() => handleOpen(item)} />
</li> </li>
))} ))}
</ul> </ul>
</li>
))}
</ul>
)} )}
<div ref={sentinelRef} aria-hidden="true" /> <div ref={sentinelRef} aria-hidden="true" />
@@ -149,6 +170,71 @@ export default function RepairsFeedPage() {
} }
// ── Группировка по локальной дате (Asia/Vladivostok) ────────────────────────
interface DayGroup {
key: string; // YYYY-MM-DD (VLAT)
items: FeedItem[];
total: number;
}
function groupByLocalDay(items: FeedItem[]): DayGroup[] {
const map = new Map<string, DayGroup>();
for (const it of items) {
const key = toLocalDateKey(it.created_at);
let g = map.get(key);
if (!g) {
g = { key, items: [], total: 0 };
map.set(key, g);
}
g.items.push(it);
g.total += it.works_total || 0;
}
// Items already sorted DESC by created_at, so keys naturally come in
// newest-first order from the Map.
return Array.from(map.values());
}
function toLocalDateKey(iso: string): string {
// ISO → date in Asia/Vladivostok (+10).
const d = new Date(iso);
// ru-RU sv-style → YYYY-MM-DD via Intl with explicit TZ.
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Vladivostok",
year: "numeric", month: "2-digit", day: "2-digit",
});
return fmt.format(d); // 2026-05-26
}
function formatDayLabel(key: string): string {
// key = YYYY-MM-DD → «26 мая» / «Сегодня» / «Вчера»
const today = toLocalDateKey(new Date().toISOString());
if (key === today) return "Сегодня";
// Yesterday in same TZ:
const now = new Date();
const yest = new Date(now.getTime() - 24 * 3600 * 1000);
if (key === toLocalDateKey(yest.toISOString())) return "Вчера";
const [y, m, d] = key.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
return new Intl.DateTimeFormat("ru-RU", {
day: "numeric", month: "long", timeZone: "UTC",
}).format(dt);
}
function DaySeparator({ dateKey, total, count }: { dateKey: string; total: number; count: number }) {
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>
</div>
);
}
function RepairRow({ item, onOpen }: { item: FeedItem; onOpen: () => void }) { function RepairRow({ item, onOpen }: { item: FeedItem; onOpen: () => void }) {
const carLine = [item.car_plate, item.car_make, item.car_model] const carLine = [item.car_plate, item.car_make, item.car_model]
.filter(Boolean) .filter(Boolean)