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>();
|
.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)
|
||||||
|
|||||||
Reference in New Issue
Block a user