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 }) => ( + + {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'); +});