This commit is contained in:
parent
f41ca530c5
commit
49304a78bc
3 changed files with 257 additions and 39 deletions
|
|
@ -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 (один enqueue→poll даёт 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 && (
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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-карточка гоняют ОДИН общий прогон enqueue→poll,
|
||||
* а какой файл скачать в конце — запоминает 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,36 +238,48 @@ 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).
|
||||
setTimedOut(false);
|
||||
setElapsedMs(0);
|
||||
startedAtRef.current = null;
|
||||
autoDownloadedRef.current = false;
|
||||
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;
|
||||
triggerReportDownload(cad);
|
||||
} else {
|
||||
// building → стартуем поллинг status.
|
||||
startedAtRef.current = Date.now();
|
||||
setPolling(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [enqueue, cad]);
|
||||
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(() => {
|
||||
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 "";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue