diff --git a/web/src/components/Stages/SettingModal.jsx b/web/src/components/Stages/SettingModal.jsx
index ad213bd..1f7d058 100644
--- a/web/src/components/Stages/SettingModal.jsx
+++ b/web/src/components/Stages/SettingModal.jsx
@@ -1,5 +1,5 @@
import { Box, CircularProgress, Stack } from '@mui/material';
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { useAuth } from '../../app/context/AuthProvider';
import { useTaskStages } from '../../hooks/useTaskStages';
import { useProjectStages } from '../../hooks/useProjectStages';
@@ -10,7 +10,9 @@ import { EditContainer } from '../common/Modal/ModalStyled';
import { WhitePlus } from '../common/icons/icons';
import { AddStagesModal } from './AddStagesModal';
import { DeleteStageModal } from './DeleteStageModal';
+import { StagesFilters } from './StagesFilters';
import { StagesTable } from './StagesTable/StagesTable';
+import { EMPTY_STAGE_FILTERS, filterStages, sortStages } from './stageFilters';
export const SettingModal = ({
isOpen = true,
@@ -50,6 +52,24 @@ export const SettingModal = ({
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [stageToDelete, setStageToDelete] = useState(null);
const [sheetOptions, setSheetOptions] = useState([]);
+ const [filters, setFilters] = useState(EMPTY_STAGE_FILTERS);
+
+ useEffect(() => {
+ if (!isOpen) setFilters(EMPTY_STAGE_FILTERS);
+ }, [isOpen]);
+
+ const allStages = stagesInfo.stages || [];
+ const visibleStages = useMemo(
+ () =>
+ filterStages(
+ sortStages(
+ allStages,
+ (sheets || []).map((s) => s.sheet),
+ ),
+ filters,
+ ),
+ [allStages, sheets, filters],
+ );
const handleAddStage = () => {
setStageForEdit(null);
@@ -119,11 +139,25 @@ export const SettingModal = ({
) : (
-
+ <>
+
+ 0 && visibleStages.length === 0
+ ? 'По выбранным фильтрам этапы не найдены'
+ : undefined
+ }
+ onEdit={handleEditStage}
+ onDelete={handleDeleteStage}
+ />
+ >
)}
{isAdmin && (
{
+ const title = SHEET_NAME[sheet] || name || sheet;
+ return direction ? `${title} — ${DIRECTION_TRANSLATE[direction] ?? direction}` : title;
+};
+
+export const StagesFilters = ({ filters, onChange, sheets = [], showSheet = true, showRole = false }) => {
+ const changeFilter = (key, value) => onChange({ ...filters, [key]: value });
+ const sheetOptions = [];
+ for (const item of sheets) {
+ const value = getSheetKey(item);
+ if (!sheetOptions.some((o) => o.value === value)) sheetOptions.push({ value, label: getSheetLabel(item) });
+ }
+
+ const selects = [
+ showSheet && ['sheetKey', 'Лист', sheetOptions],
+ showRole && ['role', 'Роль', Object.entries(STAGE_ROLE_RUSSIAN_NAME).map(([value, label]) => ({ value, label }))],
+ ['status', 'Статус', STATUS_OPTIONS.map((value) => ({ value, label: value }))],
+ ].filter(Boolean);
+
+ return (
+
+ changeFilter('search', e.target.value)}
+ sx={{ minWidth: 220, flex: 1 }}
+ />
+ {selects.map(([key, label, options]) => (
+ changeFilter(key, e.target.value)}
+ sx={{ minWidth: 170 }}>
+
+ {options.map(({ value, label: text }) => (
+
+ ))}
+
+ ))}
+ onChange({ ...EMPTY_STAGE_FILTERS })}>Сбросить
+
+ );
+};
diff --git a/web/src/components/Stages/StagesTable/StagesTable.jsx b/web/src/components/Stages/StagesTable/StagesTable.jsx
index 04bd411..1e68c5d 100644
--- a/web/src/components/Stages/StagesTable/StagesTable.jsx
+++ b/web/src/components/Stages/StagesTable/StagesTable.jsx
@@ -9,7 +9,7 @@ import { getStageKey } from '../utils';
import { StyledTable, StyledTableContainer, StyledTableHead, StyledTableHeadRow, StyledTableRow } from './StagesTable.style';
import { getStageStatus, getStatusColors } from './utils';
-export const StagesTable = ({ stages, onEdit, onDelete }) => {
+export const StagesTable = ({ stages, onEdit, onDelete, emptyText = 'Нет данных для отображения' }) => {
const [anchorEl, setAnchorEl] = useState(null);
const [selectedStage, setSelectedStage] = useState(null);
const { user } = useAuth();
@@ -57,7 +57,7 @@ export const StagesTable = ({ stages, onEdit, onDelete }) => {
{!stages || stages.length === 0 ? (
- Нет данных для отображения
+ {emptyText}
) : (
diff --git a/web/src/components/Stages/StagesTable/utils.js b/web/src/components/Stages/StagesTable/utils.js
index 67b31b0..0f9f2b4 100644
--- a/web/src/components/Stages/StagesTable/utils.js
+++ b/web/src/components/Stages/StagesTable/utils.js
@@ -1,4 +1,4 @@
-export const getStageStatus = (stage) => {
+export const getStageStatus = (stage, nowDate = new Date()) => {
const start = stage.opens_at;
const end = stage.closes_at;
@@ -6,7 +6,7 @@ export const getStageStatus = (stage) => {
return 'Не определен';
}
- const now = new Date();
+ const now = new Date(nowDate);
now.setHours(0, 0, 0, 0);
const startDate = new Date(start);
diff --git a/web/src/components/Stages/stageFilters.js b/web/src/components/Stages/stageFilters.js
new file mode 100644
index 0000000..823df24
--- /dev/null
+++ b/web/src/components/Stages/stageFilters.js
@@ -0,0 +1,46 @@
+import { getStageStatus } from './StagesTable/utils.js';
+
+export const EMPTY_STAGE_FILTERS = { search: '', sheetKey: '', role: '', status: '' };
+
+export const DIRECTION_ORDER = ['Support', 'Development'];
+
+export const getSheetKey = ({ sheet, direction }) => `${sheet}|${direction ?? ''}`;
+
+const normalize = (value) =>
+ String(value ?? '')
+ .trim()
+ .toLowerCase()
+ .replaceAll('ё', 'е');
+
+export const filterStages = (stages, filters = EMPTY_STAGE_FILTERS, now = new Date()) => {
+ const { search = '', sheetKey = '', role = '', status = '' } = filters ?? {};
+ const query = normalize(search);
+ return (stages ?? []).filter((stage) => {
+ if (sheetKey && getSheetKey(stage) !== sheetKey) return false;
+ if (role && stage.role !== role) return false;
+ if (status && getStageStatus(stage, now) !== status) return false;
+ if (query && !normalize(stage.phase_code).includes(query)) return false;
+ return true;
+ });
+};
+
+const rank = (list, value) => {
+ const index = list.indexOf(value);
+ return index === -1 ? list.length : index;
+};
+
+const time = (value) => (value ? new Date(value).getTime() : Number.POSITIVE_INFINITY);
+
+export const sortStages = (stages, sheetOrder = []) =>
+ [...(stages ?? [])].sort((a, b) => {
+ const bySheet = rank(sheetOrder, a.sheet) - rank(sheetOrder, b.sheet);
+ if (bySheet !== 0) return bySheet;
+ // направления: Support, Development, затем без направления
+ const dirRank = (s) => (s.direction ? rank(DIRECTION_ORDER, s.direction) : DIRECTION_ORDER.length + 1);
+ const byDirection = dirRank(a) - dirRank(b);
+ if (byDirection !== 0) return byDirection;
+ const ta = time(a.opens_at);
+ const tb = time(b.opens_at);
+ if (ta !== tb) return ta < tb ? -1 : 1;
+ return String(a.phase_code ?? '').localeCompare(String(b.phase_code ?? ''), 'ru');
+ });
diff --git a/web/src/components/Stages/stageFilters.test.js b/web/src/components/Stages/stageFilters.test.js
new file mode 100644
index 0000000..0147c0f
--- /dev/null
+++ b/web/src/components/Stages/stageFilters.test.js
@@ -0,0 +1,83 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { EMPTY_STAGE_FILTERS, filterStages, sortStages } from './stageFilters.js';
+
+const NOW = new Date(2026, 10, 11);
+const mk = (sheet, direction, phase_code, extra = {}) => ({
+ budget_form_id: 1,
+ sheet,
+ direction,
+ phase_code,
+ role: 'executor',
+ opens_at: '2026-11-01',
+ closes_at: '2026-11-30',
+ ...extra,
+});
+
+const stages = [
+ mk('AHR', 'Support', 'Сбор'),
+ mk('AHR', 'Development', 'Сбор'),
+ mk('OPER', null, 'Сбор'),
+ mk('OPER', null, 'Ёлка', { role: 'approver', opens_at: '2026-01-01', closes_at: '2026-02-01' }),
+ mk('AHR', 'Support', 'Будущее', { opens_at: '2027-01-01', closes_at: '2027-02-01' }),
+];
+
+const f = (o) => ({ ...EMPTY_STAGE_FILTERS, ...o });
+
+test('пустые фильтры возвращают всё', () => {
+ assert.equal(filterStages(stages, EMPTY_STAGE_FILTERS, NOW).length, stages.length);
+ assert.equal(filterStages(stages, undefined, NOW).length, stages.length);
+});
+
+test('AHR|Development не пропускает AHR|Support с тем же названием', () => {
+ const res = filterStages(stages, f({ sheetKey: 'AHR|Development' }), NOW);
+ assert.equal(res.length, 1);
+ assert.equal(res[0].direction, 'Development');
+});
+
+test('OPER| пропускает только этапы без направления', () => {
+ const res = filterStages(stages, f({ sheetKey: 'OPER|' }), NOW);
+ assert.equal(res.length, 2);
+ assert.ok(res.every((s) => s.sheet === 'OPER' && !s.direction));
+});
+
+test('фильтр по статусу при фиксированном now', () => {
+ assert.equal(filterStages(stages, f({ status: 'Активен' }), NOW).length, 3);
+ assert.equal(filterStages(stages, f({ status: 'Завершен' }), NOW)[0].phase_code, 'Ёлка');
+ assert.equal(filterStages(stages, f({ status: 'Запланирован' }), NOW)[0].phase_code, 'Будущее');
+});
+
+test('поиск без учёта регистра, кириллица и Ё', () => {
+ assert.equal(filterStages(stages, f({ search: 'сбОР' }), NOW).length, 3);
+ assert.equal(filterStages(stages, f({ search: 'ёлка' }), NOW).length, 1);
+ assert.equal(filterStages(stages, f({ search: 'ЁЛКА' }), NOW).length, 1);
+ assert.equal(filterStages(stages, f({ search: ' сбор ' }), NOW).length, 3);
+});
+
+test('фильтр роли', () => {
+ const res = filterStages(stages, f({ role: 'approver' }), NOW);
+ assert.equal(res.length, 1);
+ assert.equal(res[0].phase_code, 'Ёлка');
+});
+
+test('фильтры комбинируются', () => {
+ const res = filterStages(stages, f({ sheetKey: 'AHR|Support', status: 'Активен' }), NOW);
+ assert.equal(res.length, 1);
+});
+
+test('sortStages: лист по порядку sheets, затем направление, дата, название', () => {
+ const sheetOrder = ['OPER', 'AHR'];
+ const shuffled = [stages[4], stages[1], stages[3], stages[0], stages[2]];
+ const res = sortStages(shuffled, sheetOrder);
+ assert.deepEqual(
+ res.map((s) => `${s.sheet}|${s.direction ?? ''}|${s.phase_code}`),
+ ['OPER||Ёлка', 'OPER||Сбор', 'AHR|Support|Сбор', 'AHR|Support|Будущее', 'AHR|Development|Сбор'],
+ );
+});
+
+test('sortStages не мутирует вход, неизвестный лист в конце', () => {
+ const input = [mk('ZZZ', null, 'A'), mk('AHR', null, 'B')];
+ const res = sortStages(input, ['AHR']);
+ assert.equal(res[0].sheet, 'AHR');
+ assert.equal(input[0].sheet, 'ZZZ');
+});