73 lines
4.0 KiB
TypeScript
73 lines
4.0 KiB
TypeScript
import { Link, useSearchParams } from "react-router";
|
||
import type { Project } from "../../../api/types.ts";
|
||
import { PageHeader, Panel } from "../../../ui/index.ts";
|
||
import type { AgileSprint } from "../api/types.ts";
|
||
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";
|
||
import { useAgileView } from "./useAgileView.ts";
|
||
import { daysLeft, fmtRange, fmtSp, tasksWord } from "./model.ts";
|
||
import "./agile.css";
|
||
|
||
function SprintHead({ s }: { s: AgileSprint }) {
|
||
const left = s.state === "active" ? daysLeft(s.end_date, new Date()) : null;
|
||
return (
|
||
<header className="agile-section__head">
|
||
<h2 className="agile-section__title">{s.name}</h2>
|
||
<span className="agile-section__meta">{fmtRange(s.start_date, s.end_date)}</span>
|
||
{left !== null && <span className="agile-section__meta">{left >= 0 ? `осталось ${left} дн.` : "срок прошёл"}</span>}
|
||
{s.goal && <span className="agile-section__goal">{s.goal}</span>}
|
||
<span className="agile-section__meta">{`${s.count_done} из ${s.count} ${tasksWord(s.count)}`}</span>
|
||
<span className="agile-section__meta">{`${fmtSp(s.points_done)} из ${fmtSp(s.points)} SP`}</span>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
// «Спринт» (spec web-app): колонки доски выбранных спринтов (по умолчанию все активные); только просмотр
|
||
export function SprintPage({ project }: { project: Project }) {
|
||
const [params, setParams] = useSearchParams();
|
||
const { view, set, setQ, filter, reset } = useAgileView(project.label, "sprint");
|
||
return (
|
||
<div data-testid="page-sprint" className="agile agile--wide">
|
||
<PageHeader title="Спринт" actions={<RefreshButton />} />
|
||
<AgileGate project={project} kind="scrum">
|
||
{(d) => {
|
||
const selectable = selectableSprints(d.sprints);
|
||
const ids = parseSprintIds(params.get("sprint"), d.sprints);
|
||
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 allNames = columnsOf(d.board?.columns ?? null, issues).map((c) => c.name); // меню — по задачам без фильтров (T7.2)
|
||
// выбор спринтов — в адресе, без новой записи истории
|
||
const pick = (next: number[]) => setParams((p) => { const n = new URLSearchParams(p); n.set("sprint", next.join(",")); return n; }, { replace: true });
|
||
return (
|
||
<>
|
||
<AgileToolbar filter={filter} setQ={setQ} set={set} onReset={reset} assignees={assigneeOptions(issues, view.assignee)} showHideDone>
|
||
{selectable.length > 0 && <SprintPicker sprints={selectable} selected={ids} onChange={pick} />}
|
||
{chosen.length > 0 && <ColumnsMenu names={allNames} hidden={view.hiddenColumns} onChange={(hiddenColumns) => set({ hiddenColumns })} />}
|
||
</AgileToolbar>
|
||
{chosen.length === 0 ? (
|
||
<Panel className="agile-section">
|
||
<p className="agile__empty">Нет активного спринта</p>
|
||
<Link className="agile__link" to={`/jira/backlog?project=${encodeURIComponent(project.label)}`}>Бэклог</Link>
|
||
</Panel>
|
||
) : (
|
||
<>
|
||
{chosen.map((s) => <SprintHead key={s.id} s={s} />)}
|
||
<BoardColumns columns={cols} hidden={view.hiddenColumns} />
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}}
|
||
</AgileGate>
|
||
</div>
|
||
);
|
||
}
|