Обходной лист #12
@@ -34,6 +34,7 @@
|
||||
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||
"ClientSecret": "",
|
||||
"Scope": "",
|
||||
"DirectorTansuEmail": "a.samayeva@tnsu.kz"
|
||||
"DirectorTansuEmail": "a.samayeva@tnsu.kz",
|
||||
"ManagingDirectorTansuEmail": "nkim@tnsu.kz"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,11 @@ public sealed class TemplateRouteService(
|
||||
.GroupBy(f => f.FieldName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First().Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Синтетическое поле условия (не пришедшее из request.FieldValues, а вычисленное
|
||||
// здесь): должность в справочнике — свободный текст, поэтому «руководитель или
|
||||
// нет» определяем по ключевым словам в её названии, а не по отдельному флагу.
|
||||
fieldValues["IsManager"] = IsManagerPosition(employee.Position) ? "true" : "false";
|
||||
|
||||
var matchingSteps = route.Steps
|
||||
.Where(s => s.Status == request.Status)
|
||||
.Where(s => MatchesCondition(s, fieldValues))
|
||||
@@ -264,6 +269,7 @@ public sealed class TemplateRouteService(
|
||||
HeadType.Chief => await portalClient.GetManagerAsync(initiatorEmail, "chief", cancellationToken),
|
||||
HeadType.DirectionHead => await portalClient.GetManagerAsync(initiatorEmail, "directionHead", cancellationToken),
|
||||
HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail,
|
||||
HeadType.ManagingDirectorTansu => portalOptions.Value.ManagingDirectorTansuEmail,
|
||||
_ => null
|
||||
};
|
||||
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||
@@ -328,6 +334,21 @@ public sealed class TemplateRouteService(
|
||||
private static bool TryParseNumber(string? value, out double number) =>
|
||||
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number);
|
||||
|
||||
/// <summary>
|
||||
/// Должность в справочнике api.tnsu.kz — свободный текст без структуры (нет
|
||||
/// отдельного флага «это руководитель»), поэтому определяем это по ключевым
|
||||
/// словам в названии должности. Список подобран по фактическим должностям
|
||||
/// в справочнике — Начальник/Руководитель/Директор/Заместитель/Главный/
|
||||
/// Заведующий складом. «Ведущий», «Старший» и «Менеджер» намеренно не входят:
|
||||
/// это старший специалист, а не должность с подчинёнными.
|
||||
/// </summary>
|
||||
private static readonly string[] ManagerPositionKeywords =
|
||||
["начальник", "руководитель", "директор", "заместитель", "главный", "заведующий склад"];
|
||||
|
||||
private static bool IsManagerPosition(string? position) =>
|
||||
!string.IsNullOrWhiteSpace(position) &&
|
||||
ManagerPositionKeywords.Any(keyword => position.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
|
||||
{
|
||||
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -6,6 +6,7 @@ public enum HeadType
|
||||
Chief,
|
||||
DirectionHead,
|
||||
DirectorTansu,
|
||||
ManagingDirectorTansu,
|
||||
|
||||
/// <summary>
|
||||
/// Руководитель отдела, указанного явно в запросе на resolve (см.
|
||||
|
||||
@@ -8,4 +8,5 @@ public sealed class TnsuPortalOptions
|
||||
public string ClientSecret { get; set; } = string.Empty;
|
||||
public string Scope { get; set; } = string.Empty;
|
||||
public string DirectorTansuEmail { get; set; } = "director.tansu@tnsu.kz";
|
||||
public string ManagingDirectorTansuEmail { get; set; } = "nkim@tnsu.kz";
|
||||
}
|
||||
|
||||
@@ -76,6 +76,18 @@ const OPERATION_OPTIONS = Object.values(ComparisonOperator)
|
||||
.filter(v => v !== ComparisonOperator.None)
|
||||
.map(v => ({ value: v, label: comparisonOperatorLabels[v] }));
|
||||
|
||||
/**
|
||||
* Служебное поле условия, не привязанное к списку SharePoint — вычисляется на бэкенде
|
||||
* из должности инициатора (см. TemplateRouteService.ResolveAsync), а не из данных заявки.
|
||||
* Поэтому оно доступно всегда, даже если у типа заявки не настроен list_name.
|
||||
*/
|
||||
const IS_MANAGER_FIELD = 'IsManager';
|
||||
const SYNTHETIC_FIELD_OPTIONS = [{ value: IS_MANAGER_FIELD, label: texts.routeSteps.conditionFieldIsManagerLabel }];
|
||||
const IS_MANAGER_VALUE_OPTIONS = [
|
||||
{ value: 'true', label: texts.common.yes },
|
||||
{ value: 'false', label: texts.common.no }
|
||||
];
|
||||
|
||||
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
|
||||
if (!editing) {
|
||||
return {
|
||||
@@ -132,18 +144,23 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
|
||||
|
||||
const fieldNameOptions = React.useMemo(() => {
|
||||
const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||
const listOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
|
||||
const options = [...SYNTHETIC_FIELD_OPTIONS, ...listOptions];
|
||||
if (form.fieldName && !options.some(o => o.value === form.fieldName)) {
|
||||
options.unshift({ value: form.fieldName, label: form.fieldName });
|
||||
options.push({ value: form.fieldName, label: form.fieldName });
|
||||
}
|
||||
return options;
|
||||
}, [sharePointFields.items, form.fieldName]);
|
||||
|
||||
const isManagerField = form.fieldName === IS_MANAGER_FIELD;
|
||||
|
||||
// Тип столбца известен, только если он есть среди подгруженных полей списка —
|
||||
// для значения, введённого до появления поля в списке (или после его удаления),
|
||||
// ограничения ниже не действуют, чтобы не блокировать уже сохранённые данные.
|
||||
// IsManager — служебное булево поле, а не колонка списка, поэтому для него
|
||||
// числовые ограничения не действуют в любом случае.
|
||||
const selectedField = sharePointFields.items.find(f => f.internalName === form.fieldName);
|
||||
const isNumericField = selectedField ? isNumericFieldType(selectedField.typeAsString) : true;
|
||||
const isNumericField = isManagerField ? false : selectedField ? isNumericFieldType(selectedField.typeAsString) : true;
|
||||
|
||||
const operationOptions = isNumericField
|
||||
? OPERATION_OPTIONS
|
||||
@@ -158,6 +175,15 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isNumericField]);
|
||||
|
||||
// IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS,
|
||||
// а не произвольным текстом, который пользователь мог набрать для другого field_name.
|
||||
React.useEffect(() => {
|
||||
if (isManagerField && form.value !== 'true' && form.value !== 'false') {
|
||||
setForm(f => ({ ...f, value: 'true' }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isManagerField]);
|
||||
|
||||
const submitForm = (): void => {
|
||||
const orderNo = Number(form.orderNo);
|
||||
if (!orderNo || orderNo < 1) {
|
||||
@@ -326,12 +352,11 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
<Checkbox
|
||||
label={texts.routeSteps.conditionCheckbox}
|
||||
checked={form.hasCondition}
|
||||
disabled={!hasListId}
|
||||
onChange={v => setForm({ ...form, hasCondition: v })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{form.hasCondition && hasListId && (
|
||||
{form.hasCondition && (
|
||||
<>
|
||||
<Field
|
||||
label={texts.routeSteps.conditionFieldNameLabel}
|
||||
@@ -361,12 +386,20 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.routeSteps.conditionValueLabel} required>
|
||||
{isManagerField ? (
|
||||
<Select
|
||||
options={IS_MANAGER_VALUE_OPTIONS}
|
||||
value={form.value}
|
||||
onChange={e => setForm({ ...form, value: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
type={isNumericField ? 'number' : 'text'}
|
||||
value={form.value}
|
||||
onChange={e => setForm({ ...form, value: e.target.value })}
|
||||
placeholder={texts.routeSteps.conditionValuePlaceholder}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -22,6 +22,7 @@ export enum HeadType {
|
||||
Chief = 'Chief',
|
||||
DirectionHead = 'DirectionHead',
|
||||
DirectorTansu = 'DirectorTansu',
|
||||
ManagingDirectorTansu = 'ManagingDirectorTansu',
|
||||
// Резолвится по departmentId из запроса на resolve (см. ResolveTemplateRouteQuery),
|
||||
// а не по цепочке подчинённости инициатора, как остальные типы, — поэтому на
|
||||
// экране «Проверка маршрута» появляется отдельное поле выбора отдела.
|
||||
|
||||
@@ -31,6 +31,7 @@ export const headTypeLabels: Record<HeadType, string> = {
|
||||
[HeadType.Chief]: 'Непосредственный руководитель',
|
||||
[HeadType.DirectionHead]: 'Руководитель направления',
|
||||
[HeadType.DirectorTansu]: 'Директор Tansu',
|
||||
[HeadType.ManagingDirectorTansu]: 'Управляющий директор Tansu',
|
||||
[HeadType.DepartmentHead]: 'Руководитель указанного отдела'
|
||||
};
|
||||
|
||||
@@ -233,7 +234,9 @@ export const texts = {
|
||||
askOverdueReasonHint: '«Не задано» — берётся значение типа заявки.',
|
||||
conditionLabel: 'Условие видимости шага',
|
||||
conditionCheckbox: 'Показывать шаг только при выполнении условия',
|
||||
conditionDisabledHint: 'Недоступно: у типа заявки не указан список SharePoint (list_name).',
|
||||
conditionDisabledHint:
|
||||
'У типа заявки не указан список SharePoint (list_name) — доступно только служебное поле «Инициатор — руководитель».',
|
||||
conditionFieldIsManagerLabel: 'Инициатор — руководитель (IsManager)',
|
||||
conditionFieldNameLabel: 'Поле списка',
|
||||
conditionFieldNamePlaceholder: 'Выберите столбец списка',
|
||||
conditionFieldNameLoading: 'Загрузка столбцов списка...',
|
||||
|
||||
Reference in New Issue
Block a user