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..4fdb979 --- /dev/null +++ b/web/src/components/Stages/stageFilters.js @@ -0,0 +1,42 @@ +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'); +});