"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",
});
}