89 lines
5.9 KiB
TypeScript
89 lines
5.9 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { useJiraRefresh } from "../../shell/refresh.ts";
|
||
import { refreshText } from "../../shell/refreshStatus.ts";
|
||
import { applyTheme, readTheme, saveTheme, THEME_NAMES, THEMES, type Theme } from "../../shell/theme.ts";
|
||
import { statusText, type BoardStatus } from "../status.ts";
|
||
import type { BoardModel, Visible } from "../types.ts";
|
||
import { NOT_IN_HUB } from "../constants.ts";
|
||
import { Dropdown } from "../../ui/index.ts";
|
||
|
||
interface ToolbarProps {
|
||
label: string; title: string; status: BoardStatus | null; byDate: boolean; snap: boolean;
|
||
model: BoardModel; vis: Visible; problemCount: number; problemsOpen: boolean; tableOpen: boolean;
|
||
onReload(): void; onFile(name: string, text: string): void; onProblems(): void;
|
||
onExport(): void; onTable(): void; onSnap(): void; onReset(): void;
|
||
}
|
||
|
||
export function Toolbar(p: ToolbarProps) {
|
||
const [theme, setTheme] = useState<Theme>(readTheme);
|
||
const [confirm, setConfirm] = useState(false);
|
||
useEffect(() => { if (!confirm) return; const t = setTimeout(() => setConfirm(false), 8000); return () => clearTimeout(t); }, [confirm]);
|
||
const refresh = useJiraRefresh(); // кнопка и ход пакета временно здесь; в верхней панели — с Task 5
|
||
const rt = refresh.event ? refreshText(refresh.event) : null;
|
||
const st = p.status ? statusText(p.status) : rt ? { text: rt.text, warn: rt.tone === "error" || rt.tone === "warning" } : null;
|
||
const m = p.model, v = p.vis, r = v.removed;
|
||
const coreShown = v.nodes.filter((n) => !n.dim).length, ctxShown = v.nodes.length - coreShown;
|
||
const nextTheme = () => { const t = THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length]; setTheme(t); saveTheme(t); applyTheme(t); };
|
||
return (
|
||
<div className="top">
|
||
<h1>Доска связей</h1>
|
||
<span className="src">
|
||
<span className="file-name" title={p.title}>{p.label}</span>
|
||
<button type="button" className="link" title="Загрузить снимок зеркала хаба заново, без обращения к Jira" onClick={p.onReload}>Перечитать</button>
|
||
<button type="button" className="link" disabled={refresh.busy} onClick={() => void refresh.start()}
|
||
title="Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira">Обновить из Jira</button>
|
||
<label className="link" title="CSV из фильтра Jira или JSON, выгруженный этой доской. JSON при работе с хабом переносит только расстановку карточек, дорожки и фильтры; CSV показывает снимок файла до «Перечитать»">
|
||
файл…
|
||
<input type="file" accept=".csv,.json,text/csv,application/json" hidden onChange={async (e) => {
|
||
const f = e.target.files?.[0];
|
||
e.target.value = "";
|
||
if (f) p.onFile(f.name, await f.text());
|
||
}} />
|
||
</label>
|
||
</span>
|
||
{st && <span className={"stats" + (st.warn ? " warn" : "")} role="status">{st.text}</span>}
|
||
<span className="stats">
|
||
<b>{coreShown}</b> задач из {m.nodes.filter((n) => !n.stub).length}
|
||
{ctxShown > 0 && <> · <b>+{ctxShown}</b> соседи по связям</>}
|
||
{" · "}<b>{v.links.length}</b> связей из {m.links.length}
|
||
{r.loose > 0 && <> · скрыто без связей: {r.loose}</>}
|
||
</span>
|
||
<span className="spacer" />
|
||
<button className="btn" type="button" aria-pressed={p.problemsOpen} onClick={p.onProblems}
|
||
title="Сроки блокеров, задачи без исполнителя, передачи между людьми">
|
||
Проблемы <span className={"n" + (p.problemCount ? "" : " zero")}>{p.problemCount}</span>
|
||
</button>
|
||
<Dropdown className="more" button={({ open, toggle }) => (
|
||
<button type="button" className="btn more-btn" aria-haspopup="menu" aria-expanded={open} title="Ещё" onClick={toggle}>⋯</button>
|
||
)}>
|
||
{(close) => (
|
||
<>
|
||
<button type="button" onClick={() => { close(); p.onExport(); }}>Экспорт JSON</button>
|
||
<button type="button" aria-pressed={p.tableOpen} onClick={p.onTable}>Таблица связей</button>
|
||
<button type="button" aria-pressed={p.snap} onClick={p.onSnap}>Привязка к сетке</button>
|
||
<button type="button" onClick={nextTheme}>Тема: {THEME_NAMES[theme]}</button>
|
||
<hr />
|
||
{!confirm ? <button type="button" className="danger" onClick={() => setConfirm(true)}>Сбросить раскладку</button> : (
|
||
<div className="confirm">
|
||
<span>Вернуть автоматическую раскладку {p.byDate ? "режима «по датам»" : "(обычную; раскладка «по датам» останется)"}, порядок и раскрытие дорожек?</span>
|
||
<div className="row">
|
||
<button type="button" className="btn yes" onClick={() => { p.onReset(); setConfirm(false); close(); }}>Да, сбросить</button>
|
||
<button type="button" className="btn no" onClick={() => setConfirm(false)}>Нет</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</Dropdown>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// предупреждения о данных под шапкой (строки 878–880)
|
||
export function Notice({ model }: { model: BoardModel }) {
|
||
const notes = [...model.problems];
|
||
if (model.missing) notes.push(`упомянуты в связях, но в ${model.stubLane === NOT_IN_HUB ? "зеркале хаба" : "файле"} отсутствуют: ${model.missing}`);
|
||
if (!notes.length) return null;
|
||
return <div className="notice">{notes.map((n) => <span key={n}>{n}</span>)}</div>;
|
||
}
|