feat(site-finder): кнопка «Полный отчёт (PDF)» — enqueue → поллинг → download (#2259 PR-E)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m55s
CI / openapi-codegen-check (pull_request) Successful in 2m56s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m55s
CI / openapi-codegen-check (pull_request) Successful in 2m56s
Общий хук useFullReport(cad): POST /report (200 ready → мгновенный download; 202 building → TanStack-поллинг status 5с с cap 3 мин → честный timeout «Сборка затянулась — попробуйте позже»), авто-download по ready (ref-guard, один раз), скачивание скрытым <a download> — наследует Caddy basic_auth без отдельного blob-fetch. Сброс прогона при смене cad (страховка in-place реюза), elapsed по wall-clock (иммунно к троттлингу фоновых вкладок). Кнопка в PticaReports (вместо honest-disabled «скоро») и ExportButtons — логика не дублируется. Статусы сужены типами из api-types (не руками). Fix: паттерн reports/ из PR-D в .gitignore заякорен в корень (/reports/) — неякоренный матчил и frontend .../ptica/reports/. Refs #2259
This commit is contained in:
parent
034d9f9bca
commit
1312dec0e6
4 changed files with 356 additions and 16 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -89,7 +89,7 @@ data/osrm/*
|
||||||
|
|
||||||
# Full PDF report bind-source (#2259 PR-D) — worker пишет PDF в ./reports:/app/reports.
|
# Full PDF report bind-source (#2259 PR-D) — worker пишет PDF в ./reports:/app/reports.
|
||||||
# Артефакты рантайма, не коммитим (untracked и так выживает при deploy git reset --hard).
|
# Артефакты рантайма, не коммитим (untracked и так выживает при deploy git reset --hard).
|
||||||
reports/
|
/reports/
|
||||||
|
|
||||||
# Log cruft at repo root
|
# Log cruft at repo root
|
||||||
debug.log
|
debug.log
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,14 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Download, FileText } from "lucide-react";
|
import { Download, FileText, FileBarChart } from "lucide-react";
|
||||||
import { API_BASE_URL } from "@/lib/api";
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
import { triggerDownload } from "@/lib/download";
|
import { triggerDownload } from "@/lib/download";
|
||||||
|
import {
|
||||||
|
useFullReport,
|
||||||
|
formatElapsed,
|
||||||
|
formatReportDate,
|
||||||
|
} from "@/hooks/useFullReport";
|
||||||
import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api";
|
import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -51,10 +56,7 @@ function buildCsvRows(
|
||||||
if (egrn) {
|
if (egrn) {
|
||||||
rows.push(
|
rows.push(
|
||||||
["Адрес", egrn.address],
|
["Адрес", egrn.address],
|
||||||
[
|
["Площадь м²", Number.isFinite(egrn.area_m2) ? String(egrn.area_m2) : ""],
|
||||||
"Площадь м²",
|
|
||||||
Number.isFinite(egrn.area_m2) ? String(egrn.area_m2) : "",
|
|
||||||
],
|
|
||||||
["ВРИ", egrn.vri],
|
["ВРИ", egrn.vri],
|
||||||
["Категория", egrn.category],
|
["Категория", egrn.category],
|
||||||
["Форма собственности", egrn.owner_type],
|
["Форма собственности", egrn.owner_type],
|
||||||
|
|
@ -97,6 +99,11 @@ export function ExportButtons({ cad, analyzeData, egrn }: Props) {
|
||||||
const [csvLoading, setCsvLoading] = useState(false);
|
const [csvLoading, setCsvLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Полный PDF-отчёт (#2259 PR-E) — общий хук enqueue → poll → download.
|
||||||
|
const fullReport = useFullReport(cad);
|
||||||
|
const reportBuilding = fullReport.uiState === "building";
|
||||||
|
const reportReady = fullReport.uiState === "ready";
|
||||||
|
|
||||||
async function handlePdfDownload() {
|
async function handlePdfDownload() {
|
||||||
setPdfLoading(true);
|
setPdfLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -193,8 +200,66 @@ export function ExportButtons({ cad, analyzeData, egrn }: Props) {
|
||||||
<Download size={14} strokeWidth={1.5} />
|
<Download size={14} strokeWidth={1.5} />
|
||||||
{csvLoading ? "Генерация..." : "Скачать CSV"}
|
{csvLoading ? "Генерация..." : "Скачать CSV"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Full-report PDF — async job (#2259 PR-E): enqueue → poll → download */}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
reportReady ? fullReport.download() : fullReport.start()
|
||||||
|
}
|
||||||
|
disabled={reportBuilding}
|
||||||
|
aria-label={
|
||||||
|
reportReady
|
||||||
|
? "Скачать полный отчёт PDF"
|
||||||
|
: "Собрать и скачать полный отчёт PDF"
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "6px 12px",
|
||||||
|
height: 32,
|
||||||
|
background: reportBuilding
|
||||||
|
? "var(--bg-card-alt)"
|
||||||
|
: "var(--accent-2)",
|
||||||
|
color: reportBuilding ? "var(--fg-tertiary)" : "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: reportBuilding ? "not-allowed" : "pointer",
|
||||||
|
opacity: reportBuilding ? 0.6 : 1,
|
||||||
|
transition: "opacity 150ms",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileBarChart size={14} strokeWidth={1.5} />
|
||||||
|
{reportBuilding
|
||||||
|
? `Готовится… ${formatElapsed(fullReport.elapsedMs)}`
|
||||||
|
: reportReady
|
||||||
|
? `Скачать отчёт (${formatReportDate(
|
||||||
|
fullReport.reportGeneratedAt,
|
||||||
|
)})`
|
||||||
|
: "Полный отчёт (PDF)"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Full-report timeout / enqueue-error note (contract has no "failed") */}
|
||||||
|
{fullReport.uiState === "timeout" && (
|
||||||
|
<p
|
||||||
|
style={{ margin: 0, fontSize: 12, color: "var(--warn)" }}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
Сборка затянулась — попробуйте позже
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{fullReport.uiState === "error" && (
|
||||||
|
<p
|
||||||
|
style={{ margin: 0, fontSize: 12, color: "var(--danger)" }}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
Не удалось поставить сборку отчёта — повторите
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
{error && (
|
{error && (
|
||||||
<p
|
<p
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,11 @@ import { FileText, Download, Presentation, FileType, Lock } from "lucide-react";
|
||||||
import { API_BASE_URL } from "@/lib/api";
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
import { triggerDownload } from "@/lib/download";
|
import { triggerDownload } from "@/lib/download";
|
||||||
import { useParcelForecastQuery } from "@/lib/site-finder-api";
|
import { useParcelForecastQuery } from "@/lib/site-finder-api";
|
||||||
|
import {
|
||||||
|
useFullReport,
|
||||||
|
formatElapsed,
|
||||||
|
formatReportDate,
|
||||||
|
} from "@/hooks/useFullReport";
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
import { StatusRow } from "@/components/site-finder/ptica/drawers/DrawerPrimitives";
|
import { StatusRow } from "@/components/site-finder/ptica/drawers/DrawerPrimitives";
|
||||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||||
|
|
@ -130,6 +135,9 @@ export function PticaReports({ cad, analysis }: Props) {
|
||||||
const { data: forecastData } = useParcelForecastQuery(cad);
|
const { data: forecastData } = useParcelForecastQuery(cad);
|
||||||
const forecastReady = forecastData?.status === "ready";
|
const forecastReady = forecastData?.status === "ready";
|
||||||
|
|
||||||
|
// Полный PDF-отчёт (#2259 PR-E) — общий хук enqueue → poll → download.
|
||||||
|
const fullReport = useFullReport(cad);
|
||||||
|
|
||||||
async function handlePdf() {
|
async function handlePdf() {
|
||||||
setBusy("pdf");
|
setBusy("pdf");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -308,21 +316,55 @@ export function PticaReports({ cad, analysis }: Props) {
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Full-report DOCX — NO endpoint → honest disabled «скоро» */}
|
{/* Full-report PDF — REAL async job (#2259 PR-E): enqueue → poll → download */}
|
||||||
<div
|
<button
|
||||||
className={`${styles.exportCard} ${styles.exportCardDisabled}`}
|
type="button"
|
||||||
aria-disabled="true"
|
className={styles.exportCard}
|
||||||
|
onClick={() =>
|
||||||
|
fullReport.uiState === "ready"
|
||||||
|
? fullReport.download()
|
||||||
|
: fullReport.start()
|
||||||
|
}
|
||||||
|
disabled={fullReport.uiState === "building"}
|
||||||
|
aria-label={
|
||||||
|
fullReport.uiState === "ready"
|
||||||
|
? "Скачать полный отчёт в формате PDF"
|
||||||
|
: "Собрать и скачать полный отчёт в формате PDF"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className={`${styles.fmt} ${styles.fmtMuted}`}>DOCX</span>
|
<span className={`${styles.fmt} ${styles.fmtPdf}`}>PDF</span>
|
||||||
<span className={styles.exportMeta}>
|
<span className={styles.exportMeta}>
|
||||||
<span className={styles.exportTitle}>
|
<span className={styles.exportTitle}>
|
||||||
Полный отчёт
|
{fullReport.uiState === "building"
|
||||||
<span className={styles.exportTagSoon}>скоро</span>
|
? `Готовится… ${formatElapsed(fullReport.elapsedMs)}`
|
||||||
|
: fullReport.uiState === "ready"
|
||||||
|
? `Скачать отчёт (${formatReportDate(
|
||||||
|
fullReport.reportGeneratedAt,
|
||||||
|
)})`
|
||||||
|
: "Полный отчёт"}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.exportDesc}>Редактируемый Word</span>
|
<span className={styles.exportDesc}>
|
||||||
|
{fullReport.uiState === "timeout"
|
||||||
|
? "Сборка затянулась — попробуйте позже"
|
||||||
|
: fullReport.uiState === "error"
|
||||||
|
? "Не удалось поставить сборку — повторите"
|
||||||
|
: "Все секции · PDF"}
|
||||||
</span>
|
</span>
|
||||||
<Lock size={16} strokeWidth={1.5} className={styles.exportIco} />
|
</span>
|
||||||
</div>
|
{fullReport.uiState === "building" ? (
|
||||||
|
<span
|
||||||
|
className={styles.pendingPulse}
|
||||||
|
style={{ width: 16, height: 16, borderWidth: 2 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FileText
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
className={styles.exportIco}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Full-report PPTX — NO endpoint → honest disabled «скоро» */}
|
{/* Full-report PPTX — NO endpoint → honest disabled «скоро» */}
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
233
frontend/src/hooks/useFullReport.ts
Normal file
233
frontend/src/hooks/useFullReport.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { API_BASE_URL, apiFetch, apiFetchWithStatus } from "@/lib/api";
|
||||||
|
import type { components } from "@/lib/api-types";
|
||||||
|
|
||||||
|
// Reuse generated OpenAPI types (api-types.ts) — НЕ дублировать руками (#2259 PR-D).
|
||||||
|
export type ReportBuildResponse = components["schemas"]["ReportBuildResponse"];
|
||||||
|
export type ReportStatusResponse =
|
||||||
|
components["schemas"]["ReportStatusResponse"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useFullReport — единый хук кнопки «Полный отчёт (PDF)» (#2259 PR-E).
|
||||||
|
*
|
||||||
|
* Переиспользуется двумя компонентами (ptica/reports/PticaReports «тёмная»
|
||||||
|
* витрина экспортов + analysis/ExportButtons «светлая» страница) — вся логика
|
||||||
|
* enqueue → poll → download живёт здесь, компоненты только рендерят состояние.
|
||||||
|
*
|
||||||
|
* Контракт бэкенда (PR-D #2287):
|
||||||
|
* • POST /api/v1/parcels/{cad}/report
|
||||||
|
* → 202 { status: "building" } джоба поставлена, PDF собирается в фоне
|
||||||
|
* → 200 { status: "ready", ... } готовый кэш уже на диске, качать сразу
|
||||||
|
* • GET /api/v1/parcels/{cad}/report/status
|
||||||
|
* → { status: "none" | "building" | "ready", report_generated_at?, ... }
|
||||||
|
* • GET /api/v1/parcels/{cad}/report/download → application/pdf (attachment)
|
||||||
|
*
|
||||||
|
* «failed»-состояния в контракте НЕТ: если building висит дольше POLL_CAP_MS,
|
||||||
|
* фронт сам останавливает поллинг и показывает «Сборка затянулась — попробуйте
|
||||||
|
* позже» (uiState === "timeout"), кнопка снова активна.
|
||||||
|
*
|
||||||
|
* Скачивание: страница за Caddy basic_auth. Обычная навигация браузера к
|
||||||
|
* /report/download наследует уже авторизованную сессию (cookie/basic-auth),
|
||||||
|
* поэтому качаем через скрытый <a href download> (см. triggerReportDownload) —
|
||||||
|
* не тянем blob отдельным fetch'ем, который пришлось бы отдельно авторизовывать.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Поллинг: тик каждые 5с, cap ~3 мин (36 тиков) → стоп + сообщение.
|
||||||
|
const POLL_INTERVAL_MS = 5_000;
|
||||||
|
const POLL_CAP_MS = 3 * 60 * 1000;
|
||||||
|
|
||||||
|
export type FullReportUiState =
|
||||||
|
| "idle" // ничего не запускалось / ещё не готов
|
||||||
|
| "building" // джоба идёт, поллим status
|
||||||
|
| "ready" // PDF готов, есть report_generated_at
|
||||||
|
| "timeout" // building провисел дольше cap — остановились
|
||||||
|
| "error"; // POST enqueue упал
|
||||||
|
|
||||||
|
export interface UseFullReportResult {
|
||||||
|
uiState: FullReportUiState;
|
||||||
|
/** ISO дата генерации готового отчёта (для подписи «Скачать отчёт (дата)»). */
|
||||||
|
reportGeneratedAt: string | null;
|
||||||
|
/** Прошедшее с начала сборки время, мс (для «Готовится… мм:сс»). */
|
||||||
|
elapsedMs: number;
|
||||||
|
/** Клик по кнопке: enqueue → сразу download если ready, иначе старт поллинга. */
|
||||||
|
start: () => void;
|
||||||
|
/** Ручное скачивание готового PDF (кнопка «Скачать отчёт»). */
|
||||||
|
download: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReportPath(cad: string, suffix: string): string {
|
||||||
|
return `/api/v1/parcels/${encodeURIComponent(cad)}/report${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Навигация браузера к /report/download через скрытый <a download>. Наследует
|
||||||
|
* Caddy basic_auth сессию текущей вкладки — отдельный авторизованный fetch не
|
||||||
|
* нужен. Browser-only (использует document) — вызывать только из обработчика.
|
||||||
|
*/
|
||||||
|
function triggerReportDownload(cad: string): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = `${API_BASE_URL}${buildReportPath(cad, "/download")}`;
|
||||||
|
// Content-Disposition: attachment на бэкенде задаёт имя файла; download-атрибут
|
||||||
|
// без значения лишь подтверждает намерение скачать, не переопределяя имя.
|
||||||
|
a.download = "";
|
||||||
|
a.rel = "noopener";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFullReport(cad: string): UseFullReportResult {
|
||||||
|
// Флаг активного поллинга — заводится на building, гасится на ready/timeout.
|
||||||
|
const [polling, setPolling] = useState(false);
|
||||||
|
const [timedOut, setTimedOut] = useState(false);
|
||||||
|
const [elapsedMs, setElapsedMs] = useState(0);
|
||||||
|
// Момент начала сборки — источник истины для elapsed и cap.
|
||||||
|
const startedAtRef = useRef<number | null>(null);
|
||||||
|
// На ready один раз авто-скачиваем — иначе повторные тики status дёргали бы
|
||||||
|
// загрузку снова.
|
||||||
|
const autoDownloadedRef = useRef(false);
|
||||||
|
|
||||||
|
// ── POST enqueue ────────────────────────────────────────────────────────────
|
||||||
|
const enqueue = useMutation({
|
||||||
|
mutationFn: async (): Promise<ReportBuildResponse> => {
|
||||||
|
// apiFetchWithStatus терпит 202 Accepted без throw'а (как async-джобы §22).
|
||||||
|
const { body } = await apiFetchWithStatus<ReportBuildResponse>(
|
||||||
|
buildReportPath(cad, ""),
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET status polling ────────────────────────────────────────────────────────
|
||||||
|
const statusQuery = useQuery({
|
||||||
|
queryKey: ["full-report-status", cad],
|
||||||
|
queryFn: (): Promise<ReportStatusResponse> =>
|
||||||
|
apiFetch<ReportStatusResponse>(buildReportPath(cad, "/status")),
|
||||||
|
// enabled=false вне активной сборки → нет фонового поллинга и утечки после
|
||||||
|
// ухода со страницы (unmount снимает подписку, а enabled сам гасит тик).
|
||||||
|
enabled: !!cad && polling,
|
||||||
|
// Тик каждые POLL_INTERVAL_MS пока building; на ready — стоп (false).
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
query.state.data?.status === "ready" ? false : POLL_INTERVAL_MS,
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
|
gcTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusData = statusQuery.data;
|
||||||
|
|
||||||
|
// ── Тикающий elapsed + cap-стоп (только пока идёт поллинг) ────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!polling) return;
|
||||||
|
if (startedAtRef.current === null) startedAtRef.current = Date.now();
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const started = startedAtRef.current;
|
||||||
|
if (started === null) return;
|
||||||
|
const el = Date.now() - started;
|
||||||
|
setElapsedMs(el);
|
||||||
|
if (el >= POLL_CAP_MS) {
|
||||||
|
setPolling(false);
|
||||||
|
setTimedOut(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1000);
|
||||||
|
// Cleanup на unmount / смене polling → интервал не течёт после ухода.
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [polling]);
|
||||||
|
|
||||||
|
// ── На ready: авто-download один раз + стоп поллинга ──────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (statusData?.status === "ready" && polling) {
|
||||||
|
setPolling(false);
|
||||||
|
if (!autoDownloadedRef.current) {
|
||||||
|
autoDownloadedRef.current = true;
|
||||||
|
triggerReportDownload(cad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [statusData?.status, polling, cad]);
|
||||||
|
|
||||||
|
// ── Смена участка: полный сброс прогона ──────────────────────────────────────
|
||||||
|
// Оба потребителя живут под route-сегментом [cad] (смена участка = remount),
|
||||||
|
// этот эффект — страховка на случай будущего in-place реюза хука с другим cad.
|
||||||
|
useEffect(() => {
|
||||||
|
setPolling(false);
|
||||||
|
setTimedOut(false);
|
||||||
|
setElapsedMs(0);
|
||||||
|
autoDownloadedRef.current = false;
|
||||||
|
startedAtRef.current = null;
|
||||||
|
}, [cad]);
|
||||||
|
|
||||||
|
// ── Действие: клик по кнопке ──────────────────────────────────────────────────
|
||||||
|
const start = useCallback(() => {
|
||||||
|
// Сброс прошлого прогона (retry после timeout/error).
|
||||||
|
setTimedOut(false);
|
||||||
|
setElapsedMs(0);
|
||||||
|
startedAtRef.current = null;
|
||||||
|
autoDownloadedRef.current = false;
|
||||||
|
|
||||||
|
enqueue.mutate(undefined, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.status === "ready") {
|
||||||
|
// Кэш готов — качаем сразу, поллинг не нужен.
|
||||||
|
autoDownloadedRef.current = true;
|
||||||
|
triggerReportDownload(cad);
|
||||||
|
} else {
|
||||||
|
// building → стартуем поллинг status.
|
||||||
|
startedAtRef.current = Date.now();
|
||||||
|
setPolling(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [enqueue, cad]);
|
||||||
|
|
||||||
|
const download = useCallback(() => {
|
||||||
|
triggerReportDownload(cad);
|
||||||
|
}, [cad]);
|
||||||
|
|
||||||
|
// ── Производное UI-состояние ──────────────────────────────────────────────────
|
||||||
|
let uiState: FullReportUiState = "idle";
|
||||||
|
if (statusData?.status === "ready" || enqueue.data?.status === "ready") {
|
||||||
|
uiState = "ready";
|
||||||
|
} else if (timedOut) {
|
||||||
|
uiState = "timeout";
|
||||||
|
} else if (polling || enqueue.isPending) {
|
||||||
|
uiState = "building";
|
||||||
|
} else if (enqueue.isError) {
|
||||||
|
uiState = "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportGeneratedAt =
|
||||||
|
(uiState === "ready" ? statusData?.report_generated_at : null) ?? null;
|
||||||
|
|
||||||
|
return { uiState, reportGeneratedAt, elapsedMs, start, download };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Хелперы микрокопии кнопки (общие для обоих компонентов) ────────────────────
|
||||||
|
|
||||||
|
/** «мм:сс» из миллисекунд — для «Готовится… 1:05». */
|
||||||
|
export function formatElapsed(ms: number): string {
|
||||||
|
const totalSec = Math.floor(ms / 1000);
|
||||||
|
const min = Math.floor(totalSec / 60);
|
||||||
|
const sec = totalSec % 60;
|
||||||
|
return `${min}:${String(sec).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO → «ru-RU» дата генерации отчёта (репо-конвенция, NaN-fallback). */
|
||||||
|
export function formatReportDate(iso: string | null): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
// new Date(мусор) не бросает, а даёт Invalid Date — try/catch тут мёртв.
|
||||||
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
|
return d.toLocaleDateString("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue