feat(ptica/reports): включить DOCX-скачивание полного отчёта (#2259 R2 UI)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 7s
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 55s
CI / openapi-codegen-check (pull_request) Successful in 1m52s

Полный отчёт отдаёт и PDF, и DOCX одним билдом (backend #2301) — карточка
DOCX была honest-disabled «скоро», теперь неправда. useFullReport
принимает format через pendingFormatRef — один общий прогон
enqueue→poll, формат только у финального download, без дубля стейта;
кто кликнул — тот формат и качается, обе карточки дизейблятся на общий
building. DOCX-карточка ПТИЦА + вторичная DOCX-кнопка на светлой
странице. Легаси-раны без docx_path: HEAD-preflight (same-origin,
наследует basic_auth) ловит любой не-OK и просит пересобрать отчёт
вместо молчаливой закачки страницы ошибки; сетевой сбой preflight —
фолбэк на прямую навигацию. PPTX остаётся «скоро» честно (бэкенда нет).
This commit is contained in:
bot-backend 2026-07-04 03:37:13 +05:00
parent f41ca530c5
commit bdd95063b6
3 changed files with 257 additions and 39 deletions

View file

@ -9,13 +9,14 @@
*/
import { useState } from "react";
import { Download, FileText, FileBarChart } from "lucide-react";
import { Download, FileText, FileBarChart, FileType } from "lucide-react";
import { API_BASE_URL } from "@/lib/api";
import { triggerDownload } from "@/lib/download";
import {
useFullReport,
formatElapsed,
readyReportLabel,
readyDocxLabel,
} from "@/hooks/useFullReport";
import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api";
@ -238,6 +239,46 @@ export function ExportButtons({ cad, analyzeData, egrn }: Props) {
? readyReportLabel(fullReport.reportGeneratedAt)
: "Полный отчёт (PDF)"}
</button>
{/*
Full-report DOCX вторичный компакт рядом с «Полный отчёт (PDF)».
Тот же общий прогон useFullReport (один enqueuepoll даёт pdf+docx),
финальный download в ?format=docx. Компактнее PDF-кнопки (иконка +
«DOCX»), чтобы не перегружать ряд. Building/ready-состояние общее с
PDF-кнопкой (обе дизейблятся на сборке).
*/}
<button
onClick={() =>
reportReady ? fullReport.download("docx") : fullReport.start("docx")
}
disabled={reportBuilding}
aria-label={
reportReady
? "Скачать полный отчёт Word"
: "Собрать и скачать полный отчёт Word"
}
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",
}}
>
<FileType size={14} strokeWidth={1.5} />
{reportReady ? readyDocxLabel(fullReport.reportGeneratedAt) : "DOCX"}
</button>
</div>
{/* Full-report timeout / enqueue-error note (contract has no "failed") */}
@ -257,6 +298,15 @@ export function ExportButtons({ cad, analyzeData, egrn }: Props) {
Не удалось поставить сборку отчёта повторите
</p>
)}
{/* Легаси-ран без docx (собран до PR-F) → download?format=docx = 404. */}
{fullReport.downloadError && (
<p
style={{ margin: 0, fontSize: 12, color: "var(--danger)" }}
role="alert"
>
{fullReport.downloadError}
</p>
)}
{/* Error message */}
{error && (

View file

@ -14,10 +14,17 @@
* being ready (useParcelForecastQuery): until then the cards are disabled
* with an honest «готовится» tag.
*
* Full-report DOCX / PPTX (the prototype's editable-Word / presentation cards)
* have NO backend endpoint the only Word/PowerPoint export the backend ships
* is the §22 forecast above. So those two cards render DISABLED with a «скоро»
* tag (honest we never trigger a download that 404s).
* Full-report DOCX (#2259 R2 PR-F) REAL: GET /report/download?format=docx.
* One report build produces BOTH pdf + docx, so the DOCX card shares the SAME
* useFullReport(cad) run as the full-report PDF card (enqueue poll download)
* clicking either kicks the same job; only the final download format differs
* (remembered by the hook via pendingFormatRef). Legacy runs (built before PR-F)
* carry no docx_path the hook HEAD-preflights the docx URL and, on 404, shows
* «Пересоберите отчёт» instead of silently downloading an HTML error page.
*
* Full-report PPTX (the prototype's presentation card) has NO backend endpoint
* the only PowerPoint export the backend ships is the §22 forecast above. So that
* card renders DISABLED with a «скоро» tag (honest we never trigger a 404).
*
* The «Состав отчёта» list is a visual checklist of the PDF snapshot's sections
* (matches the prototype TOC); the PDF snapshot is the real multi-section
@ -34,6 +41,7 @@ import {
useFullReport,
formatElapsed,
readyReportLabel,
readyDocxLabel,
} from "@/hooks/useFullReport";
import type { ParcelAnalysis } from "@/types/site-finder";
import { StatusRow } from "@/components/site-finder/ptica/drawers/DrawerPrimitives";
@ -364,6 +372,55 @@ export function PticaReports({ cad, analysis }: Props) {
)}
</button>
{/* Full-report DOCX REAL (#2259 R2 PR-F): same run as PDF card,
final download ?format=docx. Building-спиннер общий на прогон. */}
<button
type="button"
className={styles.exportCard}
onClick={() =>
fullReport.uiState === "ready"
? fullReport.download("docx")
: fullReport.start("docx")
}
disabled={fullReport.uiState === "building"}
aria-label={
fullReport.uiState === "ready"
? "Скачать полный отчёт в формате Word"
: "Собрать и скачать полный отчёт в формате Word"
}
>
<span className={`${styles.fmt} ${styles.fmtDocx}`}>DOCX</span>
<span className={styles.exportMeta}>
<span className={styles.exportTitle}>
{fullReport.uiState === "building"
? `Готовится… ${formatElapsed(fullReport.elapsedMs)}`
: fullReport.uiState === "ready"
? readyDocxLabel(fullReport.reportGeneratedAt)
: "Полный отчёт"}
</span>
<span className={styles.exportDesc}>
{fullReport.uiState === "timeout"
? "Сборка затянулась — попробуйте позже"
: fullReport.uiState === "error"
? "Не удалось поставить сборку — повторите"
: "Все секции · Word"}
</span>
</span>
{fullReport.uiState === "building" ? (
<span
className={styles.pendingPulse}
style={{ width: 16, height: 16, borderWidth: 2 }}
aria-hidden="true"
/>
) : (
<FileType
size={16}
strokeWidth={1.5}
className={styles.exportIco}
/>
)}
</button>
{/* Full-report PPTX — NO endpoint → honest disabled «скоро» */}
<div
className={`${styles.exportCard} ${styles.exportCardDisabled}`}
@ -387,6 +444,14 @@ export function PticaReports({ cad, analysis }: Props) {
</p>
)}
{/* Легаси-раны без docx (собраны до PR-F) download?format=docx = 404.
Хук ловит это HEAD-preflight'ом и просит пересобрать отчёт. */}
{fullReport.downloadError && (
<p className={styles.reportError} role="alert">
{fullReport.downloadError}
</p>
)}
{/* ── Параметры — что войдёт в сгенерированный отчёт ─────────────── */}
<hr className={styles.overlayDivider} />
<h3 className={styles.cardTitle}>

View file

@ -24,7 +24,20 @@ export type ReportStatusResponse =
* 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)
* 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-карточка гоняют ОДИН общий прогон enqueuepoll,
* а какой файл скачать в конце запоминает pendingFormatRef (кто кликнул тот
* формат и качается). Отдельного state под формат НЕТ.
*
* Легаси-раны (собранные до PR-F) не несут docx_path download?format=docx даёт
* 404. Чтобы <a download> не «молча» скачивал HTML-страницу ошибки, DOCX-скачивание
* идёт через HEAD-preflight (triggerReportDownload с preflight): 404 onDownloadError
* («Пересоберите отчёт»), ok навигация <a>. PDF-путь preflight не нужен (файл всегда
* есть при ready), остаётся прямой <a>-навигацией.
*
* «failed»-состояния в контракте НЕТ: если building висит дольше POLL_CAP_MS,
* фронт сам останавливает поллинг и показывает «Сборка затянулась попробуйте
@ -40,6 +53,9 @@ export type ReportStatusResponse =
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
@ -53,25 +69,40 @@ export interface UseFullReportResult {
reportGeneratedAt: string | null;
/** Прошедшее с начала сборки время, мс (для «Готовится… мм:сс»). */
elapsedMs: number;
/** Клик по кнопке: enqueue → сразу download если ready, иначе старт поллинга. */
start: () => void;
/** Ручное скачивание готового PDF (кнопка «Скачать отчёт»). */
download: () => void;
/**
* Клик по кнопке: 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 через скрытый <a download>. Наследует
* Caddy basic_auth сессию текущей вкладки отдельный авторизованный fetch не
* нужен. Browser-only (использует document) вызывать только из обработчика.
*/
function triggerReportDownload(cad: string): void {
function triggerAnchorDownload(cad: string, format: ReportFormat): void {
if (typeof document === "undefined") return;
const a = document.createElement("a");
a.href = `${API_BASE_URL}${buildReportPath(cad, "/download")}`;
a.href = downloadUrl(cad, format);
// Content-Disposition: attachment на бэкенде задаёт имя файла; download-атрибут
// без значения лишь подтверждает намерение скачать, не переопределяя имя.
a.download = "";
@ -81,16 +112,59 @@ function triggerReportDownload(cad: string): void {
document.body.removeChild(a);
}
/**
* Скачать отчёт в `format`. PDF-путь прямая <a>-навигация (файл всегда есть при
* ready). DOCX-путь HEAD-preflight: легаси-раны без docx_path отдают 404, а
* <a download> на 404 «молча» скачал бы HTML-страницу ошибки; ловим здесь и зовём
* onError («Пересоберите отчёт»). HEAD дешевле GET (тело не тянется) и наследует
* ту же basic_auth сессию, что и <a>. Сеть упала не блокируем: пробуем <a>.
*/
async function triggerReportDownload(
cad: string,
format: ReportFormat,
onError: () => void,
): Promise<void> {
if (format === "pdf") {
triggerAnchorDownload(cad, "pdf");
return;
}
try {
const res = await fetch(downloadUrl(cad, "docx"), { method: "HEAD" });
// Любой не-OK (404 легаси-рана, 5xx) — не даём <a> молча скачать страницу ошибки.
if (!res.ok) {
onError();
return;
}
} catch {
// Сетевая ошибка preflight'а (offline / CORS) — не проглатываем download:
// отдаём <a>-навигации, пусть браузер сам разрулит.
}
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<string | null>(null);
// Момент начала сборки — источник истины для elapsed и cap.
const startedAtRef = useRef<number | null>(null);
// На ready один раз авто-скачиваем — иначе повторные тики status дёргали бы
// загрузку снова.
const autoDownloadedRef = useRef(false);
// Формат финального авто-download'а — «кто кликнул, тот формат и качается».
// Ref (не state): читается только внутри обработчиков/эффекта, ре-рендер под
// него не нужен, и это держит один общий прогон без дубля состояния формата.
const pendingFormatRef = useRef<ReportFormat>("pdf");
// Стабильный колбэк ошибки docx-download'а для triggerReportDownload.
const handleDownloadError = useCallback(() => {
setDownloadError(
"Пересоберите отчёт — DOCX для старого прогона отсутствует",
);
}, []);
// ── POST enqueue ────────────────────────────────────────────────────────────
const enqueue = useMutation({
@ -142,16 +216,20 @@ export function useFullReport(cad: string): UseFullReportResult {
return () => window.clearInterval(id);
}, [polling]);
// ── На ready: авто-download один раз + стоп поллинга ──────────────────────────
// ── На ready: авто-download один раз (в запомненном формате) + стоп поллинга ───
useEffect(() => {
if (statusData?.status === "ready" && polling) {
setPolling(false);
if (!autoDownloadedRef.current) {
autoDownloadedRef.current = true;
triggerReportDownload(cad);
void triggerReportDownload(
cad,
pendingFormatRef.current,
handleDownloadError,
);
}
}
}, [statusData?.status, polling, cad]);
}, [statusData?.status, polling, cad, handleDownloadError]);
// ── Смена участка: полный сброс прогона ──────────────────────────────────────
// Оба потребителя живут под route-сегментом [cad] (смена участка = remount),
@ -160,15 +238,21 @@ export function useFullReport(cad: string): UseFullReportResult {
setPolling(false);
setTimedOut(false);
setElapsedMs(0);
setDownloadError(null);
autoDownloadedRef.current = false;
startedAtRef.current = null;
pendingFormatRef.current = "pdf";
}, [cad]);
// ── Действие: клик по кнопке ──────────────────────────────────────────────────
const start = useCallback(() => {
// Сброс прошлого прогона (retry после timeout/error).
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;
@ -177,7 +261,7 @@ export function useFullReport(cad: string): UseFullReportResult {
if (data.status === "ready") {
// Кэш готов — качаем сразу, поллинг не нужен.
autoDownloadedRef.current = true;
triggerReportDownload(cad);
void triggerReportDownload(cad, format, handleDownloadError);
} else {
// building → стартуем поллинг status.
startedAtRef.current = Date.now();
@ -185,11 +269,17 @@ export function useFullReport(cad: string): UseFullReportResult {
}
},
});
}, [enqueue, cad]);
},
[enqueue, cad, handleDownloadError],
);
const download = useCallback(() => {
triggerReportDownload(cad);
}, [cad]);
const download = useCallback(
(format: ReportFormat = "pdf") => {
setDownloadError(null);
void triggerReportDownload(cad, format, handleDownloadError);
},
[cad, handleDownloadError],
);
// ── Производное UI-состояние ──────────────────────────────────────────────────
let uiState: FullReportUiState = "idle";
@ -206,7 +296,14 @@ export function useFullReport(cad: string): UseFullReportResult {
const reportGeneratedAt =
(uiState === "ready" ? statusData?.report_generated_at : null) ?? null;
return { uiState, reportGeneratedAt, elapsedMs, start, download };
return {
uiState,
reportGeneratedAt,
elapsedMs,
start,
download,
downloadError,
};
}
// ── Хелперы микрокопии кнопки (общие для обоих компонентов) ────────────────────
@ -225,6 +322,12 @@ export function readyReportLabel(iso: string | null): string {
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 "";