diff --git a/web/src/modules/jira/agile/AgileToolbar.test.tsx b/web/src/modules/jira/agile/AgileToolbar.test.tsx
new file mode 100644
index 0000000..101927d
--- /dev/null
+++ b/web/src/modules/jira/agile/AgileToolbar.test.tsx
@@ -0,0 +1,82 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { AGILE_KEY } from "./agilePref.ts";
+import { assigneeOptions, type AgilePage } from "./filters.ts";
+import { AgileToolbar } from "./AgileToolbar.tsx";
+import { useAgileView } from "./useAgileView.ts";
+import type { AgileIssue } from "../api/types.ts";
+
+const issues = [{ assignee: "ivanov" }, { assignee: "petrov" }] as AgileIssue[];
+
+function Harness({ label = "AWPCA", page = "backlog", showHideDone = true }: { label?: string; page?: AgilePage; showHideDone?: boolean }) {
+ const { view, set, setQ, filter, reset } = useAgileView(label, page);
+ return (
+ <>
+
+ слот
+
+
+ >
+ );
+}
+
+const stored = () => JSON.parse(localStorage.getItem(AGILE_KEY) ?? "null");
+const search = () => screen.getByLabelText("Поиск по ключу или теме") as HTMLInputElement;
+const assignee = () => screen.getByLabelText("Исполнитель") as HTMLSelectElement;
+
+beforeEach(() => localStorage.clear());
+afterEach(cleanup);
+
+describe("AgileToolbar", () => {
+ it("ввод в поиск меняет filter.q и не сохраняется", () => {
+ render();
+ fireEvent.change(search(), { target: { value: "abc" } });
+ expect(screen.getByTestId("q").textContent).toBe("abc");
+ expect(localStorage.getItem(AGILE_KEY)).toBeNull();
+ });
+
+ it("исполнитель сохраняется по метке и странице", () => {
+ render();
+ fireEvent.change(assignee(), { target: { value: "petrov" } });
+ expect(stored().AWPCA.backlog.assignee).toBe("petrov");
+ expect(stored().AWPCA.backlog).not.toHaveProperty("q");
+ });
+
+ it("«Сбросить фильтры» появляется после ввода и снимает всё", () => {
+ render();
+ expect(screen.queryByText("Сбросить фильтры")).toBeNull();
+ fireEvent.change(search(), { target: { value: "abc" } });
+ fireEvent.change(assignee(), { target: { value: "petrov" } });
+ fireEvent.click(screen.getByLabelText("Скрыть выполненные"));
+ fireEvent.click(screen.getByText("Сбросить фильтры"));
+ expect(search().value).toBe("");
+ expect(assignee().value).toBe("");
+ expect((screen.getByLabelText("Скрыть выполненные") as HTMLInputElement).checked).toBe(false);
+ expect(screen.queryByText("Сбросить фильтры")).toBeNull();
+ expect(stored().AWPCA.backlog).toMatchObject({ assignee: "", epic: "", hideDone: false });
+ });
+
+ it("showHideDone=false — флажка нет; слот показан", () => {
+ render();
+ expect(screen.queryByLabelText("Скрыть выполненные")).toBeNull();
+ expect(screen.getByText("слот")).toBeTruthy();
+ });
+
+ it("повторный монтаж с той же меткой и страницей восстанавливает исполнителя и флажок", () => {
+ const a = render();
+ fireEvent.change(assignee(), { target: { value: "ivanov" } });
+ fireEvent.click(screen.getByLabelText("Скрыть выполненные"));
+ a.unmount();
+ render();
+ expect(assignee().value).toBe("ivanov");
+ expect((screen.getByLabelText("Скрыть выполненные") as HTMLInputElement).checked).toBe(true);
+ });
+
+ it("другая страница или метка — свои значения", () => {
+ const a = render();
+ fireEvent.change(assignee(), { target: { value: "ivanov" } });
+ a.unmount();
+ render();
+ expect(assignee().value).toBe("");
+ });
+});
diff --git a/web/src/modules/jira/agile/AgileToolbar.tsx b/web/src/modules/jira/agile/AgileToolbar.tsx
new file mode 100644
index 0000000..6cfbc2a
--- /dev/null
+++ b/web/src/modules/jira/agile/AgileToolbar.tsx
@@ -0,0 +1,25 @@
+// панель фильтров экранов доски: поиск, исполнитель, «Скрыть выполненные», слот страницы, «Сбросить фильтры»
+import type { ReactNode } from "react";
+import { Button, Checkbox, Panel, SearchInput, Select } from "../../../ui/index.ts";
+import { hasFilters, type AgileView, type IssueFilter, type Opt } from "./filters.ts";
+
+export function AgileToolbar({ filter, setQ, set, onReset, assignees, showHideDone, children }: {
+ filter: IssueFilter; setQ(v: string): void; set(patch: Partial): void; onReset(): void;
+ assignees: Opt[]; showHideDone: boolean; children?: ReactNode;
+}) {
+ return (
+
+
+
+
+
+ {showHideDone && (
+ set({ hideDone: e.target.checked })} />
+ )}
+ {children}
+ {hasFilters(filter) && }
+
+ );
+}
diff --git a/web/src/modules/jira/agile/agile.css b/web/src/modules/jira/agile/agile.css
index d997592..08af104 100644
--- a/web/src/modules/jira/agile/agile.css
+++ b/web/src/modules/jira/agile/agile.css
@@ -57,3 +57,6 @@
.agile-card__summary{overflow-wrap:anywhere}
.agile-card__foot{display:flex;align-items:center;gap:var(--space-2);min-width:0}
.agile-card__assignee{flex:1 1 auto;min-width:0;color:var(--color-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+/* панель фильтров */
+.agile-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-3)}
+.agile-toolbar__search{flex:1 1 220px;min-width:0}
diff --git a/web/src/modules/jira/agile/useAgileView.ts b/web/src/modules/jira/agile/useAgileView.ts
new file mode 100644
index 0000000..49b72eb
--- /dev/null
+++ b/web/src/modules/jira/agile/useAgileView.ts
@@ -0,0 +1,43 @@
+// состояние фильтров страницы доски: вид (хранится по метке и странице) + поиск q (только в памяти)
+import { useCallback, useMemo, useRef, useState } from "react";
+import { readView, saveView } from "./agilePref.ts";
+import type { AgilePage, AgileView, IssueFilter } from "./filters.ts";
+
+export function useAgileView(label: string, page: AgilePage) {
+ const id = `${label}\u0000${page}`;
+ const [state, setState] = useState(() => ({ id, view: readView(label, page), q: "" }));
+ // метка или страница сменились — читаем вид заново, поиск сбрасывается
+ let cur = state;
+ if (state.id !== id) {
+ cur = { id, view: readView(label, page), q: "" };
+ setState(cur);
+ }
+ const ref = useRef(cur);
+ ref.current = cur;
+
+ const commit = useCallback((next: typeof state) => {
+ ref.current = next;
+ setState(next);
+ }, []);
+
+ const set = useCallback((patch: Partial) => {
+ const next = { ...ref.current, view: { ...ref.current.view, ...patch } };
+ saveView(label, page, next.view); // запись вне функции-обновителя setState (T8.1 #69)
+ commit(next);
+ }, [label, page, commit]);
+
+ const setQ = useCallback((q: string) => commit({ ...ref.current, q }), [commit]);
+
+ const reset = useCallback(() => {
+ const next = { ...ref.current, q: "", view: { ...ref.current.view, assignee: "", epic: "", hideDone: false } };
+ saveView(label, page, next.view);
+ commit(next);
+ }, [label, page, commit]);
+
+ const { view, q } = cur;
+ const filter: IssueFilter = useMemo(
+ () => ({ q, assignee: view.assignee, epic: view.epic, hideDone: view.hideDone }),
+ [q, view.assignee, view.epic, view.hideDone],
+ );
+ return { view, set, q, setQ, filter, reset };
+}