90 lines
5.3 KiB
TypeScript
90 lines
5.3 KiB
TypeScript
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, Toolbar } from "./components/Toolbar.tsx";
|
||
import { dictsFromSettings } from "./dicts.ts";
|
||
import { exportFileName, exportJson } from "./exportJson.ts";
|
||
import { todayIso } from "./model/util.ts";
|
||
import { useBoardPrefs } from "./useBoardPrefs.ts";
|
||
import { useBoardSource } from "./useBoardSource.ts";
|
||
import "./board.css";
|
||
|
||
export function BoardPage() {
|
||
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);
|
||
const prefs = useBoardPrefs(src.model);
|
||
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 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 <p className="warn" data-testid="page-board">Настройки хаба не загрузились: {settings.error.message}</p>;
|
||
if (d && d.missing.length) {
|
||
return (
|
||
<div className="notice" role="alert" data-testid="page-board">
|
||
В настройках хаба нет {d.missing.join(" и ")} — доска не может перевести виды работ и типы связей Jira. Задайте
|
||
словари в config.example.json и выполните <code>uv run jira-hub init --config …</code> или запишите их через PUT /settings.
|
||
</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}>
|
||
<Toolbar label={src.label} title={src.title} status={src.status} refreshing={src.refreshing} 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} onRefresh={() => void src.refreshFromJira()} 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} />
|
||
<Notice model={src.model} />
|
||
<Legend prefs={prefs} />
|
||
</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>
|
||
);
|
||
}
|