"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?format=pdf|docx * → application/pdf | application/vnd.openxmlformats-…docx (attachment) * * Один report-билд даёт ОБА файла (PDF + DOCX) — status=ready означает, что оба * готовы для свежих ранов (#2259 R2 PR-F). Формат влияет только на финальный * download: и PDF-, и DOCX-карточка гоняют ОДИН общий прогон enqueue→poll, * а какой файл скачать в конце — запоминает pendingFormatRef (кто кликнул — тот * формат и качается). Отдельного state под формат НЕТ. * * Легаси-раны (собранные до PR-F) не несут docx_path → download?format=docx даёт * 404. Чтобы не «молча» скачивал HTML-страницу ошибки, DOCX-скачивание * идёт через HEAD-preflight (triggerReportDownload с preflight): 404 → onDownloadError * («Пересоберите отчёт»), ok → навигация . PDF-путь preflight не нужен (файл всегда * есть при ready), остаётся прямой -навигацией. * * «failed»-состояния в контракте НЕТ: если building висит дольше POLL_CAP_MS, * фронт сам останавливает поллинг и показывает «Сборка затянулась — попробуйте * позже» (uiState === "timeout"), кнопка снова активна. * * Скачивание: страница за Caddy basic_auth. Обычная навигация браузера к * /report/download наследует уже авторизованную сессию (cookie/basic-auth), * поэтому качаем через скрытый (см. triggerReportDownload) — * не тянем blob отдельным fetch'ем, который пришлось бы отдельно авторизовывать. */ // Поллинг: тик каждые 5с, cap ~3 мин (36 тиков) → стоп + сообщение. const POLL_INTERVAL_MS = 5_000; const POLL_CAP_MS = 3 * 60 * 1000; /** Формат финального download'а. Один прогон enqueue→poll — формат только у скачивания. */ export type ReportFormat = "pdf" | "docx"; 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 в `format` если ready, иначе старт * поллинга (формат запоминается — финальный авто-download будет в нём же). * format по умолчанию "pdf" — обратная совместимость со старыми вызовами. */ start: (format?: ReportFormat) => void; /** Ручное скачивание готового отчёта в `format` (кнопка «Скачать …»). */ download: (format?: ReportFormat) => void; /** * Легаси-раны без docx: download?format=docx вернул 404 → сообщение * «Пересоберите отчёт». null пока ошибки нет. Сбрасывается на новом start(). */ downloadError: string | null; } function buildReportPath(cad: string, suffix: string): string { return `/api/v1/parcels/${encodeURIComponent(cad)}/report${suffix}`; } /** URL download'а с ?format= (pdf — дефолт бэкенда, не гоним лишний query). */ function downloadUrl(cad: string, format: ReportFormat): string { const base = `${API_BASE_URL}${buildReportPath(cad, "/download")}`; return format === "docx" ? `${base}?format=docx` : base; } /** * Навигация браузера к /report/download через скрытый . Наследует * Caddy basic_auth сессию текущей вкладки — отдельный авторизованный fetch не * нужен. Browser-only (использует document) — вызывать только из обработчика. */ function triggerAnchorDownload(cad: string, format: ReportFormat): void { if (typeof document === "undefined") return; const a = document.createElement("a"); a.href = downloadUrl(cad, format); // Content-Disposition: attachment на бэкенде задаёт имя файла; download-атрибут // без значения лишь подтверждает намерение скачать, не переопределяя имя. a.download = ""; a.rel = "noopener"; document.body.appendChild(a); a.click(); document.body.removeChild(a); } /** * Скачать отчёт в `format`. PDF-путь — прямая -навигация (файл всегда есть при * ready). DOCX-путь — HEAD-preflight: легаси-раны без docx_path отдают 404, а * на 404 «молча» скачал бы HTML-страницу ошибки; ловим здесь и зовём * onError («Пересоберите отчёт»). HEAD дешевле GET (тело не тянется) и наследует * ту же basic_auth сессию, что и . Сеть упала — не блокируем: пробуем . */ async function triggerReportDownload( cad: string, format: ReportFormat, onError: (message: string) => void, ): Promise { if (format === "pdf") { triggerAnchorDownload(cad, "pdf"); return; } try { let res = await fetch(downloadUrl(cad, "docx"), { method: "HEAD" }); // 5xx сразу после status=ready — транзиентная proxy-гонка (стресс-свип // 2026-07-04: первый HEAD после ready ловил 502, ретрей через ~1.5с — 200). // Один ретрай, прежде чем показывать пользователю ошибку. if (!res.ok && res.status !== 404) { await new Promise((r) => setTimeout(r, 1500)); res = await fetch(downloadUrl(cad, "docx"), { method: "HEAD" }); } if (res.status === 404) { // Легаси-ран без docx_path — честная просьба пересобрать. onError("Пересоберите отчёт — DOCX для старого прогона отсутствует"); return; } if (!res.ok) { // Устойчивый не-OK (5xx и после ретрая) — не даём молча скачать // страницу ошибки, но и НЕ просим пересобрать (файл скорее всего цел). onError("Не удалось скачать отчёт — повторите через минуту"); return; } } catch { // Сетевая ошибка preflight'а (offline / CORS) — не проглатываем download: // отдаём -навигации, пусть браузер сам разрулит. } triggerAnchorDownload(cad, "docx"); } export function useFullReport(cad: string): UseFullReportResult { // Флаг активного поллинга — заводится на building, гасится на ready/timeout. const [polling, setPolling] = useState(false); const [timedOut, setTimedOut] = useState(false); const [elapsedMs, setElapsedMs] = useState(0); // Легаси-404 при download?format=docx — сообщение «Пересоберите отчёт». const [downloadError, setDownloadError] = useState(null); // Момент начала сборки — источник истины для elapsed и cap. const startedAtRef = useRef(null); // На ready один раз авто-скачиваем — иначе повторные тики status дёргали бы // загрузку снова. const autoDownloadedRef = useRef(false); // Формат финального авто-download'а — «кто кликнул, тот формат и качается». // Ref (не state): читается только внутри обработчиков/эффекта, ре-рендер под // него не нужен, и это держит один общий прогон без дубля состояния формата. const pendingFormatRef = useRef("pdf"); // Стабильный колбэк ошибки docx-download'а для triggerReportDownload. const handleDownloadError = useCallback((message: string) => { setDownloadError(message); }, []); // ── POST enqueue ──────────────────────────────────────────────────────────── const enqueue = useMutation({ mutationFn: async (): Promise => { // apiFetchWithStatus терпит 202 Accepted без throw'а (как async-джобы §22). const { body } = await apiFetchWithStatus( buildReportPath(cad, ""), { method: "POST" }, ); return body; }, }); // ── GET status polling ──────────────────────────────────────────────────────── const statusQuery = useQuery({ queryKey: ["full-report-status", cad], queryFn: (): Promise => apiFetch(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; void triggerReportDownload( cad, pendingFormatRef.current, handleDownloadError, ); } } }, [statusData?.status, polling, cad, handleDownloadError]); // ── Смена участка: полный сброс прогона ────────────────────────────────────── // Оба потребителя живут под route-сегментом [cad] (смена участка = remount), // этот эффект — страховка на случай будущего in-place реюза хука с другим cad. useEffect(() => { setPolling(false); setTimedOut(false); setElapsedMs(0); setDownloadError(null); autoDownloadedRef.current = false; startedAtRef.current = null; pendingFormatRef.current = "pdf"; }, [cad]); // ── Действие: клик по кнопке ────────────────────────────────────────────────── const start = useCallback( (format: ReportFormat = "pdf") => { // Сброс прошлого прогона (retry после timeout/error) + запомнить формат // финального download'а: кто кликнул PDF/DOCX — тот файл и скачается. pendingFormatRef.current = format; setTimedOut(false); setElapsedMs(0); setDownloadError(null); startedAtRef.current = null; autoDownloadedRef.current = false; enqueue.mutate(undefined, { onSuccess: (data) => { if (data.status === "ready") { // Кэш готов — качаем сразу, поллинг не нужен. autoDownloadedRef.current = true; void triggerReportDownload(cad, format, handleDownloadError); } else { // building → стартуем поллинг status. startedAtRef.current = Date.now(); setPolling(true); } }, }); }, [enqueue, cad, handleDownloadError], ); const download = useCallback( (format: ReportFormat = "pdf") => { setDownloadError(null); void triggerReportDownload(cad, format, handleDownloadError); }, [cad, handleDownloadError], ); // ── Производное 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 ?? enqueue.data?.report_generated_at) : null) ?? null; return { uiState, reportGeneratedAt, elapsedMs, start, download, downloadError, }; } // ── Хелперы микрокопии кнопки (общие для обоих компонентов) ──────────────────── /** «мм:сс» из миллисекунд — для «Готовится… 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")}`; } /** «Скачать отчёт (дата)»; на fast-path из кэша даты ещё нет — без пустых скобок. */ export function readyReportLabel(iso: string | null): string { const d = formatReportDate(iso); return d ? `Скачать отчёт (${d})` : "Скачать отчёт"; } /** «Скачать DOCX (дата)» — параллель readyReportLabel для DOCX-карточки. */ export function readyDocxLabel(iso: string | null): string { const d = formatReportDate(iso); return d ? `Скачать DOCX (${d})` : "Скачать DOCX"; } /** 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", }); }