feat(web): шапка доски, панель управления по группам, сворачиваемая легенда (#65)
BoardHeader вместо Toolbar, панель управления в Panel, легенда сворачивается (jira-hub-legend), ящик проблем, экспорт и таблица связей в Panel. Dropdown: явный проп menu; тест фонового обновления проектов мокает GET /batches/7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9e480cceaf
commit
cec8beecfb
@ -35,7 +35,7 @@ test("растяжка за уголок и возврат размера дво
|
||||
});
|
||||
|
||||
test("«по датам» — колонки дат, подписи и стрелки", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "▥ по датам" }).click();
|
||||
await page.getByRole("button", { name: "По датам" }).click();
|
||||
await expect(page.locator(".dcol").first()).toBeVisible();
|
||||
await expect(page.locator(".dates-track .dlabel").first()).toBeVisible();
|
||||
await expect(page.locator("svg.edges path")).not.toHaveCount(0);
|
||||
|
||||
@ -28,7 +28,7 @@ test("высота карточек берётся по содержимому
|
||||
|
||||
test("при открытой панели «Проблемы» полоса дат по ширине равна обёртке холста", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.getByRole("button", { name: "▥ по датам" }).click();
|
||||
await page.getByRole("button", { name: "По датам" }).click();
|
||||
await expect(page.locator(".dates-in")).toBeVisible();
|
||||
for (const open of [false, true]) {
|
||||
if (open) {
|
||||
@ -47,22 +47,22 @@ test("смена спринта и исполнителя, убирающая к
|
||||
await login(page);
|
||||
await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
|
||||
const total = await page.locator(".board .card[data-key]").count();
|
||||
const dd = (i: number) => page.locator(".bar .dd-btn").nth(i); // 0 — спринты, 1 — исполнители
|
||||
const dd = (i: number) => page.locator(".board-controls .multi-btn").nth(i); // 0 — спринты, 1 — исполнители
|
||||
for (const [i, name] of [[0, "Спринт 3"], [1, "Сидорова"]] as const) {
|
||||
await dd(i).click();
|
||||
await page.getByRole("button", { name: "Снять выбор" }).click();
|
||||
await page.locator(".dd-item", { hasText: name }).first().locator("input").check();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("h1")).toHaveText("Доска связей");
|
||||
await expect(page.locator(".top")).toBeVisible();
|
||||
await expect(page.locator(".sticky .ui-page-header")).toBeVisible();
|
||||
await expect(page.locator(".board")).toBeVisible();
|
||||
await expect.poll(() => page.locator(".board .card[data-key]:not(.dim)").count(), `фильтр «${name}»`).toBeLessThan(total);
|
||||
await dd(i).click();
|
||||
await page.getByRole("button", { name: "Снять выбор" }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
await page.getByRole("button", { name: "▥ по датам" }).click();
|
||||
await expect(page.locator(".top")).toBeVisible();
|
||||
await page.getByRole("button", { name: "По датам" }).click();
|
||||
await expect(page.locator(".sticky .ui-page-header")).toBeVisible();
|
||||
expect(errs).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@ -63,7 +63,7 @@ describe("страница доски", () => {
|
||||
hub();
|
||||
page();
|
||||
await screen.findByText(/задач из 9/);
|
||||
const nb = screen.getByRole("button", { name: "+ соседи по связям" });
|
||||
const nb = screen.getByRole("button", { name: "Соседи по связям" });
|
||||
await userEvent.click(nb);
|
||||
expect(nb).toHaveAttribute("aria-pressed", "true");
|
||||
expect(localStorage.getItem("graph-neighbors")).toBe("1");
|
||||
@ -78,13 +78,23 @@ describe("страница доски", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: /Проблемы/ }));
|
||||
expect(screen.getByText("Проблемы на доске")).toBeInTheDocument();
|
||||
expect(localStorage.getItem("graph-prob")).toBe("1");
|
||||
expect(screen.getByRole("button", { name: /^Проблемы/ })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("кнопка «Проблемы» несёт счётчик и aria-pressed", async () => {
|
||||
hub();
|
||||
page();
|
||||
await screen.findByText(/задач из 9/);
|
||||
const b = screen.getByRole("button", { name: /^Проблемы/ });
|
||||
expect(b).toHaveAttribute("aria-pressed", "false");
|
||||
expect(within(b).getByText(/^\d+$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("экспорт JSON показывает выгрузку arm-ka-links/1 с раскладкой", async () => {
|
||||
hub();
|
||||
page();
|
||||
await screen.findByText(/задач из 9/);
|
||||
await userEvent.click(screen.getByTitle("Ещё"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ещё" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Экспорт JSON" }));
|
||||
const text = (screen.getByRole("textbox") as HTMLTextAreaElement).value;
|
||||
expect(JSON.parse(text)).toMatchObject({ format: "arm-ka-links/1", source: "Хаб" });
|
||||
@ -96,7 +106,7 @@ describe("страница доски", () => {
|
||||
localStorage.setItem("graph-pos:board", '{"AURORA-9001":{"x":1,"ry":40}}');
|
||||
page();
|
||||
await screen.findByText(/задач из 9/);
|
||||
await userEvent.click(screen.getByTitle("Ещё"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ещё" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Сбросить раскладку" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Да, сбросить" }));
|
||||
expect(localStorage.getItem("graph-pos:board")).toBeNull();
|
||||
@ -108,7 +118,42 @@ describe("страница доски", () => {
|
||||
await screen.findByText(/задач из 9/);
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files: [new File([fx("jira-export.csv")], "jira-export.csv", { type: "text/csv" })] } });
|
||||
expect(await screen.findByText("jira-export.csv (из файла)")).toBeInTheDocument();
|
||||
expect(await screen.findByText("из файла: jira-export.csv")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Вернуться к данным хаба" }));
|
||||
expect(await screen.findByText(/^Хаб · данные на/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^из файла:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("«Загрузить файл…» в меню «Ещё» открывает выбор файла", async () => {
|
||||
hub();
|
||||
page();
|
||||
await screen.findByText(/задач из 9/);
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const click = vi.spyOn(input, "click");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ещё" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Загрузить файл…" }));
|
||||
expect(click).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("свёрнутая легенда: скрыт «тестирование», «Легенда» свёрнута, после перемонтирования свёрнута, «скрыто: 1»", async () => {
|
||||
hub();
|
||||
const r = page();
|
||||
await screen.findByText(/задач из 9/);
|
||||
const legend = () => document.querySelector(".legend");
|
||||
await userEvent.click(within(legend() as HTMLElement).getByRole("button", { name: /тестирование/ }));
|
||||
expect(screen.getByText("скрыто: 1")).toBeInTheDocument();
|
||||
const toggle = screen.getByRole("button", { name: /^Легенда/ });
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
await userEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(legend()).toBeNull();
|
||||
expect(localStorage.getItem("jira-hub-legend")).toBe("0");
|
||||
r.unmount();
|
||||
page();
|
||||
await screen.findByText(/задач из 9/);
|
||||
expect(screen.getByRole("button", { name: /^Легенда/ })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(legend()).toBeNull();
|
||||
expect(screen.getByText("скрыто: 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("доска не рисует «Обновить из Jira» и ход пакета: они в верхней панели", async () => {
|
||||
@ -116,7 +161,9 @@ describe("страница доски", () => {
|
||||
page();
|
||||
await screen.findByText(/задач из 9/);
|
||||
expect(screen.queryByRole("button", { name: "Обновить из Jira" })).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTitle("Ещё"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ещё" }));
|
||||
expect(screen.getByRole("button", { name: "Ещё" })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("button", { name: "Перечитать" })).toBeInTheDocument(); // меню действительно открыто
|
||||
expect(screen.queryByRole("button", { name: /^Тема/ })).not.toBeInTheDocument();
|
||||
});
|
||||
it("заголовок страницы — h1, в шапке label; строка состояния — только события доски", async () => {
|
||||
@ -147,7 +194,7 @@ describe("страница доски", () => {
|
||||
renderWithProviders(<StrictMode><BoardPage project="AWPCA" /></StrictMode>, { route: "/board", path: "/board" });
|
||||
await screen.findByText(/^Хаб · данные на/);
|
||||
const set = vi.spyOn(Storage.prototype, "setItem");
|
||||
fireEvent.click(screen.getByRole("button", { name: /критический путь/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Критический путь" }));
|
||||
expect(set.mock.calls.filter(([k]) => k === "graph-crit")).toEqual([["graph-crit", "1"]]);
|
||||
});
|
||||
|
||||
|
||||
@ -8,10 +8,12 @@ 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 { 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";
|
||||
@ -26,6 +28,7 @@ export function BoardPage({ project }: { project: string }) {
|
||||
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) => {
|
||||
@ -42,10 +45,10 @@ export function BoardPage({ project }: { project: string }) {
|
||||
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">
|
||||
<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.
|
||||
</div>
|
||||
</Notice></div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -67,14 +70,14 @@ export function BoardPage({ project }: { project: string }) {
|
||||
return (
|
||||
<div data-testid="page-board">
|
||||
<div className="sticky" ref={headRef}>
|
||||
<Toolbar label={src.label} title={src.title} status={src.status} byDate={prefs.filters.byDate}
|
||||
<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} />
|
||||
<Notice model={src.model} />
|
||||
<Legend 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}
|
||||
|
||||
@ -1,14 +1,31 @@
|
||||
/* 3. Закреплённая шапка: название, файл, статистика, кнопка проблем, меню; под ней строка управления и легенда */
|
||||
.sticky{position:sticky;top:env(safe-area-inset-top,0px);z-index:10;background:var(--bg);padding-block:12px 8px;margin:0 0 8px;box-shadow:0 1px 0 var(--line)}
|
||||
.top{display:flex;align-items:center;gap:6px 14px;flex-wrap:wrap;margin:0 0 8px;min-height:34px}
|
||||
.src{display:inline-flex;align-items:baseline;gap:8px;min-width:0}
|
||||
.file-name{font-size:14px;color:var(--muted);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.stats{font-size:14px;color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.stats b{color:var(--fg);font-weight:500}
|
||||
/* 5. Строка управления, предупреждения о файле, легенда-фильтр */
|
||||
.bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 8px}
|
||||
.notice{display:flex;flex-wrap:wrap;gap:4px 16px;font-size:13.5px;color:var(--p-high);margin:0 0 6px}
|
||||
.legend{display:flex;flex-wrap:wrap;align-items:center;gap:2px 12px;margin:0;font-size:13px;color:var(--muted)}
|
||||
/* 3. Закреплённая шапка: заголовок и действия (PageHeader), панель управления (Panel), предупреждения, легенда */
|
||||
.sticky{position:sticky;top:env(safe-area-inset-top,0px);z-index:10;background:var(--bg);padding-block:var(--space-4) var(--space-2);margin:0 0 var(--space-2);box-shadow:0 1px 0 var(--line)}
|
||||
.sticky .ui-page-header{margin-bottom:var(--space-3);gap:var(--space-2)}
|
||||
.stats{font-variant-numeric:tabular-nums}
|
||||
.stats b{color:var(--color-text-primary);font-weight:600}
|
||||
.board-src{display:flex;align-items:center;flex-wrap:wrap;gap:var(--space-2) var(--space-3);min-height:var(--lh-body)}
|
||||
.board-src:empty{display:none}
|
||||
.board-src__label{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary);max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.board-event{font-size:var(--fs-body);line-height:var(--lh-body);color:var(--color-text-secondary)}
|
||||
.board-event--warn{color:var(--color-status-warning)}
|
||||
.menu__label{display:inline-flex;align-items:center;gap:var(--space-2)}
|
||||
.confirm{display:flex;flex-direction:column;gap:var(--space-2);padding:var(--space-2) var(--space-3);font-size:var(--fs-body);line-height:var(--lh-body);max-width:280px}
|
||||
.confirm__row{display:flex;gap:var(--space-2)}
|
||||
.menu .confirm .ui-btn{width:auto;flex:1 1 0;justify-content:center;border-radius:var(--radius-round);min-height:var(--control-h);padding:0 var(--space-3);font-weight:600}
|
||||
.menu .confirm .ui-btn--danger{background:var(--color-action-danger);color:var(--color-text-on-primary)}
|
||||
.menu .confirm .ui-btn--secondary{background:transparent;border:1px solid var(--color-border-strong)}
|
||||
/* 5. Панель управления: выбор спринтов и исполнителей, группы «Показать» и «Режим» */
|
||||
.board-controls{display:flex;align-items:center;flex-wrap:wrap;gap:var(--space-2) var(--space-3);padding:var(--space-3) var(--space-4);margin:0 0 var(--space-2)}
|
||||
.board-controls__group{display:flex;align-items:center;flex-wrap:wrap;gap:var(--space-2)}
|
||||
.board-controls__label{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)}
|
||||
.board-controls__sep{width:1px;align-self:stretch;min-height:var(--control-h);background:var(--color-border-default)}
|
||||
.board-controls__text{font-size:var(--fs-body);color:var(--color-text-secondary)}
|
||||
.multi-btn{display:inline-flex;align-items:center;justify-content:space-between;gap:var(--space-2);width:auto;min-width:180px;max-width:360px;text-align:left;cursor:pointer}
|
||||
.multi-btn .lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.multi-btn[aria-expanded="true"]{border-color:var(--color-action-primary)}
|
||||
.legend-box{margin:0}
|
||||
.legend-head{display:flex;align-items:center;gap:var(--space-2)}
|
||||
.legend{display:flex;flex-wrap:wrap;align-items:center;gap:2px 12px;margin:var(--space-1) 0 0;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)}
|
||||
.legend span{display:inline-flex;align-items:center;gap:6px}
|
||||
.legend .sep{width:1px;height:14px;background:var(--line)}
|
||||
.legend .hint{margin-left:auto}
|
||||
@ -30,7 +47,6 @@ svg.edges,svg.guides{position:absolute;inset:0;width:100%;height:100%;pointer-ev
|
||||
svg.guides{z-index:3}
|
||||
svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
|
||||
.empty{padding:40px 16px;color:var(--muted);text-align:center}
|
||||
.bar>span:empty{display:none}
|
||||
#hubState{white-space:normal;min-width:0}
|
||||
.login{max-width:560px;margin:48px auto;padding:20px 24px;background:var(--panel);border:1px solid var(--line);border-radius:8px}
|
||||
.login h2{margin:0 0 8px;font-size:17px;font-weight:600}
|
||||
@ -128,32 +144,31 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
|
||||
.who.none::before{background:transparent;border:1px solid var(--muted)}
|
||||
|
||||
/* 7. Панель проблем справа от доски, липнет под шапкой и прокручивается внутри себя */
|
||||
.drawer{flex:0 0 340px;max-width:40%;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 12px;overflow:auto;font-size:14px;box-sizing:border-box;align-self:flex-start;position:sticky;top:calc(var(--head-h,0px) + 8px);max-height:calc(100vh - var(--head-h,0px) - 24px)}
|
||||
.drawer-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||
.drawer-head b{font-weight:600}
|
||||
.drawer h4{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500;margin:12px 0 4px}
|
||||
.drawer{flex:0 0 340px;max-width:40%;overflow:auto;box-sizing:border-box;align-self:flex-start;position:sticky;top:calc(var(--head-h,0px) + 8px);max-height:calc(100vh - var(--head-h,0px) - 24px);font-size:var(--fs-body)}
|
||||
.drawer-head{display:flex;justify-content:space-between;align-items:center;gap:var(--space-2)}
|
||||
.board-h2{margin:0;font-size:var(--fs-lg);line-height:var(--lh-lg);font-weight:600;color:var(--color-text-primary)}
|
||||
.board-h3{margin:var(--space-3) 0 var(--space-1);font-size:var(--fs-caption);line-height:var(--lh-caption);font-weight:600;color:var(--color-text-secondary)}
|
||||
.drawer ul{margin:0;padding-left:18px}
|
||||
.drawer li{margin:3px 0}
|
||||
.drawer .k{font-family:var(--font-mono);font-size:13px;color:var(--accent);text-decoration:none}
|
||||
.drawer .ok{color:var(--k-test);margin:8px 0 0}
|
||||
.drawer .k{font-family:var(--font-mono);font-size:13px;color:var(--color-action-primary);text-decoration:none}
|
||||
.drawer .ok{color:var(--color-status-success);margin:var(--space-2) 0 0}
|
||||
.drawer .why{color:var(--muted)}
|
||||
|
||||
/* 8. Экспорт JSON и таблица связей (включаются из меню «⋯») */
|
||||
.export{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin:0 0 8px;display:flex;flex-direction:column;gap:8px}
|
||||
.export textarea{font-family:var(--font-mono);font-size:11.5px;width:100%;box-sizing:border-box;height:180px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:8px;resize:vertical}
|
||||
details{margin-top:12px}
|
||||
summary{cursor:pointer;color:var(--muted);font-size:14px}
|
||||
.tbl{max-width:100%;overflow-x:auto;margin-top:8px}
|
||||
table{border-collapse:collapse;font-size:14px;min-width:520px}
|
||||
/* 8. Экспорт JSON и таблица связей (включаются из меню «Ещё») */
|
||||
.export{display:flex;flex-direction:column;gap:var(--space-2);margin:0 0 var(--space-2)}
|
||||
.export textarea{font-family:var(--font-mono);font-size:11.5px;width:100%;box-sizing:border-box;height:180px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--card);color:var(--fg);padding:8px;resize:vertical}
|
||||
.link-table{margin-top:var(--space-3)}
|
||||
.tbl{max-width:100%;overflow-x:auto;margin-top:var(--space-2)}
|
||||
table{border-collapse:collapse;font-size:var(--fs-body);min-width:520px}
|
||||
th,td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:500}
|
||||
td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap}
|
||||
th{font-size:var(--fs-caption);font-weight:600;color:var(--color-text-secondary)}
|
||||
td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap;color:var(--color-action-primary)}
|
||||
|
||||
/* 9. Движение и узкие экраны */
|
||||
@media (prefers-reduced-motion:no-preference){.card{transition:border-color .15s}}
|
||||
@media (prefers-reduced-motion:reduce){.card .grip,.fold::before{transition:none}}
|
||||
@media (max-width:900px){.stage{flex-direction:column}.drawer{flex:none;max-width:100%;max-height:none;position:static}}
|
||||
@media (max-width:640px){.sticky{position:static}.dates-bar{position:static}.file-name{max-width:100%}}
|
||||
@media (max-width:640px){.sticky{position:static}.dates-bar{position:static}}
|
||||
|
||||
/* «Открыть карточку» в заголовке карточки (R-1) */
|
||||
.card .open-card{margin-left:6px;color:var(--muted);text-decoration:none;font-size:13px}
|
||||
@ -162,4 +177,3 @@ td.k{font-family:var(--font-mono);font-size:12px;white-space:nowrap}
|
||||
.measure{position:absolute;left:-100000px;top:0;visibility:hidden;pointer-events:none}
|
||||
.measure .card{position:static;height:auto}
|
||||
.measure .card .title{-webkit-line-clamp:unset}
|
||||
.top h1{font-size:15px;font-weight:600;margin:0}
|
||||
|
||||
84
web/src/board/components/BoardHeader.tsx
Normal file
84
web/src/board/components/BoardHeader.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { statusText, type BoardStatus } from "../status.ts";
|
||||
import type { BoardModel, Visible } from "../types.ts";
|
||||
import { NOT_IN_HUB } from "../constants.ts";
|
||||
import { Badge, Button, IconButton, IconRefresh, IconUpload, IconWarning, Menu, MenuItem, MenuSeparator, Notice, PageHeader } from "../../ui/index.ts";
|
||||
|
||||
interface BoardHeaderProps {
|
||||
label: string; title: string; source: "hub" | "file"; fileName: 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 BoardHeader(p: BoardHeaderProps) {
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => { if (!confirm) return; const t = setTimeout(() => setConfirm(false), 8000); return () => clearTimeout(t); }, [confirm]);
|
||||
const st = p.status ? statusText(p.status) : null; // только события доски; ход пакета «Обновить из Jira» — в верхней панели
|
||||
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 stats = (
|
||||
<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>
|
||||
);
|
||||
return (
|
||||
<PageHeader title="Доска связей" subtitle={stats} actions={
|
||||
<>
|
||||
<Button variant="secondary" icon={<IconWarning />} aria-pressed={p.problemsOpen} onClick={p.onProblems}
|
||||
title="Сроки блокеров, задачи без исполнителя, передачи между людьми">
|
||||
Проблемы <Badge tone={p.problemCount ? "error" : "neutral"}>{p.problemCount}</Badge>
|
||||
</Button>
|
||||
<Menu label="Ещё">
|
||||
{(close) => (
|
||||
<>
|
||||
<MenuItem title="Загрузить снимок зеркала хаба заново, без обращения к Jira" onSelect={() => { close(); p.onReload(); }}>Перечитать</MenuItem>
|
||||
<MenuItem icon={<IconUpload size={16} />} onSelect={() => { close(); fileRef.current?.click(); }}
|
||||
title="CSV из фильтра Jira или JSON, выгруженный этой доской. JSON при работе с хабом переносит только расстановку карточек, дорожки и фильтры; CSV показывает снимок файла до «Перечитать»">Загрузить файл…</MenuItem>
|
||||
<MenuItem onSelect={() => { close(); p.onExport(); }}>Экспорт JSON</MenuItem>
|
||||
<MenuItem pressed={p.tableOpen} onSelect={p.onTable}>Таблица связей</MenuItem>
|
||||
<MenuItem pressed={p.snap} onSelect={p.onSnap}>Привязка к сетке</MenuItem>
|
||||
<MenuSeparator />
|
||||
{!confirm ? <MenuItem danger onSelect={() => setConfirm(true)}>Сбросить раскладку</MenuItem> : (
|
||||
<div className="confirm">
|
||||
<span>Вернуть автоматическую раскладку {p.byDate ? "режима «по датам»" : "(обычную; раскладка «по датам» останется)"}, порядок и раскрытие дорожек?</span>
|
||||
<div className="confirm__row">
|
||||
<Button variant="danger" onClick={() => { p.onReset(); setConfirm(false); close(); }}>Да, сбросить</Button>
|
||||
<Button variant="secondary" onClick={() => setConfirm(false)}>Нет</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
<input ref={fileRef} 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());
|
||||
}} />
|
||||
</>
|
||||
}>
|
||||
<div className="board-src">
|
||||
{p.source === "file" ? (
|
||||
<>
|
||||
<Badge tone="info">из файла: {p.fileName}</Badge>
|
||||
<IconButton label="Вернуться к данным хаба" icon={<IconRefresh size={16} />} onClick={p.onReload} />
|
||||
</>
|
||||
) : <span className="board-src__label" title={p.title}>{p.label}</span>}
|
||||
{st && <span className={"board-event" + (st.warn ? " board-event--warn" : "")} role="status">{st.text}</span>}
|
||||
</div>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
|
||||
// предупреждения о данных под шапкой (строки 878–880)
|
||||
export function BoardNotes({ 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 <Notice tone="warning">{notes.map((n) => <div key={n}>{n}</div>)}</Notice>;
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Panel } from "../../ui/index.ts";
|
||||
|
||||
export function ExportBox({ text, onClose }: { text: string; onClose(): void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
@ -8,13 +9,13 @@ export function ExportBox({ text, onClose }: { text: string; onClose(): void })
|
||||
};
|
||||
let ta: HTMLTextAreaElement | null = null;
|
||||
return (
|
||||
<div className="export">
|
||||
<Panel className="export">
|
||||
<div className="row">
|
||||
<span className="hint">JSON доски: все задачи, связи, раскладка, дорожки и фильтры. Загружается обратно ссылкой «файл…». Если файл не скачался, скопируйте текст.</span>
|
||||
<button className="btn" type="button" onClick={() => void copy(ta)}>{copied ? "Скопировано" : "Копировать"}</button>
|
||||
<button className="btn" type="button" onClick={onClose}>Закрыть</button>
|
||||
<span className="hint">JSON доски: все задачи, связи, раскладка, дорожки и фильтры. Загружается обратно пунктом «Загрузить файл…» в меню «Ещё». Если файл не скачался, скопируйте текст.</span>
|
||||
<Button onClick={() => void copy(ta)}>{copied ? "Скопировано" : "Копировать"}</Button>
|
||||
<Button onClick={onClose}>Закрыть</Button>
|
||||
</div>
|
||||
<textarea readOnly spellCheck={false} value={text} ref={(el) => { ta = el; }} />
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,27 +1,43 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { sprintBox, sprintItems, whoBox, whoItems } from "../analysis/facets.ts";
|
||||
import type { BoardPrefsApi } from "../useBoardPrefs.ts";
|
||||
import type { BoardModel } from "../types.ts";
|
||||
import { Button, IconCalendar, IconCritical, IconHandoff, IconLink, IconNeighbors, Panel } from "../../ui/index.ts";
|
||||
import { MultiSelect } from "./MultiSelect.tsx";
|
||||
|
||||
const TOGGLES = [
|
||||
{ name: "neighbors", label: "+ соседи по связям", title: "Добавить к отобранным задачам их соседей по связям из любого спринта, любого исполнителя и вида работы. Соседи рисуются пунктиром и считаются отдельно" },
|
||||
{ name: "onlyHand", label: "⇄ передачи", title: "Оставить только стрелки, где задачу ждёт другой человек. Карточки не убирает — для этого «скрыть без связей»" },
|
||||
{ name: "onlyLinked", label: "скрыть без связей", title: "Убрать с доски карточки, у которых не осталось ни одной видимой стрелки" },
|
||||
{ name: "crit", label: "⚑ критический путь", title: "Самая длинная цепочка блокировок по плановым датам окончания" },
|
||||
{ name: "byDate", label: "▥ по датам", title: "Разложить карточки по колонкам плановых дат окончания. У этого режима своя расстановка: карточку можно двигать внутри её колонки; обычная расстановка не меняется и вернётся, когда режим выключен" },
|
||||
] as const;
|
||||
type Toggle = "neighbors" | "onlyHand" | "onlyLinked" | "crit" | "byDate";
|
||||
interface Item { name: Toggle; label: string; icon: ReactNode; title: string }
|
||||
|
||||
const SHOW: Item[] = [
|
||||
{ name: "neighbors", label: "Соседи по связям", icon: <IconNeighbors size={16} />, title: "Добавить к отобранным задачам их соседей по связям из любого спринта, любого исполнителя и вида работы. Соседи рисуются пунктиром и считаются отдельно" },
|
||||
{ name: "onlyHand", label: "Только передачи", icon: <IconHandoff size={16} />, title: "Оставить только стрелки, где задачу ждёт другой человек. Карточки не убирает — для этого «скрыть без связей»" },
|
||||
{ name: "onlyLinked", label: "Скрыть без связей", icon: <IconLink size={16} />, title: "Убрать с доски карточки, у которых не осталось ни одной видимой стрелки" },
|
||||
];
|
||||
const MODE: Item[] = [
|
||||
{ name: "crit", label: "Критический путь", icon: <IconCritical size={16} />, title: "Самая длинная цепочка блокировок по плановым датам окончания" },
|
||||
{ name: "byDate", label: "По датам", icon: <IconCalendar size={16} />, title: "Разложить карточки по колонкам плановых дат окончания. У этого режима своя расстановка: карточку можно двигать внутри её колонки; обычная расстановка не меняется и вернётся, когда режим выключен" },
|
||||
];
|
||||
|
||||
export function FilterBar({ model, prefs }: { model: BoardModel; prefs: BoardPrefsApi }) {
|
||||
const f = prefs.filters, sb = sprintBox(model), wb = whoBox(model, f);
|
||||
return (
|
||||
<div className="bar">
|
||||
<span>{sb.kind === "text" ? <span className="file-name">{sb.text}</span>
|
||||
: sb.kind === "multi" && <MultiSelect items={sprintItems(model, f).items} selected={f.sprints} allLabel="Все спринты" onChange={prefs.setSprints} />}</span>
|
||||
<span>{wb.kind === "text" ? <span className="file-name">{wb.text}</span>
|
||||
: wb.kind === "multi" && <MultiSelect items={whoItems(model, f).items} selected={f.who} allLabel="Все исполнители" onChange={prefs.setWho} />}</span>
|
||||
{TOGGLES.map((t) => (
|
||||
<button key={t.name} className="btn" type="button" aria-pressed={f[t.name]} title={t.title} onClick={() => prefs.toggle(t.name)}>{t.label}</button>
|
||||
const group = (label: string, items: Item[]) => (
|
||||
<div className="board-controls__group" role="group" aria-label={label}>
|
||||
<span className="board-controls__label">{label}</span>
|
||||
{items.map((t) => (
|
||||
<Button key={t.name} variant="secondary" icon={t.icon} aria-pressed={f[t.name]} title={t.title} onClick={() => prefs.toggle(t.name)}>{t.label}</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Panel className="board-controls">
|
||||
{sb.kind === "text" ? <span className="board-controls__text">{sb.text}</span>
|
||||
: sb.kind === "multi" && <MultiSelect items={sprintItems(model, f).items} selected={f.sprints} allLabel="Все спринты" onChange={prefs.setSprints} />}
|
||||
{wb.kind === "text" ? <span className="board-controls__text">{wb.text}</span>
|
||||
: wb.kind === "multi" && <MultiSelect items={whoItems(model, f).items} selected={f.who} allLabel="Все исполнители" onChange={prefs.setWho} />}
|
||||
<span className="board-controls__sep" aria-hidden="true" />
|
||||
{group("Показать", SHOW)}
|
||||
<span className="board-controls__sep" aria-hidden="true" />
|
||||
{group("Режим", MODE)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,25 +1,35 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { BoardPrefsApi } from "../useBoardPrefs.ts";
|
||||
import { Badge, Button, IconLegend } from "../../ui/index.ts";
|
||||
|
||||
const KINDS = [["decision", "решение"], ["analytics", "аналитика / актуализация"], ["dev", "разработка"], ["ui", "UI"],
|
||||
["test", "тестирование"], ["org", "организационная"]] as const;
|
||||
const EDGES = [["block", "блокирует начало → заблокирована", "ln"], ["rel", "связанные", "ln dash"]] as const;
|
||||
|
||||
export function Legend({ prefs }: { prefs: BoardPrefsApi }) {
|
||||
export function Legend({ prefs, open, onToggle }: { prefs: BoardPrefsApi; open: boolean; onToggle(): void }) {
|
||||
const { hideKind, hideEdge } = prefs.filters;
|
||||
const hidden = hideKind.size + hideEdge.size;
|
||||
const btn = (what: "kind" | "edge", value: string, off: boolean, body: ReactNode) => (
|
||||
<button key={what + value} type="button" className={"lg" + (off ? " off" : "")} aria-pressed={!off}
|
||||
title={off ? "Показать снова" : "Скрыть на доске"} onClick={() => prefs.toggleHidden(what, value)}>{body}</button>
|
||||
);
|
||||
return (
|
||||
<div className="legend">
|
||||
{KINDS.map(([k, label]) => btn("kind", k, hideKind.has(k), <><span className="sw" style={{ background: `var(--k-${k})` }} />{label}</>))}
|
||||
<span className="sep" />
|
||||
{EDGES.map(([k, label, ln]) => btn("edge", k, hideEdge.has(k), <><span className={ln} />{label}</>))}
|
||||
<span><span className="ln hand" />передача другому исполнителю</span>
|
||||
<span><span className="ln crit" />критический путь</span>
|
||||
<span><span className="out-sw" />сосед по связям, сам под фильтры не попадает</span>
|
||||
<span className="hint">щелчок по виду работы или типу связи скрывает их</span>
|
||||
<div className="legend-box">
|
||||
<div className="legend-head">
|
||||
<Button variant="ghost" icon={<IconLegend size={16} />} aria-expanded={open} onClick={onToggle}>Легенда</Button>
|
||||
{hidden > 0 && <Badge tone="neutral">скрыто: {hidden}</Badge>}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="legend">
|
||||
{KINDS.map(([k, label]) => btn("kind", k, hideKind.has(k), <><span className="sw" style={{ background: `var(--k-${k})` }} />{label}</>))}
|
||||
<span className="sep" />
|
||||
{EDGES.map(([k, label, ln]) => btn("edge", k, hideEdge.has(k), <><span className={ln} />{label}</>))}
|
||||
<span><span className="ln hand" />передача другому исполнителю</span>
|
||||
<span><span className="ln crit" />критический путь</span>
|
||||
<span><span className="out-sw" />сосед по связям, сам под фильтры не попадает</span>
|
||||
<span className="hint">щелчок по виду работы или типу связи скрывает их</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import { Panel } from "../../ui/index.ts";
|
||||
import type { VisLink } from "../types.ts";
|
||||
|
||||
export function LinkTable({ links }: { links: VisLink[] }) {
|
||||
return (
|
||||
<details open>
|
||||
<summary>Таблица связей</summary>
|
||||
<Panel as="section" className="link-table">
|
||||
<h2 className="board-h2">Таблица связей</h2>
|
||||
<div className="tbl"><table>
|
||||
<thead><tr><th>Блокирует</th><th>Тип</th><th>Заблокирована</th></tr></thead>
|
||||
<tbody>{links.map((l) => <tr key={`${l.src}>${l.dst}${l.type}`}><td className="k">{l.src}</td><td>{l.type}</td><td className="k">{l.dst}</td></tr>)}</tbody>
|
||||
</table></div>
|
||||
</details>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import type { FacetItem } from "../analysis/facets.ts";
|
||||
import { Dropdown } from "../../ui/index.ts";
|
||||
import { Button, Dropdown, IconChevronDown, SearchInput } from "../../ui/index.ts";
|
||||
|
||||
// мультиселект с поиском: пустой выбор = без фильтра (строки 913–938)
|
||||
// мультиселект с поиском: пустой выбор = без фильтра (строки 913–938); кнопка — вид Select
|
||||
export function MultiSelect({ items, selected, allLabel, onChange }: {
|
||||
items: FacetItem[]; selected: Set<string>; allLabel: string; onChange(next: Set<string>): void;
|
||||
}) {
|
||||
@ -13,13 +13,13 @@ export function MultiSelect({ items, selected, allLabel, onChange }: {
|
||||
const flip = (v: string, on: boolean) => { const n = new Set(selected); if (on) n.add(v); else n.delete(v); onChange(n); };
|
||||
return (
|
||||
<Dropdown onOpen={() => setQ("")} button={({ open, toggle }) => (
|
||||
<button type="button" className="btn dd-btn" aria-expanded={open} title={sel.map((i) => i.label).join(", ")} onClick={toggle}>
|
||||
<span className="lbl">{label}</span><span className="car">▼</span>
|
||||
<button type="button" className="ui-input multi-btn" aria-expanded={open} title={sel.map((i) => i.label).join(", ")} onClick={toggle}>
|
||||
<span className="lbl">{label}</span><IconChevronDown size={16} />
|
||||
</button>
|
||||
)}>
|
||||
{() => (
|
||||
<>
|
||||
<input type="search" placeholder="Поиск…" aria-label="Поиск" autoFocus value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<SearchInput label="Поиск" placeholder="Поиск…" autoFocus value={q} onChange={setQ} />
|
||||
<div className="dd-list" role="group" aria-label={allLabel}>
|
||||
{!shown.length && <div className="dd-empty">ничего не найдено</div>}
|
||||
{shown.map((i) => (
|
||||
@ -30,8 +30,8 @@ export function MultiSelect({ items, selected, allLabel, onChange }: {
|
||||
))}
|
||||
</div>
|
||||
<div className="dd-foot">
|
||||
<button type="button" className="btn all" onClick={() => onChange(new Set(items.map((i) => i.value)))}>Выбрать все</button>
|
||||
<button type="button" className="btn clear" onClick={() => onChange(new Set())}>Снять выбор</button>
|
||||
<Button variant="ghost" onClick={() => onChange(new Set(items.map((i) => i.value)))}>Выбрать все</Button>
|
||||
<Button variant="ghost" onClick={() => onChange(new Set())}>Снять выбор</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Button, Panel } from "../../ui/index.ts";
|
||||
import { JIRA_URL } from "../constants.ts";
|
||||
import type { ProblemItem, Problems } from "../types.ts";
|
||||
|
||||
@ -8,7 +9,7 @@ function Section({ title, list }: { title: string; list: ProblemItem[] }) {
|
||||
if (!list.length) return null;
|
||||
return (
|
||||
<>
|
||||
<h4>{title} ({list.length})</h4>
|
||||
<h3 className="board-h3">{title} ({list.length})</h3>
|
||||
<ul>{list.map((x, i) => <li key={i}><K k={x.a.key} /> → <K k={x.b.key} /> <span className="why">— {x.text}</span></li>)}</ul>
|
||||
</>
|
||||
);
|
||||
@ -17,7 +18,8 @@ function Section({ title, list }: { title: string; list: ProblemItem[] }) {
|
||||
export function ProblemsDrawer({ problems, onClose }: { problems: Problems; onClose(): void }) {
|
||||
return (
|
||||
<aside className="drawer">
|
||||
<div className="drawer-head"><b>Проблемы на доске</b><button type="button" className="btn sm" onClick={onClose}>Закрыть</button></div>
|
||||
<Panel>
|
||||
<div className="drawer-head"><h2 className="board-h2">Проблемы на доске</h2><Button variant="ghost" onClick={onClose}>Закрыть</Button></div>
|
||||
<div>
|
||||
{!problemCount(problems) && <div className="ok">По датам, исполнителям и статусам блокеров на видимой части доски проблем нет.</div>}
|
||||
<Section title="Срок блокера не раньше срока зависимой задачи" list={problems.dates} />
|
||||
@ -25,6 +27,7 @@ export function ProblemsDrawer({ problems, onClose }: { problems: Problems; onCl
|
||||
<Section title="Блокер не начат, а срок зависимой задачи близко" list={problems.late} />
|
||||
<Section title="Передачи между исполнителями (точки ожидания)" list={problems.hand} />
|
||||
</div>
|
||||
</Panel>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,78 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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 [confirm, setConfirm] = useState(false);
|
||||
useEffect(() => { if (!confirm) return; const t = setTimeout(() => setConfirm(false), 8000); return () => clearTimeout(t); }, [confirm]);
|
||||
const st = p.status ? statusText(p.status) : null; // только события доски; ход пакета «Обновить из Jira» — в верхней панели
|
||||
const m = p.model, v = p.vis, r = v.removed;
|
||||
const coreShown = v.nodes.filter((n) => !n.dim).length, ctxShown = v.nodes.length - coreShown;
|
||||
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>
|
||||
<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>
|
||||
<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>;
|
||||
}
|
||||
10
web/src/board/legendPref.ts
Normal file
10
web/src/board/legendPref.ts
Normal file
@ -0,0 +1,10 @@
|
||||
// свёрнутость легенды доски: "0" — свёрнута, иначе развёрнута; ошибки хранилища глотаем
|
||||
const KEY = "jira-hub-legend";
|
||||
|
||||
export function readLegendOpen(): boolean {
|
||||
try { return localStorage.getItem(KEY) !== "0"; } catch { return true; }
|
||||
}
|
||||
|
||||
export function saveLegendOpen(open: boolean): void {
|
||||
try { localStorage.setItem(KEY, open ? "1" : "0"); } catch { /* хранилище недоступно — состояние только в памяти */ }
|
||||
}
|
||||
@ -150,6 +150,7 @@ describe("проект в приложении", () => {
|
||||
const h = hub([A, S], {
|
||||
"GET /projects": () => (++n === 1 ? json(200, { projects: [A, S] }) : json(500, { error: "boom" })),
|
||||
"POST /projects": () => json(201, { project: S, batch_id: 7 }),
|
||||
"GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }),
|
||||
});
|
||||
const router = open("/app/board?project=AWPCA");
|
||||
await screen.findByTestId("page-board");
|
||||
|
||||
@ -15,8 +15,6 @@
|
||||
.dd-btn .lbl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.dd-btn .car{font-size:10px;opacity:.7}
|
||||
.dd-menu{position:absolute;top:calc(100% + 4px);left:0;z-index:20;min-width:280px;max-width:420px;padding:8px;display:flex;flex-direction:column;gap:6px}
|
||||
.dd-menu input[type=search]{font:inherit;font-size:14px;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 8px;width:100%;box-sizing:border-box}
|
||||
.dd-menu input[type=search]:focus-visible{border-color:var(--accent);outline:none}
|
||||
.dd-list{max-height:260px;overflow:auto;display:flex;flex-direction:column}
|
||||
.dd-item{display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:5px;cursor:pointer;font-size:14px}
|
||||
.dd-item:hover{background:var(--soft)}
|
||||
|
||||
@ -3,8 +3,8 @@ import { IconButton } from "./Button.tsx";
|
||||
import { IconMore } from "./icons.tsx";
|
||||
|
||||
// выпадающий список: кнопка открывает, щелчок вне или Escape закрывает (строки 897–904)
|
||||
export function Dropdown({ button, className = "", children, onOpen }: {
|
||||
button: (p: { open: boolean; toggle(): void }) => ReactNode; className?: string;
|
||||
export function Dropdown({ button, className = "", menu = false, children, onOpen }: {
|
||||
button: (p: { open: boolean; toggle(): void }) => ReactNode; className?: string; menu?: boolean; // menu — меню пунктов («Ещё»), не панель
|
||||
children: (close: () => void) => ReactNode; onOpen?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@ -19,7 +19,7 @@ export function Dropdown({ button, className = "", children, onOpen }: {
|
||||
return (
|
||||
<div className={"dd " + className} ref={ref} onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}>
|
||||
{button({ open, toggle })}
|
||||
{open && <div className={"dd-menu" + (className.includes("more") ? " menu" : "")}>{children(() => setOpen(false))}</div>}
|
||||
{open && <div className={"dd-menu" + (menu ? " menu" : "")}>{children(() => setOpen(false))}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -29,19 +29,19 @@ export function Menu({ label, children, align = "end" }: {
|
||||
label: string; children: (close: () => void) => ReactNode; align?: "start" | "end";
|
||||
}) {
|
||||
return (
|
||||
<Dropdown className={"more" + (align === "start" ? " dd--start" : "")}
|
||||
<Dropdown menu className={"more" + (align === "start" ? " dd--start" : "")}
|
||||
button={({ open, toggle }) => <IconButton label={label} icon={<IconMore />} aria-haspopup="menu" aria-expanded={open} onClick={toggle} />}>
|
||||
{children}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuItem({ onSelect, pressed, danger, icon, children }: {
|
||||
onSelect(): void; pressed?: boolean; danger?: boolean; icon?: ReactNode; children: ReactNode;
|
||||
export function MenuItem({ onSelect, pressed, danger, icon, title, children }: {
|
||||
onSelect(): void; title?: string; pressed?: boolean; danger?: boolean; icon?: ReactNode; children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className={danger ? "danger" : undefined} aria-pressed={pressed === undefined ? undefined : pressed} onClick={onSelect}>
|
||||
{icon}{children}
|
||||
<button type="button" className={danger ? "danger" : undefined} aria-pressed={pressed === undefined ? undefined : pressed} title={title} onClick={onSelect}>
|
||||
<span className="menu__label">{icon}{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@ -13,11 +13,11 @@ export function Textarea({ size = "md", className, ...rest }: Size & Omit<Textar
|
||||
return <textarea {...rest} className={cx("ui-input", "ui-textarea", `ui-input--${size}`, className)} />;
|
||||
}
|
||||
|
||||
export function SearchInput({ value, onChange, label, placeholder }: { value: string; onChange(v: string): void; label: string; placeholder?: string }) {
|
||||
export function SearchInput({ value, onChange, label, placeholder, autoFocus }: { value: string; onChange(v: string): void; label: string; placeholder?: string; autoFocus?: boolean }) {
|
||||
return (
|
||||
<span className="ui-search">
|
||||
<span className="ui-search__icon" aria-hidden="true"><IconSearch /></span>
|
||||
<input type="search" className="ui-input ui-input--md ui-search__input" aria-label={label} placeholder={placeholder}
|
||||
<input type="search" className="ui-input ui-input--md ui-search__input" aria-label={label} placeholder={placeholder} autoFocus={autoFocus}
|
||||
value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
{value !== "" && <IconButton className="ui-search__clear" label="Очистить" icon={<IconClose />} onClick={() => onChange("")} />}
|
||||
</span>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user