Новые условия (#15)
Tests / frontend-test (push) Successful in 46s
Tests / backend-test (push) Successful in 1m48s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/15
This commit was merged in pull request #15.
This commit is contained in:
2026-08-24 12:27:25 +00:00
parent a1fa862ed4
commit e4a6cb8167
12 changed files with 137 additions and 31 deletions
@@ -1,5 +1,17 @@
@import '../../../styles/design-tokens';
// Full-width: снимаем max-width канвы SharePoint — и в workbench (dev), и на живой
// странице. !important оправдан: перебиваем встроенные стили канвы, иначе их не снять.
// Если после обновления платформы ширина вернётся — проверить актуальные селекторы в DevTools.
// Работает даже в обычной одноколоночной секции; supportsFullBleed в манифесте
// нужен дополнительно, чтобы web part можно было ставить в полноширинную секцию.
:global {
#workbenchPageContent,
.CanvasComponent.LCS .CanvasZone {
max-width: 100% !important;
}
}
.approvalAdmin {
font-family: $font-sans;
}
@@ -86,6 +86,12 @@ const OPERATION_OPTIONS = Object.values(ComparisonOperator)
.filter(v => v !== ComparisonOperator.None)
.map(v => ({ value: v, label: comparisonOperatorLabels[v] }));
/** «Больше»/«меньше» сравнивают числа — для текстовой колонки они бессмысленны. */
const NUMERIC_ONLY_OPERATIONS: ComparisonOperator[] = [ComparisonOperator.GreaterThan, ComparisonOperator.LessThan];
/** «Содержит»/«Не содержит» — поиск подстроки, только для текстовых колонок. */
const TEXT_ONLY_OPERATIONS: ComparisonOperator[] = [ComparisonOperator.Contains, ComparisonOperator.NotContains];
/**
* Служебное поле условия, не привязанное к списку SharePoint — вычисляется на бэкенде
* (см. TemplateRouteService.ResolveAsync) по значению поля Position В САМОЙ ЗАЯВКЕ
@@ -173,20 +179,33 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
// IsManager — служебное булево поле, а не колонка списка, поэтому для него
// числовые ограничения не действуют в любом случае.
const selectedField = sharePointFields.items.find(f => f.internalName === form.fieldName);
const isNumericField = isManagerField ? false : selectedField ? isNumericFieldType(selectedField.typeAsString) : true;
const fieldKind: 'bool' | 'numeric' | 'text' | 'unknown' = isManagerField
? 'bool'
: !selectedField
? 'unknown'
: isNumericFieldType(selectedField.typeAsString)
? 'numeric'
: 'text';
const isNumericField = fieldKind === 'numeric' || fieldKind === 'unknown';
const operationOptions = isNumericField
? OPERATION_OPTIONS
: OPERATION_OPTIONS.filter(o => o.value === ComparisonOperator.Equal);
// Числовая колонка: всё, кроме «содержит»/«не содержит». Текстовая: всё, кроме «больше»/«меньше».
// Булев IsManager: только «равно»/«не равно». Неизвестный тип — не ограничиваем.
const operationOptions = React.useMemo(() => OPERATION_OPTIONS.filter(o => {
if (fieldKind === 'bool') return o.value === ComparisonOperator.Equal || o.value === ComparisonOperator.NotEqual;
if (fieldKind === 'numeric') return TEXT_ONLY_OPERATIONS.indexOf(o.value) < 0;
if (fieldKind === 'text') return NUMERIC_ONLY_OPERATIONS.indexOf(o.value) < 0;
return true;
}), [fieldKind]);
// Строковая колонка допускает только «равно» — если пользователь сменил
// field_name на нечисловую колонку, а раньше был выбран «больше»/«меньше», сбрасываем.
// Оператор, недопустимый для выбранной колонки (например, «больше» после смены
// field_name на текстовую), сбрасываем на «равно» — оно допустимо всегда.
const isOperationAllowed = operationOptions.some(o => o.value === form.operation);
React.useEffect(() => {
if (!isNumericField && form.operation !== ComparisonOperator.Equal) {
if (!isOperationAllowed) {
setForm(f => ({ ...f, operation: ComparisonOperator.Equal }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNumericField]);
}, [isOperationAllowed]);
// IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS,
// а не произвольным текстом, который пользователь мог набрать для другого field_name.
@@ -33,5 +33,10 @@ export enum ComparisonOperator {
None = 'None',
GreaterThan = 'GreaterThan',
LessThan = 'LessThan',
Equal = 'Equal'
Equal = 'Equal',
NotEqual = 'NotEqual',
// «Содержит»/«Не содержит» имеют смысл только для текстовых колонок —
// см. ограничение операторов в RouteStepFormModal.
Contains = 'Contains',
NotContains = 'NotContains'
}
+8 -2
View File
@@ -39,14 +39,20 @@ export const comparisonOperatorLabels: Record<ComparisonOperator, string> = {
[ComparisonOperator.None]: '—',
[ComparisonOperator.GreaterThan]: 'Больше (>)',
[ComparisonOperator.LessThan]: 'Меньше (<)',
[ComparisonOperator.Equal]: 'Равно (=)'
[ComparisonOperator.Equal]: 'Равно (=)',
[ComparisonOperator.NotEqual]: 'Не равно (≠)',
[ComparisonOperator.Contains]: 'Содержит',
[ComparisonOperator.NotContains]: 'Не содержит'
};
export const comparisonOperatorSymbols: Record<ComparisonOperator, string> = {
[ComparisonOperator.None]: '',
[ComparisonOperator.GreaterThan]: '>',
[ComparisonOperator.LessThan]: '<',
[ComparisonOperator.Equal]: '='
[ComparisonOperator.Equal]: '=',
[ComparisonOperator.NotEqual]: '≠',
[ComparisonOperator.Contains]: 'содержит',
[ComparisonOperator.NotContains]: 'не содержит'
};
export const texts = {
@@ -9,7 +9,10 @@ import {
slaHoursToInput,
triStateToBool
} from './sla';
import { APPROVAL_PHASE } from '../models';
import { APPROVAL_PHASE, ManagedApprovalRow } from '../models';
/** Ровно те поля строки, которые читает activatedAt (см. его сигнатуру). */
type ActivationRow = Pick<ManagedApprovalRow, 'order' | 'phase' | 'dateIso'>;
// Смысл всех этих проверок один: «не задано» и «ноль/нет» — РАЗНЫЕ состояния.
// Если они где-то схлопнутся, шаг с настройкой «напоминать сразу» станет
@@ -110,7 +113,7 @@ describe('effectiveSlaHours', () => {
// Повторяет ReminderService.ActivatedAt на бэкенде — та же формула, поэтому и
// проверяется на тех же случаях (параллельные шаги, переход между фазами).
describe('activatedAt', () => {
const row = (order: number, phase: string, dateIso: string) => ({ order, phase, dateIso });
const row = (order: number, phase: string, dateIso: string): ActivationRow => ({ order, phase, dateIso });
it('первый шаг согласования — от создания заявки', () => {
const rows = [row(1, APPROVAL_PHASE.approval, '')];
@@ -76,10 +76,11 @@ export function formatSlaHours(value: number | null | undefined): string {
* здесь его нужно повторить, чтобы посчитать просрочку и показать реальное число
* часов, а не только «от типа заявки» без цифры.
*/
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
export function effectiveSlaHours(
row: Pick<ManagedApprovalRow, 'slaHours'>,
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
requestTypeSlaHours: number | null | undefined
// eslint-disable-next-line @rushstack/no-new-null -- null = «SLA не задан нигде», это не 0
): number | null {
if (row.slaHours !== null) return row.slaHours;
return requestTypeSlaHours ?? null;
@@ -140,6 +141,7 @@ export function isRowOverdue(
row: Pick<ManagedApprovalRow, 'order' | 'phase' | 'dateIso' | 'slaHours'>,
allRows: readonly Pick<ManagedApprovalRow, 'order' | 'phase' | 'dateIso'>[],
requestCreatedIso: string,
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
requestTypeSlaHours: number | null | undefined,
now: Date
): boolean {