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:
@@ -38,6 +38,15 @@ export async function getRepairsFeed(params: FeedParams = {}): Promise<FeedRespo
|
||||
.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 {
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
*
|
||||
* Пустая лента — плейсхолдер «В парке пока нет ремонтов» (У41).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronLeft, Plus, Wrench } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRepairsFeed } from "@/hooks/useRepairsFeed";
|
||||
import { formatFeedTimestamp } from "@/lib/timeFormat";
|
||||
import type { FeedItem } from "@/api/repairsFeed";
|
||||
import { getRepairsMonthStats, type FeedItem } from "@/api/repairsFeed";
|
||||
|
||||
export default function RepairsFeedPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -28,6 +29,17 @@ export default function RepairsFeedPage() {
|
||||
isFetching,
|
||||
} = useRepairsFeed();
|
||||
|
||||
// Сумма и количество ремонтов за текущий месяц (отдельный endpoint —
|
||||
// не зависит от пагинации ленты).
|
||||
const monthStatsQuery = useQuery({
|
||||
queryKey: ["repairs-month-stats"],
|
||||
queryFn: getRepairsMonthStats,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// Группировка items по локальной дате (Asia/Vladivostok).
|
||||
const grouped = useMemo(() => groupByLocalDay(items), [items]);
|
||||
|
||||
// IntersectionObserver для бесконечной прокрутки.
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -83,16 +95,18 @@ export default function RepairsFeedPage() {
|
||||
</header>
|
||||
|
||||
<main className="flex-1 container max-w-2xl py-3 space-y-2">
|
||||
{/* Итого слева — сумма работ загруженных ремонтов; Обновить справа. */}
|
||||
{/* Итого слева — сумма работ и количество ремонтов за текущий месяц
|
||||
(с 1-го числа по now). Обновить справа. */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Итого:</span>{" "}
|
||||
<span className="text-muted-foreground">За этот месяц:</span>{" "}
|
||||
<span className="font-semibold tabular-nums">
|
||||
{fmtMoney(items.reduce((s, it) => s + (it.works_total || 0), 0))} ₽
|
||||
{fmtMoney(monthStatsQuery.data?.total ?? 0)} ₽
|
||||
</span>
|
||||
{items.length > 0 && (
|
||||
{(monthStatsQuery.data?.count ?? 0) > 0 && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({items.length} {pluralRepairs(items.length)})
|
||||
({monthStatsQuery.data!.count}{" "}
|
||||
{pluralRepairs(monthStatsQuery.data!.count)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -124,9 +138,16 @@ export default function RepairsFeedPage() {
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<RepairRow item={item} onOpen={() => handleOpen(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}>
|
||||
<RepairRow item={item} onOpen={() => handleOpen(item)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -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 }) {
|
||||
const carLine = [item.car_plate, item.car_make, item.car_model]
|
||||
.filter(Boolean)
|
||||
|
||||
Reference in New Issue
Block a user