} />
@@ -16,17 +23,22 @@ export function KanbanPage({ project }: { project: Project }) {
{(d) => {
const withBacklog = d.board?.kanban_backlog === true;
const cols = d.board?.columns ?? null;
- const issues = d.board_issues ?? []; // сервер уже убрал задачи первой колонки, если бэклог включён
- const backlog = d.backlog ?? [];
+ const issues = (d.board_issues ?? []).filter((i) => matches(i, kfilter)); // сервер уже убрал задачи первой колонки, если бэклог включён
+ const backlog = (d.backlog ?? []).filter((i) => matches(i, kfilter));
+ const views = columnsOf(withBacklog && cols ? cols.slice(1) : cols, issues);
return (
<>
+
+ c.name)} hidden={view.hiddenColumns} onChange={(hiddenColumns) => set({ hiddenColumns })} />
+
{withBacklog && (
{`Бэклог (${backlog.length})`}
)}
-
+
>
);
}}
diff --git a/web/src/modules/jira/agile/SprintPage.test.tsx b/web/src/modules/jira/agile/SprintPage.test.tsx
index 9f8f09a..0844206 100644
--- a/web/src/modules/jira/agile/SprintPage.test.tsx
+++ b/web/src/modules/jira/agile/SprintPage.test.tsx
@@ -228,6 +228,57 @@ describe("«Спринт»", () => {
expect(cols.map((c) => c.querySelector(".agile-col__count")!.textContent)).toEqual(["1", "0", "1"]);
});
+ it("заголовки колонок — h3 (T10.3)", async () => {
+ hub(agile());
+ open("/app/jira/sprint?project=AWPCA");
+ const p = await page();
+ await within(p).findByText("Тема AURORA-1");
+ expect(within(p).getAllByRole("heading", { level: 3 }).map((h) => h.textContent)).toEqual(["К выполнению", "Готово", "Без колонки"]);
+ });
+
+ it("«Скрытая колонка»: снять «Готово» — колонки нет, на «Колонки» — 1; после повторного монтажа так же", async () => {
+ hub(agile());
+ open("/app/jira/sprint?project=AWPCA");
+ let p = await page();
+ await within(p).findByText("Тема AURORA-1");
+ await userEvent.click(within(p).getByRole("button", { name: /^Колонки/ }));
+ await userEvent.click(within(screen.getByRole("group", { name: "Колонки" })).getByRole("checkbox", { name: "Готово" }));
+ expect(colNames(p)).toEqual(["К выполнению", "Без колонки"]);
+ expect(within(p).getByRole("button", { name: /^Колонки/ })).toHaveTextContent("Колонки1");
+ cleanup();
+ open("/app/jira/sprint?project=AWPCA");
+ p = await page();
+ await within(p).findByText("Тема AURORA-1");
+ expect(colNames(p)).toEqual(["К выполнению", "Без колонки"]);
+ expect(within(p).getByRole("button", { name: /^Колонки/ })).toHaveTextContent("Колонки1");
+ });
+
+ it("последнюю видимую колонку снять нельзя", async () => {
+ hub(agile());
+ open("/app/jira/sprint?project=AWPCA");
+ const p = await page();
+ await within(p).findByText("Тема AURORA-1");
+ await userEvent.click(within(p).getByRole("button", { name: /^Колонки/ }));
+ const g = within(screen.getByRole("group", { name: "Колонки" }));
+ await userEvent.click(g.getByRole("checkbox", { name: "Готово" }));
+ await userEvent.click(g.getByRole("checkbox", { name: "Без колонки" }));
+ expect(colNames(p)).toEqual(["К выполнению"]);
+ expect(g.getByRole("checkbox", { name: "К выполнению" })).toBeDisabled();
+ });
+
+ it("скрытая колонка, которой больше нет, не ломает меню и не считается", async () => {
+ localStorage.setItem("sdlc-hub-agile", JSON.stringify({ AWPCA: { sprint: { hiddenColumns: ["Удалённая"] } } }));
+ hub(agile());
+ open("/app/jira/sprint?project=AWPCA");
+ const p = await page();
+ await within(p).findByText("Тема AURORA-1");
+ expect(colNames(p)).toEqual(["К выполнению", "Готово", "Без колонки"]);
+ const btn = within(p).getByRole("button", { name: /^Колонки/ });
+ expect(btn).toHaveTextContent(/^Колонки$/);
+ await userEvent.click(btn);
+ expect(within(screen.getByRole("group", { name: "Колонки" })).getAllByRole("checkbox")).toHaveLength(3);
+ });
+
it("эпик на доске спринта — карточка, учитывается в числе колонки", async () => {
hub(agile([s102({ issues: [iss("AURORA-1"), iss("AURORA-E", { issue_type: "Эпик" })] })]));
open("/app/jira/sprint?project=AWPCA");
diff --git a/web/src/modules/jira/agile/SprintPage.tsx b/web/src/modules/jira/agile/SprintPage.tsx
index 0c20af2..8ac904f 100644
--- a/web/src/modules/jira/agile/SprintPage.tsx
+++ b/web/src/modules/jira/agile/SprintPage.tsx
@@ -6,6 +6,7 @@ import { RefreshButton } from "../RefreshButton.tsx";
import { AgileGate } from "./AgileGate.tsx";
import { BoardColumns } from "./BoardColumns.tsx";
import { AgileToolbar } from "./AgileToolbar.tsx";
+import { ColumnsMenu } from "./ColumnsMenu.tsx";
import { columnsOf } from "./columns.ts";
import { assigneeOptions, matches, parseSprintIds, selectableSprints, sprintIssues } from "./filters.ts";
import { SprintPicker } from "./SprintPicker.tsx";
@@ -41,12 +42,14 @@ export function SprintPage({ project }: { project: Project }) {
const chosen = selectable.filter((s) => ids.includes(s.id));
const issues = sprintIssues(chosen);
const shown = issues.filter((i) => matches(i, filter));
+ const cols = columnsOf(d.board?.columns ?? null, shown);
// выбор спринтов — в адресе, без новой записи истории
const pick = (next: number[]) => setParams((p) => { const n = new URLSearchParams(p); n.set("sprint", next.join(",")); return n; }, { replace: true });
return (
<>
{selectable.length > 0 && }
+ c.name)} hidden={view.hiddenColumns} onChange={(hiddenColumns) => set({ hiddenColumns })} />
{chosen.length === 0 ? (
@@ -56,7 +59,7 @@ export function SprintPage({ project }: { project: Project }) {
) : (
<>
{chosen.map((s) => )}
-
+
>
)}
>
diff --git a/web/src/modules/jira/agile/agile.css b/web/src/modules/jira/agile/agile.css
index 07d4494..3568b50 100644
--- a/web/src/modules/jira/agile/agile.css
+++ b/web/src/modules/jira/agile/agile.css
@@ -37,17 +37,22 @@
.agile-row{flex-wrap:wrap}
.agile-row__assignee{flex-basis:100%}
}
-/* «Спринт»: страница во всю ширину, колонки прокручиваются внутри контейнера */
-.agile--wide{min-width:0}
-.agile-columns{display:flex;align-items:flex-start;gap:var(--space-3);overflow-x:auto;padding-bottom:var(--space-2);min-width:0}
-.agile-col{flex:0 0 280px;min-width:0;display:flex;flex-direction:column;gap:var(--space-2);
+/* «Спринт» и «Kanban»: доска занимает высоту окна под шапкой и панелью фильтров (ограничения max-width у .page--board нет);
+ колонки одной высоты, список колонки прокручивается внутри, горизонтальная прокрутка — у .agile-columns и в окне (T10.3) */
+.agile--wide{box-sizing:border-box;min-width:0;height:calc(100dvh - 32px)} /* 32px — нижний отступ .page */
+.agile--wide>*{flex:0 0 auto}
+.agile--wide>.agile-columns{flex:1 1 0}
+.agile--wide>.agile-past[open]{max-height:40dvh;overflow:auto}
+.agile-columns{display:flex;align-items:stretch;gap:var(--space-3);overflow-x:auto;padding-bottom:var(--space-2);min-width:0;min-height:360px}
+.agile-col{flex:0 0 280px;min-width:0;min-height:0;display:flex;flex-direction:column;gap:var(--space-2);
background:var(--color-bg-subtle);border:1px solid var(--color-border-default);border-radius:var(--radius-xl);padding:var(--space-3)}
+@media (max-width:639px){.agile--wide{height:calc(100dvh - 56px - 32px)}} /* 56px — закреплённая строка «Меню» */
.agile-col.is-extra{border-style:dashed}
.agile-col__head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2)}
.agile-col__name{margin:0;font-size:var(--fs-body);line-height:var(--lh-body);font-weight:700;overflow-wrap:anywhere}
.agile-col__count{font-size:var(--fs-caption);color:var(--color-text-secondary)}
.agile-col__caption{margin:0;font-size:var(--fs-caption);color:var(--color-text-secondary)}
-.agile-col__list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-2)}
+.agile-col__list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-2);flex:1 1 auto;min-height:0;overflow-y:auto}
.agile-card{display:flex;flex-direction:column;gap:var(--space-2);min-width:0;padding:var(--space-3);cursor:pointer;
background:var(--color-bg-surface);border:1px solid var(--color-border-default);border-radius:var(--radius-lg);
font-size:var(--fs-body);line-height:var(--lh-body)}
diff --git a/web/src/modules/jira/agile/columns.ts b/web/src/modules/jira/agile/columns.ts
index 177e264..86dd6cf 100644
--- a/web/src/modules/jira/agile/columns.ts
+++ b/web/src/modules/jira/agile/columns.ts
@@ -19,3 +19,9 @@ export function columnsOf(columns: BoardColumn[] | null, issues: AgileIssue[]):
if (rest.length) out.push({ name: NO_COLUMN, issues: rest, extra: true });
return out;
}
+
+// скрытые колонки, которые есть на доске; если скрыты все (колонки переименовали) — не скрыто ничего, доска не пустеет молча
+export function effectiveHidden(names: string[], hidden: string[]): string[] {
+ const gone = names.filter((n) => hidden.includes(n));
+ return gone.length >= names.length ? [] : gone;
+}