sdlc-hub/web/src/board/BoardPage.tsx
Andrey Epifancev 0a5ca2e00a fix(web): правки по финальному ревью: пометка хаба на доске, кнопка в загрузке, панель данных, контраст предупреждений (#65)
I-1 пометка «Хаб · данные на» убрана с доски; I-2 кнопка в loading сохраняет вид варианта;
I-3 статус пакета — постоянный live-регион, не сдвигает кнопку и высоту панели; I-4 текст
предупреждений затемнён через color-mix; M-1 точка «агент офлайн» и событие — предупреждение;
M-7 ошибка настроек в Notice; M-9 убраны IconGrid и IconReset; M-15 IBM Plex Mono 600;
M-16 aria-controls только при раскрытой сетке фильтров.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:52:58 +04:00

93 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useMemo, useRef, useState } from "react";
import { useClosedStatuses } from "../api/closed.ts";
import { useSettings } from "../api/queries.ts";
import { computeVisible, emptyReason } from "./analysis/visible.ts";
import { BoardCanvas } from "./components/BoardCanvas.tsx";
import { ExportBox } from "./components/ExportBox.tsx";
import { FilterBar } from "./components/FilterBar.tsx";
import { Legend } from "./components/Legend.tsx";
import { LinkTable } from "./components/LinkTable.tsx";
import { problemCount, ProblemsDrawer } from "./components/ProblemsDrawer.tsx";
import { Notice } from "../ui/index.ts";
import { BoardHeader, BoardNotes } from "./components/BoardHeader.tsx";
import { dictsFromSettings } from "./dicts.ts";
import { exportFileName, exportJson } from "./exportJson.ts";
import { todayIso } from "./model/util.ts";
import { readLegendOpen, saveLegendOpen } from "./legendPref.ts";
import { useBoardPrefs } from "./useBoardPrefs.ts";
import { useBoardSource } from "./useBoardSource.ts";
import "./board.css";
export function BoardPage({ project }: { project: string }) {
const settings = useSettings();
const closedStatuses = useClosedStatuses();
const d = useMemo(() => (settings.data ? dictsFromSettings(settings.data) : null), [settings.data]);
const src = useBoardSource(d && !d.missing.length ? d.dicts : null, project);
const prefs = useBoardPrefs(src.model, project);
const today = todayIso();
const vis = useMemo(() => computeVisible(src.model, prefs.filters, prefs.store.laneOrder, today),
[src.model, prefs.filters, prefs.store.laneOrder, today]);
const [exportText, setExportText] = useState<string | null>(null);
const [legendOpen, setLegendOpen] = useState(readLegendOpen);
const saveRef = useRef<(() => void) | null>(null);
// высота закреплённой шапки — ящик «Проблемы» и полоса дат липнут под ней (строки 1016–1019); при уходе — снимаем
const headRef = useCallback((el: HTMLDivElement | null) => {
if (!el) return;
const root = document.documentElement.style;
const set = () => root.setProperty("--head-h", (getComputedStyle(el).position === "sticky" ? el.offsetHeight : 0) + "px");
set();
const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(set);
ro?.observe(el);
return () => { ro?.disconnect(); root.removeProperty("--head-h"); };
}, []);
if (settings.isPending) return <p className="hint" data-testid="page-board">Загрузка настроек хаба…</p>;
if (settings.error) return <div data-testid="page-board"><Notice tone="error">Настройки хаба не загрузились: {settings.error.message}</Notice></div>;
if (d && d.missing.length) {
return (
<div data-testid="page-board"><Notice tone="error">
В настройках хаба нет {d.missing.join(" и ")} — доска не может перевести виды работ и типы связей Jira. Задайте
словари в config.example.json и выполните <code>uv run jira-hub init --config …</code> или запишите их через PUT /settings.
</Notice></div>
);
}
const onExport = () => {
saveRef.current?.();
const now = new Date().toISOString();
const text = JSON.stringify(exportJson(src.model, vis, prefs.filters, now), null, 2);
setExportText(text);
try { // в обычном браузере файл скачается; если нет — остаются текст и «Копировать»
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([text], { type: "application/json" }));
a.download = exportFileName(src.model, now);
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
} catch { /* остаётся текст */ }
};
const onFile = (name: string, text: string) => { const r = src.loadFile(name, text); if (r) prefs.applyJson(r); };
return (
<div data-testid="page-board">
<div className="sticky" ref={headRef}>
<BoardHeader label={src.label} title={src.title} source={src.source} fileName={src.model.fileName} status={src.status} byDate={prefs.filters.byDate}
snap={prefs.snap} model={src.model} vis={vis} problemCount={problemCount(vis.problems)} problemsOpen={prefs.problems}
tableOpen={prefs.table} onReload={src.reload} onFile={onFile}
onProblems={() => prefs.toggle("problems")} onExport={onExport} onTable={() => prefs.toggle("table")}
onSnap={() => prefs.toggle("snap")} onReset={prefs.resetLayout} />
<FilterBar model={src.model} prefs={prefs} />
<BoardNotes model={src.model} />
<Legend prefs={prefs} open={legendOpen} onToggle={() => { const n = !legendOpen; setLegendOpen(n); saveLegendOpen(n); }} />
</div>
{exportText !== null && <ExportBox text={exportText} onClose={() => setExportText(null)} />}
<BoardCanvas vis={vis} byDate={prefs.filters.byDate} snap={prefs.snap} today={today} store={prefs.store}
sprintSel={prefs.filters.sprints} emptyText={vis.nodes.length ? "" : emptyReason(src.model, vis, prefs.filters)} closedStatuses={closedStatuses}
onToggleLane={prefs.toggleLane} onMoveLane={(name, to) => prefs.moveLane(vis.lanes.map((l) => l.name), name, to)}
onColWidth={prefs.setColWidth} saveRef={saveRef}>
{prefs.problems && <ProblemsDrawer problems={vis.problems} onClose={() => prefs.toggle("problems")} />}
</BoardCanvas>
{prefs.table && <LinkTable links={vis.links} />}
</div>
);
}