diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 6c31d8c..786fcad 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -28,6 +28,8 @@ import { getImpersonatedEmail, getImpersonatedDisplayName } from '../shared/impe import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges'; import { Drawer } from './Drawer/Drawer'; import { CategoryTabs } from './CategoryTabs/CategoryTabs'; +import { TeamsBrowserNotice } from './TeamsBrowserNotice/TeamsBrowserNotice'; +import { isOpenedInTeams } from '../shared/hostEnvironment'; import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../data/categories'; import { REQUEST_TYPES } from '../data/requests'; import SubmitFeature from '../features/submit/SubmitFeature'; @@ -455,6 +457,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context onInput={markFormDirty} onChange={markFormDirty} > + {isOpenedInTeams(hasTeamsContext) && }
( +
+
{NOTICE_TITLE}
+

{NOTICE_TEXT}

+ + {LINK_TEXT} + +
+); diff --git a/src/webparts/requestPortal/forms/RecruitingHire/README.md b/src/webparts/requestPortal/forms/RecruitingHire/README.md index 11336a2..93d96ff 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/README.md +++ b/src/webparts/requestPortal/forms/RecruitingHire/README.md @@ -22,7 +22,8 @@ `RecruitingAgency`; при включении показываются «Название компании» (`AgencyName`) и «Условия» (`AgencyTerms`), при выключении оба поля очищаются. - **Условное поле «Взамен кого» (ReplacedEmployee)** — показывается только когда - тип приёма = замена уволенного (см. `HIRE_TYPE_REPLACEMENT` в модели). + тип приёма = вместо уволенного / вместо переведенного / декрет + (см. `REPLACED_EMPLOYEE_LABELS` в `sections/ApplicationSection.tsx`). - Секция **СИЗ** (рост, размеры одежды/обуви). - **Является источником для «Организация рабочего места»** — из просмотра этой заявки кнопка «Организация рабочего места на основе этой» открывает форму diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index 1d726ba..d65ed13 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -8,6 +8,8 @@ import { FormModeProvider, FieldChangesProvider, FieldChangesBanner, + FieldIssuesProvider, + issueLabels, FileDropzone, AttachmentList, CollapseSection, @@ -53,7 +55,7 @@ import { dataListTitleForListId } from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; -import { missingRequiredFields } from './validation'; +import { draftIssues, submitIssues } from './validation'; import { saveHireRequest, hireApprovalService } from './submit'; import { loadHireRequest } from './load'; import { @@ -211,32 +213,18 @@ const RecruitingHireForm: React.FC = ({ resolveApproverOrgInfo(context) ); - // При совмещении (2+ компании в «Организация») нужно явно указать, какая из них основная. - const mainCompanyMissing = data.Companies.length >= 2 && !data.MainCompanyId; - // ProjectId обязателен (а не просто текст Project): маршрут резолвится ПО - // ПРОЕКТУ (ByProject, см. projectId выше), и без выбранного из списка - // варианта (id) approval-route вернёт 404 — форма должна не пускать на - // отправку раньше, чем выяснится, что маршрут вообще некому строить (баг: - // раньше это не проверялось, и заявка молча уходила на согласование с - // пустой матрицей). - const isValid = - data.HireCandidateName.trim().length > 0 && - data.Position.trim().length > 0 && - !!data.ProjectId && - !mainCompanyMissing; + // Черновику нужны лишь несколько полей, отправке — все обязательные (см. validation.ts). + const draftMissing = draftIssues(data); + const submitMissing = submitIssues(data); + const isValid = draftMissing.length === 0; + const isSubmitValid = submitMissing.length === 0; const validate = (): string | undefined => { - if (mainCompanyMissing) return 'Выбрано несколько организаций — укажите, какая из них основное место работы.'; - if (!isValid) return 'Заполните обязательные поля: Ф.И.О. кандидата, Должность, Проект (выберите вариант из списка).'; + if (!isValid) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; return undefined; }; - // Остальные поля формы обязательны только для ОТПРАВКИ на согласование — - // черновик по-прежнему можно сохранить не заполнив их (см. validation.ts). - const missingFields = missingRequiredFields(data); - const isSubmitValid = isValid && missingFields.length === 0; - const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => // order обязателен: параллельные шаги делят один номер, по позиции в // массиве его не восстановить (см. useMatrixEditor/withOrders). @@ -328,8 +316,8 @@ const RecruitingHireForm: React.FC = ({ const validateSubmit = (): string | undefined => { const formError = validate(); if (formError) return formError; - if (missingFields.length > 0) { - return `Заполните обязательные поля: ${missingFields.join(', ')}.`; + if (!isSubmitValid) { + return `Заполните обязательные поля: ${issueLabels(submitMissing)}.`; } if (matrix.error) { return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; @@ -382,7 +370,7 @@ const RecruitingHireForm: React.FC = ({ }); return ( -
+
+ ); }; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index 55013da..3ad3142 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -14,7 +14,8 @@ import { Icon, PrintButton, FieldChangesProvider, - FieldChangesBanner + FieldChangesBanner, + issueLabels } from '../../shared/ui'; import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges'; import { HIRE_CHANGE_TRACKING } from './changeTracking'; @@ -51,7 +52,7 @@ import { loadHireRequest } from './load'; import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, attachmentsOfCategory, uncategorizedAttachments } from './attachmentCategories'; import { confirmDiscardFormChanges } from '../../shared/unsavedChanges'; import { hireApprovalService, updateApproverFields, updateAllHireFields, IApproverEditableFields } from './submit'; -import { missingRequiredFields } from './validation'; +import { submitIssues } from './validation'; import { canEditPayrollFields } from './approverRights'; import { ApplicationSection } from './sections/ApplicationSection'; import { CandidateSection } from './sections/CandidateSection'; @@ -230,8 +231,8 @@ const RecruitingHireView: React.FC = ({ context, itemI throw new Error(HIRE_DATE_REQUIRED_MESSAGE); } if (fullEditable && decision === 'approve') { - const missing = missingRequiredFields(data); - if (missing.length > 0) throw new Error(`заполните обязательные поля: ${missing.join(', ')}.`); + const missing = submitIssues(data); + if (missing.length > 0) throw new Error(`заполните обязательные поля: ${issueLabels(missing)}.`); } await saveApproverFields(); }; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/hireTypeFromSelection.test.ts b/src/webparts/requestPortal/forms/RecruitingHire/hireTypeFromSelection.test.ts index 7b538ee..8bbd96b 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/hireTypeFromSelection.test.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/hireTypeFromSelection.test.ts @@ -9,13 +9,20 @@ describe('hireTypeFromSelection', () => { }); }); - it('«Замена уволенного сотрудника» → «Вместо уволенного» + ФИО', () => { + it('«Замена уволенного сотрудника» → «Вместо уволенного сотрудника» + ФИО', () => { expect(hireTypeFromSelection('Замена уволенного сотрудника', 'Петров Пётр Петрович')).toEqual({ - hireType: 'Вместо уволенного', + hireType: 'Вместо уволенного сотрудника', replacedEmployee: 'Петров Пётр Петрович' }); }); + it('«Перевод сотрудника» → «Вместо переведенного сотрудника» + ФИО', () => { + expect(hireTypeFromSelection('Перевод сотрудника', 'Сидоров Сидор Сидорович')).toEqual({ + hireType: 'Вместо переведенного сотрудника', + replacedEmployee: 'Сидоров Сидор Сидорович' + }); + }); + it('«Новая штатная единица» → «Новый сотрудник» без ФИО', () => { expect(hireTypeFromSelection('Новая штатная единица', 'что-то')).toEqual({ hireType: 'Новый сотрудник', @@ -23,15 +30,14 @@ describe('hireTypeFromSelection', () => { }); }); - it('перевод, «Другое» и пустая причина — без подстановки', () => { - expect(hireTypeFromSelection('Перевод сотрудника', 'Сидоров')).toBeUndefined(); + it('«Другое» и пустая причина — без подстановки', () => { expect(hireTypeFromSelection('Другое', 'уточнение')).toBeUndefined(); expect(hireTypeFromSelection('', '')).toBeUndefined(); }); it('подставляемый тип всегда есть среди вариантов «Тип приёма»', () => { const allowed = HIRE_TYPE_OPTIONS.map((o) => o.value); - ['Декрет', 'Замена уволенного сотрудника', 'Новая штатная единица'].forEach((reason) => { + ['Декрет', 'Замена уволенного сотрудника', 'Перевод сотрудника', 'Новая штатная единица'].forEach((reason) => { expect(allowed).toContain(hireTypeFromSelection(reason, '')?.hireType); }); }); diff --git a/src/webparts/requestPortal/forms/RecruitingHire/hireTypeFromSelection.ts b/src/webparts/requestPortal/forms/RecruitingHire/hireTypeFromSelection.ts index 215c8aa..03115fe 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/hireTypeFromSelection.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/hireTypeFromSelection.ts @@ -1,23 +1,25 @@ import { OPEN_REASON_REPLACE, + OPEN_REASON_TRANSFER, OPEN_REASON_NEW, OPEN_REASON_MATERNITY } from '../RecruitingSelection/model'; -import { HIRE_TYPE_NEW, HIRE_TYPE_REPLACEMENT, HIRE_TYPE_MATERNITY } from './model'; +import { HIRE_TYPE_NEW, HIRE_TYPE_REPLACEMENT, HIRE_TYPE_TRANSFER, HIRE_TYPE_MATERNITY } from './model'; /** * Причина открытия вакансии (заявка на подбор) → тип приёма (заявка на приём). - * «Перевод сотрудника» и «Другое» однозначного соответствия не имеют — их тут - * нет, тип приёма в таком случае выбирает инициатор сам. + * «Другое» однозначного соответствия не имеет — его тут нет, тип приёма в таком + * случае выбирает инициатор сам. */ const REASON_TO_HIRE_TYPE: Record = { [OPEN_REASON_REPLACE]: HIRE_TYPE_REPLACEMENT, + [OPEN_REASON_TRANSFER]: HIRE_TYPE_TRANSFER, [OPEN_REASON_MATERNITY]: HIRE_TYPE_MATERNITY, [OPEN_REASON_NEW]: HIRE_TYPE_NEW }; /** Типы приёма, для которых переносим ФИО замещаемого сотрудника. */ -const HIRE_TYPES_WITH_REPLACED_EMPLOYEE = [HIRE_TYPE_REPLACEMENT, HIRE_TYPE_MATERNITY]; +const HIRE_TYPES_WITH_REPLACED_EMPLOYEE = [HIRE_TYPE_REPLACEMENT, HIRE_TYPE_TRANSFER, HIRE_TYPE_MATERNITY]; export interface IHireTypePrefill { hireType: string; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/load.ts b/src/webparts/requestPortal/forms/RecruitingHire/load.ts index 196f226..f645ab3 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/load.ts @@ -2,7 +2,7 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { ICompany } from '../../shared/dictionary'; import { dataListTitle } from '../../shared/listTitles'; import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges'; -import { FORM_ID, IFormData, EMPTY_DATA, ICompanyShare } from './model'; +import { FORM_ID, IFormData, EMPTY_DATA, ICompanyShare, LEGACY_HIRE_TYPES } from './model'; import { APPROVER_CHANGES_FIELD } from './changeTracking'; /** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */ @@ -81,6 +81,7 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise< writable[key] = String(value); } }); + data.HireType = LEGACY_HIRE_TYPES[data.HireType] || data.HireType; return { data, diff --git a/src/webparts/requestPortal/forms/RecruitingHire/model.ts b/src/webparts/requestPortal/forms/RecruitingHire/model.ts index 7fb4420..94606a2 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/model.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/model.ts @@ -127,7 +127,10 @@ export const HIRE_ON_ARRIVAL_LABEL = 'По приезду на объект'; export const HIRE_TYPE_NEW = 'Новый сотрудник'; /** Значение «Тип приёма», при котором показывается поле «Взамен кого». */ -export const HIRE_TYPE_REPLACEMENT = 'Вместо уволенного'; +export const HIRE_TYPE_REPLACEMENT = 'Вместо уволенного сотрудника'; + +/** Приём на место сотрудника, переведённого на другую должность, — тоже спрашиваем «Взамен кого». */ +export const HIRE_TYPE_TRANSFER = 'Вместо переведенного сотрудника'; /** Приём на время декретного отпуска сотрудника — тоже спрашиваем «Взамен кого». */ export const HIRE_TYPE_MATERNITY = 'Декрет'; @@ -136,9 +139,18 @@ export const HIRE_TYPE_MATERNITY = 'Декрет'; export const HIRE_TYPE_OPTIONS: ISelectOption[] = [ { value: HIRE_TYPE_NEW, label: HIRE_TYPE_NEW }, { value: HIRE_TYPE_REPLACEMENT, label: HIRE_TYPE_REPLACEMENT }, + { value: HIRE_TYPE_TRANSFER, label: HIRE_TYPE_TRANSFER }, { value: HIRE_TYPE_MATERNITY, label: HIRE_TYPE_MATERNITY } ]; +/** + * Прежние значения «Тип приёма» → текущие. В уже поданных заявках SP хранит + * старый текст; без замены при загрузке поле 1.5 в них показывалось бы пустым. + */ +export const LEGACY_HIRE_TYPES: Record = { + 'Вместо уволенного': HIRE_TYPE_REPLACEMENT +}; + /** Значение-триггер «Другое»: при нём показываем поле ручного ввода. */ export const OTHER_VALUE = 'Другое'; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json b/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json index dcadc87..830f713 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json @@ -9,7 +9,7 @@ { "internalName": "CompanySharesJson", "title": "Распределение ЗП по компаниям (JSON)", "type": "Note" }, { "internalName": "Phone", "title": "Телефон", "type": "Text" }, - { "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного", "Декрет"] }, + { "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного сотрудника", "Вместо переведенного сотрудника", "Декрет"] }, { "internalName": "HireReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" }, { "internalName": "Project", "title": "Проект", "type": "Text" }, diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx index 1d085a7..297cbc5 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx @@ -1,13 +1,14 @@ import * as React from 'react'; import { CollapseSection, FormField, TextInput, PhoneInput, Select } from '../../../shared/ui'; import { CompanyPickerMulti, ProjectPicker, ICompany } from '../../../shared/dictionary'; -import { ISectionProps, HIRE_TYPE_OPTIONS, HIRE_TYPE_REPLACEMENT, HIRE_TYPE_MATERNITY } from '../model'; +import { ISectionProps, HIRE_TYPE_OPTIONS, HIRE_TYPE_REPLACEMENT, HIRE_TYPE_TRANSFER, HIRE_TYPE_MATERNITY } from '../model'; import { SelectionRequestField } from './SelectionRequestField'; import { CompanySharesField } from './CompanySharesField'; /** Подпись поля «Взамен кого» — уточняем, чьё ФИО вписывать, по типу приёма. */ const REPLACED_EMPLOYEE_LABELS: Record = { [HIRE_TYPE_REPLACEMENT]: 'Взамен кого (Ф.И.О. уволенного, полностью, пример: Иванов Иван Иванович)', + [HIRE_TYPE_TRANSFER]: 'Взамен кого (Ф.И.О. переведенного сотрудника, полностью, пример: Иванов Иван Иванович)', [HIRE_TYPE_MATERNITY]: 'Взамен кого (Ф.И.О. сотрудника в декрете, полностью, пример: Иванова Анна Ивановна)' }; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/validation.test.ts b/src/webparts/requestPortal/forms/RecruitingHire/validation.test.ts new file mode 100644 index 0000000..7e8c31c --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingHire/validation.test.ts @@ -0,0 +1,44 @@ +import { EMPTY_DATA, IFormData } from './model'; +import { draftIssues, submitIssues } from './validation'; + +const DRAFT_READY: IFormData = { + ...EMPTY_DATA, + HireCandidateName: 'Иванов Иван Иванович', + Position: 'Инженер', + ProjectId: '42' +}; + +const labels = (issues: { label: string }[]): string[] => issues.map((i) => i.label); + +describe('RecruitingHire validation', () => { + it('пустая форма: черновику не хватает проекта, ФИО и должности', () => { + expect(labels(draftIssues(EMPTY_DATA))).toEqual(['1.3 Проект', '2.1 Ф.И.О. кандидата', '2.3 Должность']); + }); + + it('черновик готов, но для отправки остаются поля — в порядке формы', () => { + expect(draftIssues(DRAFT_READY)).toEqual([]); + expect(labels(submitIssues(DRAFT_READY))).toEqual([ + '1.2 Организация', + '1.4 Телефон', + '1.5 Тип приёма', + '2.2 Дата приёма', + '4.1 Испытательный срок', + '4.2 Оклад при испытательном сроке', + '4.3 Оклад по завершении ИС', + '4.4 График работы' + ]); + }); + + it('ключ поля совпадает с htmlFor у FormField — по нему подсветка и прокрутка', () => { + expect(submitIssues(DRAFT_READY)[0]).toEqual({ field: 'Companies', label: '1.2 Организация', message: undefined }); + expect(draftIssues(EMPTY_DATA)[0]).toEqual({ + field: 'Project', + label: '1.3 Проект', + message: 'выберите вариант из списка' + }); + }); + + it('«По приезду на объект» снимает обязательность даты приёма', () => { + expect(labels(submitIssues({ ...DRAFT_READY, HireOnArrival: true }))).not.toContain('2.2 Дата приёма'); + }); +}); diff --git a/src/webparts/requestPortal/forms/RecruitingHire/validation.ts b/src/webparts/requestPortal/forms/RecruitingHire/validation.ts index 177c801..0b54f51 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/validation.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/validation.ts @@ -1,29 +1,66 @@ +import type { IFieldIssue } from '../../shared/ui'; import { IFormData } from './model'; +interface IRequiredRule extends IFieldIssue { + ok: (d: IFormData) => boolean; + /** true — без этого поля не сохранить даже черновик. */ + draft?: boolean; +} + /** - * Поля формы, обязательные для ОТПРАВКИ на согласование (черновик по-прежнему - * можно сохранить не заполнив их). Исключения по требованию заказчика: 2.4 - * «Отдел/Участок» (Department), 2.5 «Местоположение» (SiteLocation), 3.1–3.3 - * СИЗ (PpeHeight/PpeClothingSize/PpeShoeSize). 1.1 «Заявка на подбор» - * оставлена опциональной — приём иногда - * идёт через рекрутинговое агентство (см. AgencySection), без внутреннего - * подбора. Toggle-поле 5.1 «Рекрутинговое агентство» в проверку не входит — у - * булевых нет пустого состояния. + * Обязательные поля в порядке формы. field — ключ FormField (htmlFor/fieldKey): + * по нему поле подсвечивается и к нему прокручивается форма. Большинство полей + * нужны только для ОТПРАВКИ на согласование; draft — нужны уже черновику. + * + * Исключения по требованию заказчика: 2.4 «Отдел/Участок» (Department), 2.5 + * «Местоположение» (SiteLocation), 3.1–3.3 СИЗ. 1.1 «Заявка на подбор» + * опциональна — приём иногда идёт через рекрутинговое агентство (см. + * AgencySection), без внутреннего подбора. Toggle 5.1 «Рекрутинговое + * агентство» не проверяется — у булевых нет пустого состояния. * 2.2 «Дата приёма» не нужна, если отмечено «По приезду на объект» * (HireOnArrival) — тогда дату проставляет исполнитель на своём шаге. + * + * 1.3 «Проект» проверяется по ProjectId, а не по тексту: маршрут резолвится + * ПО ПРОЕКТУ (ByProject), и без выбранного из списка варианта approval-route + * вернёт 404 — заявка ушла бы на согласование с пустой матрицей. */ -const REQUIRED_FIELDS: ReadonlyArray<{ label: string; ok: (d: IFormData) => boolean }> = [ - { label: '1.2 организация', ok: (d) => d.Companies.length > 0 }, - { label: '1.4 телефон', ok: (d) => !!d.Phone.trim() }, - { label: '1.5 тип приёма', ok: (d) => !!d.HireType }, - { label: '2.2 дата приёма', ok: (d) => !!d.HireHireDate || d.HireOnArrival }, - { label: '4.1 испытательный срок', ok: (d) => !!d.ProbationPeriod }, - { label: '4.2 оклад при испытательном сроке', ok: (d) => !!d.SalaryProbation.trim() }, - { label: '4.3 оклад по завершении ИС', ok: (d) => !!d.SalaryAfterProbation.trim() }, - { label: '4.4 график работы', ok: (d) => !!d.WorkSchedule } +const REQUIRED_RULES: ReadonlyArray = [ + { field: 'Companies', label: '1.2 Организация', ok: (d) => d.Companies.length > 0 }, + { + field: 'MainCompanyId', + label: '1.2 Основное место работы', + message: 'выбрано несколько организаций — укажите основную', + ok: (d) => d.Companies.length < 2 || !!d.MainCompanyId, + draft: true + }, + { + field: 'Project', + label: '1.3 Проект', + message: 'выберите вариант из списка', + ok: (d) => !!d.ProjectId, + draft: true + }, + { field: 'Phone', label: '1.4 Телефон', ok: (d) => !!d.Phone.trim() }, + { field: 'HireType', label: '1.5 Тип приёма', ok: (d) => !!d.HireType }, + { field: 'HireCandidateName', label: '2.1 Ф.И.О. кандидата', ok: (d) => !!d.HireCandidateName.trim(), draft: true }, + { field: 'HireHireDate', label: '2.2 Дата приёма', ok: (d) => !!d.HireHireDate || d.HireOnArrival }, + { field: 'Position', label: '2.3 Должность', ok: (d) => !!d.Position.trim(), draft: true }, + { field: 'ProbationPeriod', label: '4.1 Испытательный срок', ok: (d) => !!d.ProbationPeriod }, + { field: 'SalaryProbation', label: '4.2 Оклад при испытательном сроке', ok: (d) => !!d.SalaryProbation.trim() }, + { field: 'SalaryAfterProbation', label: '4.3 Оклад по завершении ИС', ok: (d) => !!d.SalaryAfterProbation.trim() }, + { field: 'WorkSchedule', label: '4.4 График работы', ok: (d) => !!d.WorkSchedule } ]; -/** Подписи незаполненных обязательных-для-отправки полей (пусто — всё заполнено). */ -export function missingRequiredFields(data: IFormData): string[] { - return REQUIRED_FIELDS.filter((f) => !f.ok(data)).map((f) => f.label); +function toIssue({ field, label, message }: IRequiredRule): IFieldIssue { + return { field, label, message }; +} + +/** Что мешает сохранить черновик (пусто — сохранить можно). */ +export function draftIssues(data: IFormData): IFieldIssue[] { + return REQUIRED_RULES.filter((r) => r.draft && !r.ok(data)).map(toIssue); +} + +/** Всё, что мешает отправке на согласование, в порядке формы (пусто — отправлять можно). */ +export function submitIssues(data: IFormData): IFieldIssue[] { + return REQUIRED_RULES.filter((r) => !r.ok(data)).map(toIssue); } diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index 971b07e..11efda5 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -8,6 +8,8 @@ import { FormModeProvider, FieldChangesProvider, FieldChangesBanner, + FieldIssuesProvider, + issueLabels, FileDropzone, AttachmentList, CollapseSection, @@ -54,7 +56,7 @@ import { dataListTitleForListId } from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA, SetField, todayIso } from './model'; -import { submitBlocker } from './validation'; +import { draftIssues, submitBlocker, submitIssues } from './validation'; import { saveSelectionRequest, selectionApprovalService } from './submit'; import { loadSelectionRequest } from './load'; import { ApplicationSection } from './sections/ApplicationSection'; @@ -63,6 +65,7 @@ import { VacancySection } from './sections/VacancySection'; import { HealthTripsSection } from './sections/HealthTripsSection'; import { ConditionsSection } from './sections/ConditionsSection'; import { useStableCallback } from '../../shared/useStableCallback'; +import { submitDateOnOpen } from '../../shared/submitDate'; const RecruitingSelectionForm: React.FC = ({ requestType, @@ -104,7 +107,7 @@ const RecruitingSelectionForm: React.FC = ({ Promise.all([loadSelectionRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)]) .then(([loaded, atts]) => { if (alive) { - setData(loaded.data); + setData({ ...loaded.data, SubmitDate: submitDateOnOpen(loaded.status, loaded.data.SubmitDate) }); setApproverChanges(loaded.approverChanges); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); @@ -240,33 +243,18 @@ const RecruitingSelectionForm: React.FC = ({ resolveApproverOrgInfo(context) ); - // ObjectProjectId обязателен (а не просто текст ObjectProject): маршрут - // согласования резолвится ПО ПРОЕКТУ (ByProject, см. projectId выше), и без - // выбранного из списка варианта (id) approval-route вернёт 404 — форма - // должна не пускать на отправку раньше, чем выяснится, что маршрут вообще - // некому строить (баг: раньше это не проверялось, и заявка молча уходила - // на согласование с пустой матрицей). - const isValid = !!( - data.Responsible && - data.DepartmentName.trim() && - data.Position.trim() && - data.Residence && - data.ObjectProjectId - ); + // Черновику нужны лишь несколько полей, отправке — все обязательные (см. validation.ts). + const draftMissing = draftIssues(data); + const submitProblems = submitIssues(data); + const isValid = draftMissing.length === 0; + const isSubmitValid = submitProblems.length === 0; const validate = (): string | undefined => { - if (!isValid) { - return 'Заполните обязательные поля: ответственный за подачу, название отдела, должность, проживание, объект/проект (выберите вариант из списка).'; - } + if (!isValid) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; return undefined; }; - // Остальные поля формы обязательны только для ОТПРАВКИ на согласование — - // черновик по-прежнему можно сохранить не заполнив их (см. validation.ts). - const blocker = submitBlocker(data); - const isSubmitValid = isValid && !blocker; - const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => rows.map((r) => ({ email: r.email, @@ -345,6 +333,7 @@ const RecruitingSelectionForm: React.FC = ({ const validateSubmit = (): string | undefined => { const formError = validate(); if (formError) return formError; + const blocker = submitBlocker(data); if (blocker) return blocker; if (matrix.error) { return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; @@ -388,7 +377,7 @@ const RecruitingSelectionForm: React.FC = ({ }); return ( -
+
+ ); }; diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts index 0faf9bf..526420c 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts @@ -3,6 +3,7 @@ import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList'; import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/fieldChanges'; import { buildRequestTitle } from '../../shared/requestTitle'; +import { submitDateOnSave } from '../../shared/submitDate'; import { FORM_ID, IFormData } from './model'; import { APPROVER_CHANGES_FIELD } from './changeTracking'; import { cleanVacancyReasons, legacyDetailsText } from './vacancyReasons'; @@ -146,6 +147,7 @@ export async function saveSelectionRequest( ): Promise { const item = buildScalarBody(data); item.RequestStatus = status; + item.SubmitDate = submitDateOnSave(status, data.SubmitDate) || null; await applyPeople(item, data); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/validation.ts b/src/webparts/requestPortal/forms/RecruitingSelection/validation.ts index e43d2e6..312af64 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/validation.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/validation.ts @@ -1,54 +1,107 @@ +import type { IFieldIssue } from '../../shared/ui'; import { IFormData } from './model'; import { parseVacanciesCount, vacancyReasonsError } from './vacancyReasons'; +interface IRequiredRule extends IFieldIssue { + ok: (d: IFormData) => boolean; + /** true — без этого поля не сохранить даже черновик. */ + draft?: boolean; +} + /** - * Поля формы, обязательные для ОТПРАВКИ на согласование (черновик по-прежнему - * можно сохранить не заполнив их). Исключения по требованию заказчика: 3.8 - * «Необходимые знания в программах» (Programs), 3.9 «Иностранные языки» - * (Languages), 3.11 «Водительские права, стаж вождения» (DrivingLicense). - * Toggle-поля (BusinessTrips, PieceworkPay, MaterialResponsibility, - * ReferralProgram) и условные «другое»-подполя в проверку не входят — у - * булевых нет пустого состояния, а «другое» раскрывается только при выборе - * соответствующего варианта в уже обязательном родительском поле. + * Обязательные поля в порядке формы. field — ключ FormField (htmlFor/fieldKey): + * по нему поле подсвечивается и к нему прокручивается форма. Большинство полей + * нужны только для ОТПРАВКИ на согласование; draft — нужны уже черновику. + * + * Исключения по требованию заказчика: 3.8 «Необходимые знания в программах» + * (Programs), 3.9 «Иностранные языки» (Languages), 3.11 «Водительские права, + * стаж вождения» (DrivingLicense). Toggle-поля (BusinessTrips, PieceworkPay, + * MaterialResponsibility, ReferralProgram) и условные «другое»-подполя в + * проверку не входят — у булевых нет пустого состояния, а «другое» + * раскрывается только при выборе соответствующего варианта в уже обязательном + * родительском поле. * 5.10 «Предполагаемый наставник» (ProposedMentor) сделан необязательным * по просьбе Тимура (2026-09-24) — не всегда есть кого назначить заранее. * 5.1 «Испытательный срок» добавлен 2026-09-24 — нумерация раздела 5 сдвинута на 1. + * + * 1.6 «Объект/Проект» проверяется по ObjectProjectId, а не по тексту: маршрут + * резолвится ПО ПРОЕКТУ (ByProject), и без выбранного из списка варианта + * approval-route вернёт 404 — заявка ушла бы на согласование с пустой матрицей. */ -const REQUIRED_FIELDS: ReadonlyArray<{ label: string; ok: (d: IFormData) => boolean }> = [ - { label: '1.3 дата подачи заявки', ok: (d) => !!d.SubmitDate }, - { label: '1.4 крайний срок закрытия', ok: (d) => !!d.ClosingDeadline }, - { label: '1.5 организация', ok: (d) => d.Companies.length > 0 }, - { label: '2.1 подразделение', ok: (d) => !!d.Department.trim() }, - { label: '2.2 количество открытых вакансий', ok: (d) => !!d.OpenVacanciesCount.trim() }, - { label: '2.3 причина открытия вакансии', ok: (d) => d.VacancyReasons.length > 0 }, - { label: '3.2 характер занятости', ok: (d) => !!d.EmploymentType }, - { label: '3.3 функционал', ok: (d) => !!d.JobFunction.trim() }, - { label: '3.4 компетенции', ok: (d) => !!d.Competencies.trim() }, - { label: '3.5 личностные характеристики', ok: (d) => !!d.PersonalTraits.trim() }, - { label: '3.6 образование', ok: (d) => !!d.Education }, - { label: '3.7 опыт работы', ok: (d) => !!d.WorkExperience }, - { label: '5.1 испытательный срок', ok: (d) => !!d.SelectionProbationPeriod }, - { label: '5.2 ставка на ИС', ok: (d) => !!d.RateProbation.trim() }, - { label: '5.3 ставка после ИС', ok: (d) => !!d.RateAfterProbation.trim() }, - { label: '5.5 график работы', ok: (d) => !!d.WorkSchedule }, - { label: '5.6 время работы', ok: (d) => !!d.WorkHours }, - { label: '5.8 обеспечение рабочего места', ok: (d) => d.WorkplaceProvision.length > 0 }, - { label: '5.11 непосредственный руководитель', ok: (d) => !!d.DirectManager }, - { label: '5.12 кто принимает решение о приёме', ok: (d) => d.HiringDecisionMaker.length > 0 } +const REQUIRED_RULES: ReadonlyArray = [ + { field: 'Responsible', label: '1.1 Ответственный за подачу заявки', ok: (d) => !!d.Responsible, draft: true }, + { + field: 'DepartmentName', + label: '1.2 Подразделение ответственного за подачу', + ok: (d) => !!d.DepartmentName.trim(), + draft: true + }, + { field: 'SubmitDate', label: '1.3 Дата подачи заявки', ok: (d) => !!d.SubmitDate }, + { field: 'ClosingDeadline', label: '1.4 Крайний срок закрытия', ok: (d) => !!d.ClosingDeadline }, + { field: 'Companies', label: '1.5 Организация', ok: (d) => d.Companies.length > 0 }, + { + field: 'ObjectProject', + label: '1.6 Объект/Проект', + message: 'выберите вариант из списка', + ok: (d) => !!d.ObjectProjectId, + draft: true + }, + { field: 'Department', label: '2.1 Подразделение', ok: (d) => !!d.Department.trim() }, + { field: 'OpenVacanciesCount', label: '2.2 Количество открытых вакансий', ok: (d) => !!d.OpenVacanciesCount.trim() }, + { field: 'VacancyReasons', label: '2.3 Причина открытия вакансии', ok: (d) => d.VacancyReasons.length > 0 }, + { field: 'Position', label: '3.1 Должность', ok: (d) => !!d.Position.trim(), draft: true }, + { field: 'EmploymentType', label: '3.2 Характер занятости', ok: (d) => !!d.EmploymentType }, + { field: 'JobFunction', label: '3.3 Функционал', ok: (d) => !!d.JobFunction.trim() }, + { field: 'Competencies', label: '3.4 Компетенции', ok: (d) => !!d.Competencies.trim() }, + { field: 'PersonalTraits', label: '3.5 Личностные характеристики', ok: (d) => !!d.PersonalTraits.trim() }, + { field: 'Education', label: '3.6 Образование', ok: (d) => !!d.Education }, + { field: 'WorkExperience', label: '3.7 Опыт работы', ok: (d) => !!d.WorkExperience }, + { field: 'Residence', label: '3.10 Проживание', ok: (d) => !!d.Residence, draft: true }, + { field: 'SelectionProbationPeriod', label: '5.1 Испытательный срок', ok: (d) => !!d.SelectionProbationPeriod }, + { field: 'RateProbation', label: '5.2 Ставка на ИС', ok: (d) => !!d.RateProbation.trim() }, + { field: 'RateAfterProbation', label: '5.3 Ставка после ИС', ok: (d) => !!d.RateAfterProbation.trim() }, + { field: 'WorkSchedule', label: '5.5 График работы', ok: (d) => !!d.WorkSchedule }, + { field: 'WorkHours', label: '5.6 Время работы', ok: (d) => !!d.WorkHours }, + { field: 'WorkplaceProvision', label: '5.8 Обеспечение рабочего места', ok: (d) => d.WorkplaceProvision.length > 0 }, + { field: 'DirectManager', label: '5.11 Непосредственный руководитель', ok: (d) => !!d.DirectManager }, + { field: 'HiringDecisionMaker', label: '5.12 Кто принимает решение о приёме', ok: (d) => d.HiringDecisionMaker.length > 0 } ]; -/** Подписи незаполненных обязательных-для-отправки полей (пусто — всё заполнено). */ -export function missingRequiredFields(data: IFormData): string[] { - return REQUIRED_FIELDS.filter((f) => !f.ok(data)).map((f) => f.label); +function missingFields(data: IFormData, draftOnly: boolean): IFieldIssue[] { + return REQUIRED_RULES.filter((r) => (!draftOnly || r.draft) && !r.ok(data)).map(({ field, label, message }) => ({ + field, + label, + message + })); +} + +/** Несогласованность причин (2.3) с количеством вакансий (2.2) — заполнено, но неверно. */ +function vacancyReasonsIssue(data: IFormData): IFieldIssue | undefined { + const error = vacancyReasonsError(data.VacancyReasons, parseVacanciesCount(data.OpenVacanciesCount)); + if (!error) return undefined; + // Текст ошибки — готовое предложение с заглавной; в списке после тире нужна строчная. + const message = error.charAt(0).toLowerCase() + error.slice(1); + return { field: 'VacancyReasons', label: '2.3 Причина открытия вакансии', message }; +} + +/** Что мешает сохранить черновик (пусто — сохранить можно). */ +export function draftIssues(data: IFormData): IFieldIssue[] { + return missingFields(data, true); } /** - * Всё, что мешает отправке/согласованию, одной строкой: сначала незаполненные - * обязательные поля, затем согласованность причин с количеством вакансий. - * undefined — отправлять можно. + * Всё, что мешает отправке на согласование: незаполненные поля в порядке формы, + * затем несогласованность причин с количеством вакансий (пусто — отправлять можно). */ +export function submitIssues(data: IFormData): IFieldIssue[] { + const reasonsIssue = vacancyReasonsIssue(data); + const missing = missingFields(data, false); + return reasonsIssue ? [...missing, reasonsIssue] : missing; +} + +/** Причина, по которой нельзя отправить/согласовать, одной строкой (undefined — можно). */ export function submitBlocker(data: IFormData): string | undefined { - const missing = missingRequiredFields(data); - if (missing.length > 0) return `Заполните обязательные поля: ${missing.join(', ')}.`; + const missing = missingFields(data, false); + if (missing.length > 0) return `Заполните обязательные поля: ${missing.map((m) => m.label).join(', ')}.`; return vacancyReasonsError(data.VacancyReasons, parseVacanciesCount(data.OpenVacanciesCount)); } diff --git a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx index 286a7cc..531d410 100644 --- a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx +++ b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx @@ -60,6 +60,7 @@ import { VacancySection } from './sections/VacancySection'; import { CandidateInfoSection } from './sections/CandidateInfoSection'; import { AssessmentSection } from './sections/AssessmentSection'; import { useStableCallback } from '../../shared/useStableCallback'; +import { submitDateOnOpen } from '../../shared/submitDate'; const ReferralForm: React.FC = ({ requestType, @@ -112,7 +113,7 @@ const ReferralForm: React.FC = ({ Promise.all([loadReferralRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)]) .then(([loaded, atts]) => { if (alive) { - setData(loaded.data); + setData({ ...loaded.data, SubmitDate: submitDateOnOpen(loaded.status, loaded.data.SubmitDate) }); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); if (loaded.code) notifyCode(loaded.code); @@ -142,7 +143,7 @@ const ReferralForm: React.FC = ({ setData((prev) => ({ ...prev, Referrer: prev.Referrer || user.email, - SubmitDate: prev.SubmitDate || todayIso() + SubmitDate: todayIso() })); approval .getProfileFields(user.loginName) diff --git a/src/webparts/requestPortal/forms/Referral/sections/ReferrerSection.tsx b/src/webparts/requestPortal/forms/Referral/sections/ReferrerSection.tsx index 76715ad..7fd3ee9 100644 --- a/src/webparts/requestPortal/forms/Referral/sections/ReferrerSection.tsx +++ b/src/webparts/requestPortal/forms/Referral/sections/ReferrerSection.tsx @@ -5,8 +5,9 @@ import { ISectionProps, REFERRER_PLACEHOLDER } from '../model'; /** Секция 1: кто рекомендует — дата подачи, сам рекомендатель и его контакты. */ export const ReferrerSection: React.FC = ({ data, set, context, onReferrerChange, printOrgIndex }) => ( + {/* Дата подачи — всегда сегодняшняя (ставит форма, при отправке — заново), руками не меняется. */} - set('SubmitDate', v)} /> + set('SubmitDate', v)} readOnly /> = ({ loadSkudExplanationRequest(editId, editListId) .then((loaded) => { if (!alive) return; - // Черновик/доработку подают заново — дата подачи становится сегодняшней; - // у отправленной заявки остаётся та, с которой её подали. - const submitDate = isEditableStatus(loaded.status) ? todayDateKey() : loaded.data.SubmitDate; - setData({ ...loaded.data, SubmitDate: submitDate }); + setData({ ...loaded.data, SubmitDate: submitDateOnOpen(loaded.status, loaded.data.SubmitDate) }); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); if (loaded.code) notifyCode(loaded.code); diff --git a/src/webparts/requestPortal/forms/SkudExplanation/submit.ts b/src/webparts/requestPortal/forms/SkudExplanation/submit.ts index d442630..0d41187 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/submit.ts +++ b/src/webparts/requestPortal/forms/SkudExplanation/submit.ts @@ -1,6 +1,7 @@ import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; import { ISaveItemResult, saveRequestItem } from '../../shared/dataList'; import { ensureEmployee } from '../../shared/employee'; +import { submitDateOnSave } from '../../shared/submitDate'; import { FORM_ID, IFormData, OTHER_REASON } from './model'; import { formatMissedDaysSummary, formatShortDate } from './noteText'; import { todayDateKey } from './skud/skudDays'; @@ -19,7 +20,7 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee return { Title: buildTitle(employeeName), RequestStatus: status, - SubmitDate: data.SubmitDate || null, + SubmitDate: submitDateOnSave(status, data.SubmitDate) || null, EmployeeId: employeeId, SkudEmployeeFio: data.EmployeeFio || null, Position: data.Position || null, diff --git a/src/webparts/requestPortal/forms/Ticket1C/README.md b/src/webparts/requestPortal/forms/Ticket1C/README.md index 6033f63..548b44c 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/README.md +++ b/src/webparts/requestPortal/forms/Ticket1C/README.md @@ -6,9 +6,11 @@ **RequestTypeId (внешний API маршрутов):** 10 ## Маршрут -- Фазы: только согласование. Отдельной фазы исполнения нет — после того как - согласование пройдено (или если для вида работ оно не требуется), заявка сразу - уходит не исполнителю из матрицы, а в Microsoft Planner (см. «Особое поведение»). +- Фазы: согласование + исполнение. Шаги согласования условные (вид работ/отдел), + шаг исполнения — сотрудник отдела 1С (в маршруте №8 без условия). После + согласования заявка «Выполнение» у исполнителя; он отмечает «Выполнено» → «Закрыта». + Если для вида работ согласование не требуется — заявка сразу «Выполнение». + Работу отдел 1С ведёт в карточке Microsoft Planner (см. «Особое поведение»). - Особенности: маршрут условный — набор шагов зависит от полей **«Виды работ»** (`WorkType`) и **«Отдел»** (`Department`, свободный текст из профиля AAD, см. «Особое поведение»), которые передаются в `resolveRoute`/`getPlannedMatrix` через @@ -40,13 +42,16 @@ телефон, отдел, должность — единственная форма портала, которая это делает (`services/profile.service.ts`, только для новой заявки, при ошибке Graph — просто остаётся пусто, без ошибки формы). -- **Planner вместо фазы исполнения.** Когда маршрут согласования полностью пройден - (или изначально пуст) — `Ticket1CForm`/`Ticket1CView` создают карточку в Microsoft - Planner через `services/plannerTicket.service.ts` (`api.tnsu.kz/Planner/CreateTask`): - рабочий бакет — при согласовании, отдельный бакет «Отклонённые» — при отклонении - (с указанием, кто отклонил и по какой причине). Id созданной карточки сохраняется в - колонку `PlannerTaskId` — это защита от повторного создания карточки, если решение - применяется из нескольких вкладок/сессий одновременно. +- **Planner вместе с фазой исполнения** (`plannerHandoff.ts`). Когда заявка доходит + до исполнения («Выполнение» — после согласования в `Ticket1CView` или сразу при + отправке в `Ticket1CForm`, если согласование не требуется), создаётся карточка в + Microsoft Planner через `services/plannerTicket.service.ts` + (`api.tnsu.kz/Planner/CreateTask`) — рабочий бакет; при отклонении — бакет + «Отклонённые» (кто отклонил и по какой причине). Маршрут без шага исполнения + («Согласовано») тоже уходит в рабочий бакет. Отметку «Выполнено» исполнитель + ставит в портале — Planner с порталом не синхронизируется. Id созданной карточки + сохраняется в колонку `PlannerTaskId` — это защита от повторного создания карточки, + если решение применяется из нескольких вкладок/сессий одновременно. - Ошибку резолва маршрута (например, если в админке маршрутов ещё не настроен маршрут для этого вида работ) форма не путает с «согласование не требуется» — отправка блокируется с понятным сообщением, а не молча уходит в Planner в обход согласования. diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx index e394b6a..da6e9c3 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -17,7 +17,6 @@ import { useRequestSubmit } from '../../shared/ui'; import { - composeRemoveRowRule, ApprovalMatrix, ApprovalHistory, StatusBadge, @@ -30,11 +29,7 @@ import { isEditableStatus, IApproverToWrite, unassignedStepMessage, - approvalPhaseRows, - executorsToWrite, - useFormExecutors, - ExecutorsMatrix, - initialSubmitStatus + APPROVAL_PHASE } from '../../shared/approval'; import { uploadAttachments, @@ -60,6 +55,8 @@ import { RequestSection } from './sections/RequestSection'; import { ContactSection } from './sections/ContactSection'; import { useStableCallback } from '../../shared/useStableCallback'; import { applyManagerOverride, managerEmail } from './managerOverride'; +import { handoffToPlanner } from './plannerHandoff'; +import { submitStatus } from './submitStatus'; const Ticket1CForm: React.FC = ({ requestType, @@ -174,38 +171,39 @@ const Ticket1CForm: React.FC = ({ // а не выбирается заявителем. Поэтому матрица здесь ВСЕГДА нередактируема // (editable=false) — заявитель только видит, кто согласует, без возможности // добавить/убрать/переставить согласующего. + // Условия шагов (и согласования, и исполнения) — по виду работ и отделу. + const fieldValues = useMemo( + () => [ + { fieldName: 'WorkType', value: data.WorkType }, + { fieldName: 'Department', value: data.Department } + ], + [data.WorkType, data.Department] + ); + const matrixKey = `${userEmail}-${editId ?? 'new'}-${data.WorkType}-${data.Department}`; + const matrixReady = !!editId || (!!data.WorkType && !!requestTypeId); const loadMatrix = useCallback( () => editId - ? approval.getMatrixForRequest(editId).then(approvalPhaseRows) - : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, [ - { fieldName: 'WorkType', value: data.WorkType }, - { fieldName: 'Department', value: data.Department } - ]), - [context, userEmail, approval, editId, data.WorkType, data.Department, requestTypeId] + ? approval + .getMatrixForRequest(editId) + .then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)) + : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, fieldValues), + [context, userEmail, approval, editId, fieldValues, requestTypeId] ); - const matrix = useMatrixEditor( - loadMatrix, - `${userEmail}-${editId ?? 'new'}-${data.WorkType}-${data.Department}`, - false, - !!editId || (!!data.WorkType && !!requestTypeId), - (login) => approval.getJobTitle(login) + const matrix = useMatrixEditor(loadMatrix, matrixKey, false, matrixReady, (login) => approval.getJobTitle(login)); + + // Исполнение — сотрудник отдела 1С (шаг «Исполнение» маршрута): работу ведёт + // в карточке Planner, в портале отмечает «Выполнено» (см. plannerHandoff.ts). + const loadExecutors = useCallback( + () => + editId + ? approval + .getMatrixForRequest(editId) + .then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)) + : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution', null, fieldValues), + [context, userEmail, approval, editId, fieldValues, requestTypeId] ); - // Исполнители (фаза «Исполнение») резолвятся с теми же fieldValues, что и - // согласующие: условные шаги маршрута зависят от вида работ и отдела. - const executors = useFormExecutors({ - readOnly, - context, - approval, - requestTypeId, - userEmail, - editId, - departmentId: null, - fieldValues: [ - { fieldName: 'WorkType', value: data.WorkType }, - { fieldName: 'Department', value: data.Department } - ] - }); + const executors = useMatrixEditor(loadExecutors, matrixKey, false, matrixReady, (login) => approval.getJobTitle(login)); // Должность нового «Руководителя» — approval-route её не знает (резолвил шаг // на ДРУГОГО человека и отдал его должность), поэтому подтягиваем сами по // e-mail из поля (SPS-JobTitle, тот же источник, что и у обычных согласующих, @@ -293,18 +291,7 @@ const Ticket1CForm: React.FC = ({ if (!rtId) { throw new Error('Тип заявки не определён — обновите страницу и повторите.'); } - // Маршрут запрашивается для ЛЮБОГО вида работ и используется как есть — - // без авто-approve, если админ ещё не настроил шаги для конкретного вида - // работ (раньше в этом случае заявка сразу становилась «Согласовано» в - // обход согласования; теперь это просто «На согласовании» с пустой - // матрицей — сигнал администратору маршрутов донастроить условие). - // Есть исполнители — статус отправки по initialSubmitStatus (без согласующих - // сразу «Выполнение»); иначе, как раньше, «На согласовании» (в т.ч. при пустом маршруте). - const submitStatus = - executors.rows.length > 0 - ? initialSubmitStatus(matrixRows.length, executors.rows.length) - : REQUEST_STATUS.onApproval; - const finalStatus = intendedStatus === REQUEST_STATUS.draft ? REQUEST_STATUS.draft : submitStatus; + const finalStatus = submitStatus(intendedStatus, matrixRows.length, executors.rows.length); // requestId уже задан → редактируем существующую заявку, пишем в ту же // партицию (editListId). Иначе — создаём новую, и партицию для записи @@ -319,7 +306,7 @@ const Ticket1CForm: React.FC = ({ requestId, data, toApprovers(matrixRows), - executorsToWrite(executors.rows), + toApprovers(executors.rows), finalStatus, writeListId ); @@ -339,10 +326,18 @@ const Ticket1CForm: React.FC = ({ onSaved(); } - // Передача в Planner теперь происходит только после реального решения - // согласующего (см. Ticket1CView.handoffToPlanner) — здесь заявка всегда - // отправляется на согласование, минуя Planner напрямую. - if (finalStatus === REQUEST_STATUS.onApproval || finalStatus === REQUEST_STATUS.execution) { + // Согласование для этого вида работ не нужно — заявка сразу у исполнителя, + // и карточка Planner нужна уже сейчас (иначе её создаёт решение согласующего + // в Ticket1CView). Сбой Planner не отменяет уже отправленную заявку. + if (finalStatus === REQUEST_STATUS.execution) { + try { + await handoffToPlanner({ context, approval, itemId: id, listId: writeListId, status: finalStatus, data, code: code || requestCode || '' }); + } catch (e) { + setSubmitError(`Заявка отправлена, но не удалось создать карточку в Planner: ${friendlyErrorMessage(e)}`); + } + } + + if (finalStatus !== REQUEST_STATUS.draft) { // Имена списков — той же партиции, куда фактически записали (writeListId), // не обязательно текущей на момент рендера формы: поток ищет строку // маршрута именно в НЕЙ. @@ -372,12 +367,13 @@ const Ticket1CForm: React.FC = ({ (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.` ); if (unassignedError) return unassignedError; - const unassignedExecutorError = unassignedStepMessage( + if (executors.error) { + return `Не удалось определить исполнителя: ${executors.error}. Попробуйте ещё раз или обратитесь к администратору.`; + } + return unassignedStepMessage( executors.rows, - (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.` + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.` ); - if (unassignedExecutorError) return unassignedExecutorError; - return undefined; }; const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); @@ -448,7 +444,6 @@ const Ticket1CForm: React.FC = ({ approval.searchUsers(q)} // Пустой маршрут — штатная ситуация: для части видов работ согласование // не нужно, заявка сразу уходит в Planner. Сбой загрузки маршрута @@ -456,7 +451,15 @@ const Ticket1CForm: React.FC = ({ emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С." /> - approval.searchUsers(q)} /> + {!executors.loading && executors.rows.length > 0 && ( + approval.searchUsers(q)} + title="Исполнение" + roleLabel="Исполнитель" + emptyText="Исполнители не назначены." + /> + )} {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx index 710b68b..d215a91 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx @@ -25,10 +25,7 @@ import { notifyActiveStep, useRequestTypeId, isEditableStatus, - REQUEST_STATUS, - approvalPhaseRows, - useViewExecutors, - ExecutorsMatrix + APPROVAL_PHASE } from '../../shared/approval'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -38,11 +35,10 @@ import { dataListTitle, dataListTitleForListId } from '../../shared/listTitles'; -import { FORM_ID, IFormData, EMPTY_DATA, SetField, hasOtherDatabase } from './model'; +import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { loadTicketRequest } from './load'; -import { ticket1cApprovalService, getPlannerTaskId, setPlannerTaskId } from './submit'; -import { PlannerTicketService } from './services/plannerTicket.service'; -import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME, PLANNER_REJECTED_BUCKET_NAME } from './config'; +import { ticket1cApprovalService } from './submit'; +import { handoffToPlanner, isPlannerHandoffStatus } from './plannerHandoff'; import { RequestSection } from './sections/RequestSection'; import { ContactSection } from './sections/ContactSection'; import { useStableCallback } from '../../shared/useStableCallback'; @@ -58,35 +54,6 @@ interface ITicket1CViewProps { onCodeLoaded?: (code: string) => void; } -/** Текст карточки Planner из данных заявки; rejection — автор и причина отклонения, если есть. */ -function buildPlannerNotes( - data: IFormData, - code: string, - rejection?: { who: string; comment: string } -): string { - const lines: string[] = []; - if (code) lines.push(`Код заявки: ${code}`); - lines.push(`Организация: ${data.Organization}`); - lines.push(`ФИО: ${data.FIO}`); - lines.push(`Email: ${data.Email}`); - lines.push(`Телефон: ${data.Phone}`); - lines.push(`Отдел: ${data.Department}`); - lines.push(`Должность: ${data.Position}`); - lines.push(`База 1С: ${data.DB1C.join(', ')}`); - if (hasOtherDatabase(data)) lines.push(`Другая база 1С: ${data.DB1COther}`); - lines.push(`Виды работ: ${data.WorkType}`); - lines.push(''); - lines.push('Вопрос:'); - lines.push(data.Question); - if (rejection) { - lines.push(''); - lines.push('ОТКЛОНЕНО'); - lines.push(`Кто отклонил: ${rejection.who}`); - lines.push(`Причина: ${rejection.comment}`); - } - return lines.join('\n'); -} - // Заявка в отдел 1С только для просмотра (режим согласующего). const Ticket1CView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка @@ -115,11 +82,24 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, // decisionSeq входит в reloadKey: после решения строки маршрута меняются на // сервере, поэтому счётчик инкрементируется и useMatrixEditor перечитывает матрицу. const [decisionSeq, setDecisionSeq] = useState(0); - const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]); + const loadApprovers = useCallback( + () => + approval.getMatrixForRequest(itemId).then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)), + [approval, itemId] + ); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); - const executors = useViewExecutors(context, approval, itemId, decisionSeq); + // Исполнение — отдел 1С (шаг «Исполнение» маршрута): отмечает «Выполнено» здесь, + // а саму работу ведёт в карточке Planner (см. plannerHandoff.ts). + const loadExecutors = useCallback( + () => + approval.getMatrixForRequest(itemId).then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)), + [approval, itemId] + ); + const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) => + approval.getJobTitle(l) + ); useEffect(() => { let alive = true; @@ -147,41 +127,6 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, }; }, [itemId, listId, notifyCode]); - // Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита - // от повторного создания карточки, если решение применяется из нескольких - // вкладок/сессий одновременно (см. submit.ts). - const handoffToPlanner = useCallback( - async (finalStatus: string, currentData: IFormData, currentCode: string): Promise => { - const existingTaskId = await getPlannerTaskId(itemId, listId); - if (existingTaskId) { - return; - } - - let rejection: { who: string; comment: string } | undefined; - if (finalStatus === REQUEST_STATUS.rejected) { - const rows = await approval.getMatrixForRequest(itemId); - const rejectedRow = rows.filter((r) => r.status === REQUEST_STATUS.rejected)[0]; - if (rejectedRow) { - rejection = { who: rejectedRow.who, comment: rejectedRow.comment }; - } - } - - const bucketName = - finalStatus === REQUEST_STATUS.rejected ? PLANNER_REJECTED_BUCKET_NAME : PLANNER_BUCKET_NAME; - const atts = await getAttachments(listId || dataListTitle(FORM_ID), itemId); - const taskId = await new PlannerTicketService(context).createTask({ - planId: PLANNER_PLAN_ID, - bucketName, - title: currentData.TaskName, - notes: buildPlannerNotes(currentData, currentCode, rejection), - attachments: atts, - webAbsoluteUrl: context.pageContext.web.absoluteUrl - }); - await setPlannerTaskId(itemId, taskId, listId); - }, - [approval, context, itemId, listId] - ); - if (loading) { return
Загрузка заявки…
; } @@ -251,7 +196,15 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, emptyText="Согласование не требовалось для этого вида работ." /> - approval.searchUsers(q)} /> + {!executors.loading && executors.rows.length > 0 && ( + approval.searchUsers(q)} + title="Исполнение" + roleLabel="Исполнитель" + emptyText="Исполнители не назначены." + /> + )}
{/* История согласования: маршрут пересохраняется патчем (см. @@ -271,8 +224,10 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, // Очередь сдвинулась — уведомляем следующий активный шаг. notify(); - if (newStatus === REQUEST_STATUS.approved || newStatus === REQUEST_STATUS.rejected) { - handoffToPlanner(newStatus, data, code).catch((e) => + // Дошла до исполнения / завершилась / отклонена — карточка в Planner + // (повторно не создаётся, см. plannerHandoff.ts). + if (isPlannerHandoffStatus(newStatus)) { + handoffToPlanner({ context, approval, itemId, listId, status: newStatus, data, code }).catch((e) => setError(`Не удалось передать заявку в Planner: ${(e as Error).message}`) ); } diff --git a/src/webparts/requestPortal/forms/Ticket1C/plannerHandoff.ts b/src/webparts/requestPortal/forms/Ticket1C/plannerHandoff.ts new file mode 100644 index 0000000..a4238e5 --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/plannerHandoff.ts @@ -0,0 +1,91 @@ +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import { ApprovalListService, REQUEST_STATUS } from '../../shared/approval'; +import { getAttachments } from '../../shared/attachments/attachments.service'; +import { dataListTitle } from '../../shared/listTitles'; +import { FORM_ID, IFormData, hasOtherDatabase } from './model'; +import { getPlannerTaskId, setPlannerTaskId } from './submit'; +import { PlannerTicketService } from './services/plannerTicket.service'; +import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME, PLANNER_REJECTED_BUCKET_NAME } from './config'; + +/** + * Статусы, при которых заявка уходит в Planner: согласование пройдено и + * началось исполнение («Выполнение»), маршрут без исполнения завершён + * («Согласовано») или заявку отклонили (бакет «Отклонённые»). + */ +const HANDOFF_STATUSES: ReadonlyArray = [ + REQUEST_STATUS.execution, + REQUEST_STATUS.approved, + REQUEST_STATUS.rejected +]; + +export function isPlannerHandoffStatus(status: string): boolean { + return HANDOFF_STATUSES.indexOf(status) !== -1; +} + +/** Текст карточки Planner из данных заявки; rejection — автор и причина отклонения, если есть. */ +function buildPlannerNotes(data: IFormData, code: string, rejection?: { who: string; comment: string }): string { + const lines: string[] = []; + if (code) lines.push(`Код заявки: ${code}`); + lines.push(`Организация: ${data.Organization}`); + lines.push(`ФИО: ${data.FIO}`); + lines.push(`Email: ${data.Email}`); + lines.push(`Телефон: ${data.Phone}`); + lines.push(`Отдел: ${data.Department}`); + lines.push(`Должность: ${data.Position}`); + lines.push(`База 1С: ${data.DB1C.join(', ')}`); + if (hasOtherDatabase(data)) lines.push(`Другая база 1С: ${data.DB1COther}`); + lines.push(`Виды работ: ${data.WorkType}`); + lines.push(''); + lines.push('Вопрос:'); + lines.push(data.Question); + if (rejection) { + lines.push(''); + lines.push('ОТКЛОНЕНО'); + lines.push(`Кто отклонил: ${rejection.who}`); + lines.push(`Причина: ${rejection.comment}`); + } + return lines.join('\n'); +} + +export interface IPlannerHandoffParams { + context: WebPartContext; + approval: ApprovalListService; + itemId: number; + listId?: string; + status: string; + data: IFormData; + code: string; +} + +/** + * Создать карточку Planner для заявки. Отдел 1С ведёт работу в Planner, а в + * портале исполнитель отмечает «Выполнено» — поэтому карточка создаётся, когда + * заявка доходит до исполнения, а не вместо него. PlannerTaskId — защита от + * повторного создания, если решение применяется из нескольких вкладок/сессий. + */ +export async function handoffToPlanner({ context, approval, itemId, listId, status, data, code }: IPlannerHandoffParams): Promise { + if (!isPlannerHandoffStatus(status)) return; + const existingTaskId = await getPlannerTaskId(itemId, listId); + if (existingTaskId) return; + + let rejection: { who: string; comment: string } | undefined; + if (status === REQUEST_STATUS.rejected) { + const rows = await approval.getMatrixForRequest(itemId); + const rejectedRow = rows.filter((r) => r.status === REQUEST_STATUS.rejected)[0]; + if (rejectedRow) { + rejection = { who: rejectedRow.who, comment: rejectedRow.comment }; + } + } + + const bucketName = status === REQUEST_STATUS.rejected ? PLANNER_REJECTED_BUCKET_NAME : PLANNER_BUCKET_NAME; + const attachments = await getAttachments(listId || dataListTitle(FORM_ID), itemId); + const taskId = await new PlannerTicketService(context).createTask({ + planId: PLANNER_PLAN_ID, + bucketName, + title: data.TaskName, + notes: buildPlannerNotes(data, code, rejection), + attachments, + webAbsoluteUrl: context.pageContext.web.absoluteUrl + }); + await setPlannerTaskId(itemId, taskId, listId); +} diff --git a/src/webparts/requestPortal/forms/Ticket1C/submit.ts b/src/webparts/requestPortal/forms/Ticket1C/submit.ts index 7b145cf..52a15c5 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/submit.ts +++ b/src/webparts/requestPortal/forms/Ticket1C/submit.ts @@ -32,10 +32,12 @@ function buildItem(data: IFormData, status: string): Record { export type ISaveResult = ISaveItemResult; /** - * Сохранить заявку в отдел 1С: содержимое + маршрут (согласующие и исполнители). + * Сохранить заявку в отдел 1С: содержимое + маршрут (согласование и исполнение). * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. - * executors — исполнители из маршрута админки (фаза «Исполнение»), могут быть пустыми: - * тогда после согласования заявка уходит в Planner. Возвращает Id заявки (+ код при создании). + * Исполнение — сотрудник отдела 1С из маршрута: работу ведёт в карточке Planner + * (создаётся при переходе в «Выполнение», см. plannerHandoff.ts), а в портале + * отмечает «Выполнено». executors может быть пустым — тогда после согласования + * заявка станет «Согласовано». Возвращает Id заявки (+ код при создании). * * listId — партиция, в которую фактически пишем: при редактировании — партиция * ОБНОВЛЯЕМОЙ заявки (см. loadTicketRequest), при создании — партиция, diff --git a/src/webparts/requestPortal/forms/Ticket1C/submitStatus.test.ts b/src/webparts/requestPortal/forms/Ticket1C/submitStatus.test.ts new file mode 100644 index 0000000..a6b9953 --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/submitStatus.test.ts @@ -0,0 +1,20 @@ +import { REQUEST_STATUS } from '../../shared/approval/models/statuses'; +import { submitStatus } from './submitStatus'; + +describe('Ticket1C submitStatus', () => { + it('черновик остаётся черновиком', () => { + expect(submitStatus(REQUEST_STATUS.draft, 2, 1)).toBe(REQUEST_STATUS.draft); + }); + + it('есть согласующие — на согласование', () => { + expect(submitStatus(REQUEST_STATUS.onApproval, 2, 1)).toBe(REQUEST_STATUS.onApproval); + }); + + it('согласование не нужно, исполнитель есть — сразу выполнение', () => { + expect(submitStatus(REQUEST_STATUS.onApproval, 0, 1)).toBe(REQUEST_STATUS.execution); + }); + + it('маршрут пуст совсем — на согласование, не «Согласовано» в обход', () => { + expect(submitStatus(REQUEST_STATUS.onApproval, 0, 0)).toBe(REQUEST_STATUS.onApproval); + }); +}); diff --git a/src/webparts/requestPortal/forms/Ticket1C/submitStatus.ts b/src/webparts/requestPortal/forms/Ticket1C/submitStatus.ts new file mode 100644 index 0000000..657202f --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/submitStatus.ts @@ -0,0 +1,16 @@ +import { REQUEST_STATUS } from '../../shared/approval/models/statuses'; + +/** + * Статус заявки 1С при сохранении. + * - Черновик — черновик. + * - Есть согласующие — «На согласовании». + * - Согласующих нет (условия маршрута для этого вида работ не сработали), но есть + * исполнитель — сразу «Выполнение»: заявка у отдела 1С. + * - Пусто совсем — «На согласовании» с пустым маршрутом: не «Согласовано» в обход + * согласования, а сигнал администратору маршрутов донастроить условия. + */ +export function submitStatus(intendedStatus: string, approversCount: number, executorsCount: number): string { + if (intendedStatus === REQUEST_STATUS.draft) return REQUEST_STATUS.draft; + if (approversCount === 0 && executorsCount > 0) return REQUEST_STATUS.execution; + return REQUEST_STATUS.onApproval; +} diff --git a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts index edf5210..82a1224 100644 --- a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts +++ b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts @@ -241,8 +241,8 @@ export class ApproverService { // 2) согласование завершено: нет исполнителей → «Согласовано» (финал); // исполнители есть и не все выполнили → «Выполнение»; // все выполнили → «Закрыта». - // Строка «выполнена» исполнителем помечается тем же статусом «Согласовано» - // (действие «Выполнено» в DecisionPanel), фазы различаются колонкой Phase. + // Строка исполнителя после действия «Выполнено» (DecisionPanel) получает + // статус «Выполнено»; фазы различаются колонкой Phase. private computeRequestStatus(rows: IApprovalRow[]): string { const execRows = rows.filter((r) => r.phase === APPROVAL_PHASE.execution); const approvalRows = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution); diff --git a/src/webparts/requestPortal/shared/config.ts b/src/webparts/requestPortal/shared/config.ts index 5bab3d7..2910841 100644 --- a/src/webparts/requestPortal/shared/config.ts +++ b/src/webparts/requestPortal/shared/config.ts @@ -15,6 +15,13 @@ export const DATA_SITE_URL = 'https://tnsukz.sharepoint.com/sites/requests'; export const USER_GUIDE_URL = 'https://tnsukz.sharepoint.com/:w:/r/sites/knowledge/DocLib8/%D0%98%D0%BD%D1%81%D1%82%D1%80%D1%83%D0%BA%D1%86%D0%B8%D0%B8%20%D0%BA%20IT%20%D1%81%D0%B8%D1%81%D1%82%D0%B5%D0%BC%D0%B0%D0%BC/%D0%98%D0%BD%D1%81%D1%82%D1%80%D1%83%D0%BA%D1%86%D0%B8%D1%8F%20-%20%D0%9F%D0%BE%D1%80%D1%82%D0%B0%D0%BB%20%D0%B7%D0%B0%D1%8F%D0%B2%D0%BE%D0%BA.docx?d=wce77e8a0d19d49b5b2fcdf13828e8bb3&csf=1&web=1&e=p9V3hU'; +/** + * Страница портала в SharePoint — ссылка «Открыть портал в браузере» в + * объявлении для Teams (см. components/TeamsBrowserNotice). Внутри Teams + * портал свой адрес не знает (вкладка Teams — служебная страница), поэтому задан явно. + */ +export const PORTAL_PAGE_URL = `${DATA_SITE_URL}/SitePages/${encodeURIComponent('Портал-Заявок')}.aspx`; + /** * Внешний approval-route API (маршруты согласования по ролям). * Авторизация — AadHttpClient (SSO текущего пользователя) по clientId diff --git a/src/webparts/requestPortal/shared/hostEnvironment.ts b/src/webparts/requestPortal/shared/hostEnvironment.ts new file mode 100644 index 0000000..98e7ea7 --- /dev/null +++ b/src/webparts/requestPortal/shared/hostEnvironment.ts @@ -0,0 +1,23 @@ +/** Признак настольного/мобильного Teams в User-Agent (классический и новый клиент). */ +const TEAMS_USER_AGENT = /\bTeams\//i; + +/** Страница открыта не на верхнем уровне окна, а внутри фрейма (вкладка Teams). */ +function isInsideFrame(): boolean { + try { + return window.self !== window.top; + } catch { + // Доступ к window.top из чужого домена бросает исключение — это тоже фрейм. + return true; + } +} + +/** + * Открыт ли портал внутри Teams. Teams-контекста SPFx (sdks.microsoftTeams) + * недостаточно: он есть только у приложения/вкладки SPFx. Если в Teams + * закреплена обычная страница SharePoint (вкладка «Сайт»/«SharePoint»), веб-часть + * думает, что она в браузере, — поэтому смотрим ещё на фрейм и User-Agent. + * В браузере страница портала открыта на верхнем уровне и без «Teams/» в UA. + */ +export function isOpenedInTeams(hasTeamsContext: boolean): boolean { + return hasTeamsContext || isInsideFrame() || TEAMS_USER_AGENT.test(navigator.userAgent); +} diff --git a/src/webparts/requestPortal/shared/submitDate.test.ts b/src/webparts/requestPortal/shared/submitDate.test.ts new file mode 100644 index 0000000..a546741 --- /dev/null +++ b/src/webparts/requestPortal/shared/submitDate.test.ts @@ -0,0 +1,21 @@ +import { REQUEST_STATUS } from './approval/models/statuses'; +import { submitDateOnOpen, submitDateOnSave, todaySubmitDate } from './submitDate'; + +const OLD_DATE = '2026-09-01'; + +describe('submitDate', () => { + it('отправка на согласование — сегодня, даже если черновик сохранён раньше', () => { + expect(submitDateOnSave(REQUEST_STATUS.onApproval, OLD_DATE)).toBe(todaySubmitDate()); + expect(submitDateOnSave(REQUEST_STATUS.queued, OLD_DATE)).toBe(todaySubmitDate()); + }); + + it('черновик хранит дату с экрана', () => { + expect(submitDateOnSave(REQUEST_STATUS.draft, OLD_DATE)).toBe(OLD_DATE); + }); + + it('открыли черновик или доработку — сегодня, отправленную — как подали', () => { + expect(submitDateOnOpen(REQUEST_STATUS.draft, OLD_DATE)).toBe(todaySubmitDate()); + expect(submitDateOnOpen(REQUEST_STATUS.returned, OLD_DATE)).toBe(todaySubmitDate()); + expect(submitDateOnOpen(REQUEST_STATUS.onApproval, OLD_DATE)).toBe(OLD_DATE); + }); +}); diff --git a/src/webparts/requestPortal/shared/submitDate.ts b/src/webparts/requestPortal/shared/submitDate.ts new file mode 100644 index 0000000..d870a3e --- /dev/null +++ b/src/webparts/requestPortal/shared/submitDate.ts @@ -0,0 +1,30 @@ +import { REQUEST_STATUS, isEditableStatus } from './approval/models/statuses'; + +function pad2(n: number): string { + return n < 10 ? `0${n}` : `${n}`; +} + +/** Сегодняшняя дата по местному времени, YYYY-MM-DD (формат input type=date). */ +export function todaySubmitDate(): string { + const now = new Date(); + return `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`; +} + +/** + * «Дата подачи» для формы, открытой на редактирование. Черновик и доработку + * подают заново — на экране сегодняшняя дата, а не дата первого сохранения; + * у уже отправленной заявки остаётся та, с которой её подали. + */ +export function submitDateOnOpen(status: string, savedDate: string): string { + return isEditableStatus(status) ? todaySubmitDate() : savedDate; +} + +/** + * «Дата подачи» для записи в SharePoint из формы подачи. Любое сохранение кроме + * черновика — это отправка («На согласовании» или «Ждёт очереди», см. + * initialSubmitStatus): дата всегда сегодняшняя — форму могли открыть вчера, а + * черновик сохранить неделю назад. Черновик хранит то, что на экране. + */ +export function submitDateOnSave(status: string, currentDate: string): string { + return status === REQUEST_STATUS.draft ? currentDate : todaySubmitDate(); +} diff --git a/src/webparts/requestPortal/shared/ui/FieldIssues/FieldIssuesContext.tsx b/src/webparts/requestPortal/shared/ui/FieldIssues/FieldIssuesContext.tsx new file mode 100644 index 0000000..b8e22bb --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/FieldIssues/FieldIssuesContext.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { useContext, useState } from 'react'; +import { IFieldIssue, issueFieldText, scrollToField } from './fieldIssues'; + +/** Какую кнопку нажимали: от этого зависит, какие поля подсвечивать. */ +export type RevealMode = 'draft' | 'submit'; + +interface IFieldIssuesContext { + draftIssues: IFieldIssue[]; + submitIssues: IFieldIssue[]; + /** Подсвечиваемые сейчас поля: ключ поля → текст под ним. */ + visible: Map; + /** Показать проблемы под полями и прокрутить к полю (по умолчанию — к первому). */ + reveal: (mode: RevealMode, field?: string) => void; +} + +const FieldIssuesContext = React.createContext(undefined); + +interface IFieldIssuesProviderProps { + /** Что мешает сохранить черновик (обычно подмножество submitIssues). */ + draftIssues: IFieldIssue[]; + /** Что мешает отправке на согласование. */ + submitIssues: IFieldIssue[]; + children: React.ReactNode; +} + +function toVisibleMap(issues: IFieldIssue[]): Map { + const map = new Map(); + issues.forEach((issue) => { + // Первое сообщение по полю важнее: правила идут в порядке формы. + if (!map.has(issue.field)) map.set(issue.field, issueFieldText(issue)); + }); + return map; +} + +/** + * Подсветка незаполненных/неверных полей формы. До первого нажатия кнопки + * поля не краснеют (пустая форма не должна пугать); после — подсвечиваются + * и гаснут сами, как только поле исправлено: списки пересчитываются из данных. + */ +export const FieldIssuesProvider: React.FC = ({ draftIssues, submitIssues, children }) => { + const [mode, setMode] = useState(undefined); + + const reveal = (nextMode: RevealMode, field?: string): void => { + // Раз показали проблемы отправки — не сужаем обратно до черновика. + setMode((prev) => (prev === 'submit' ? prev : nextMode)); + const issues = nextMode === 'draft' ? draftIssues : submitIssues; + const target = field || (issues.length > 0 ? issues[0].field : undefined); + if (target) scrollToField(target); + }; + + let shown: IFieldIssue[] = []; + if (mode === 'submit') shown = submitIssues; + if (mode === 'draft') shown = draftIssues; + + const value: IFieldIssuesContext = { draftIssues, submitIssues, visible: toVisibleMap(shown), reveal }; + return {children}; +}; + +/** Управление подсветкой для кнопок формы; undefined — форма без FieldIssuesProvider. */ +export function useFieldIssuesControl(): IFieldIssuesContext | undefined { + return useContext(FieldIssuesContext); +} + +/** Текст проблемы под полем, если его сейчас надо подсветить. */ +export function useFieldIssue(field: string | undefined): string | undefined { + const context = useContext(FieldIssuesContext); + if (!context || !field) return undefined; + return context.visible.get(field); +} diff --git a/src/webparts/requestPortal/shared/ui/FieldIssues/fieldIssues.ts b/src/webparts/requestPortal/shared/ui/FieldIssues/fieldIssues.ts new file mode 100644 index 0000000..a7d3200 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/FieldIssues/fieldIssues.ts @@ -0,0 +1,53 @@ +/** Проблема с полем формы, из-за которой заявку нельзя сохранить/отправить. */ +export interface IFieldIssue { + /** Ключ поля — тот же, что fieldKey/htmlFor у FormField: по нему подсветка и прокрутка. */ + field: string; + /** Подпись в списке над кнопками — как у поля на форме, с номером. */ + label: string; + /** Уточнение со строчной буквы («выберите вариант из списка»). Не задано — «заполните поле». */ + message?: string; +} + +const DEFAULT_ISSUE_MESSAGE = 'заполните поле'; + +/** Атрибут-метка обёртки поля в FormField — по нему ищем поле для прокрутки. */ +export const FIELD_ANCHOR_ATTR = 'data-rp-field'; +/** Атрибут-метка списка проблем над кнопками — запасная цель прокрутки. */ +export const ISSUES_ANCHOR_ATTR = 'data-rp-issues'; + +const FOCUSABLE = 'input:not([type="hidden"]):not([disabled]), select, textarea, button:not([disabled])'; + +/** Строка для списка над кнопками: «1.3 Проект — выберите вариант из списка». */ +export function issueListText(issue: IFieldIssue): string { + return issue.message ? `${issue.label} — ${issue.message}` : issue.label; +} + +/** Текст под полем: «Выберите вариант из списка» / «Заполните поле». */ +export function issueFieldText(issue: IFieldIssue): string { + const text = issue.message || DEFAULT_ISSUE_MESSAGE; + return text.charAt(0).toUpperCase() + text.slice(1); +} + +/** Подписи проблем одной строкой — для текста ошибки: «1.4 Телефон, 1.5 Тип приёма». */ +export function issueLabels(issues: IFieldIssue[]): string { + return issues.map((i) => i.label).join(', '); +} + +/** + * Прокрутить к полю и поставить в него курсор. Поле может отсутствовать в DOM — + * CollapseSection из ui-kit убирает тело свёрнутой секции целиком; тогда + * прокручиваем к списку проблем над кнопками, там поле названо. + */ +export function scrollToField(field: string): void { + const anchor = + document.querySelector(`[${FIELD_ANCHOR_ATTR}="${field}"]`) || + document.querySelector(`[${ISSUES_ANCHOR_ATTR}]`); + if (!anchor) return; + + anchor.scrollIntoView({ behavior: 'smooth', block: 'center' }); + const focusable = anchor.querySelector(FOCUSABLE); + if (focusable) { + // preventScroll: иначе фокус мгновенно дёрнет страницу и собьёт плавную прокрутку. + focusable.focus({ preventScroll: true }); + } +} diff --git a/src/webparts/requestPortal/shared/ui/FormField/FormField.module.scss b/src/webparts/requestPortal/shared/ui/FormField/FormField.module.scss index cd913eb..c846279 100644 --- a/src/webparts/requestPortal/shared/ui/FormField/FormField.module.scss +++ b/src/webparts/requestPortal/shared/ui/FormField/FormField.module.scss @@ -20,6 +20,22 @@ font-weight: 600; } +// Незаполненное/неверное поле после нажатия «Отправить» (см. FieldIssuesProvider). +// Полоса слева, а не рамка контрола: у пикеров, мультивыборов и переключателей +// своей рамки нет или она во Fluent — полоса одинаково видна у любого поля. +.invalid { + padding-left: $sp-2; + border-left: 3px solid $red-500; + border-radius: $r-sm; +} + +.issue { + margin-top: $sp-1; + font-size: $fs-caption; + color: $red-500; + overflow-wrap: anywhere; +} + // На печати подсветка не нужна — бумажная форма показывает итоговые значения. @media print { .changed { diff --git a/src/webparts/requestPortal/shared/ui/FormField/FormField.tsx b/src/webparts/requestPortal/shared/ui/FormField/FormField.tsx index 892dde9..3a5059c 100644 --- a/src/webparts/requestPortal/shared/ui/FormField/FormField.tsx +++ b/src/webparts/requestPortal/shared/ui/FormField/FormField.tsx @@ -3,6 +3,8 @@ import { FormField as KitFormField } from '@tnsu/ui-kit'; import styles from './FormField.module.scss'; import { useFieldChange } from '../FieldChanges/FieldChangesContext'; import { formatDateTime } from '../FieldChanges/formatDateTime'; +import { useFieldIssue } from '../FieldIssues/FieldIssuesContext'; +import { FIELD_ANCHOR_ATTR } from '../FieldIssues/fieldIssues'; interface IFormFieldProps { label: string; @@ -11,30 +13,43 @@ interface IFormFieldProps { /** Занять обе колонки сетки. */ full?: boolean; /** - * Ключ поля в журнале правок (internalName колонки). По умолчанию — htmlFor; - * задаётся явно у полей без htmlFor (люди, мультивыбор, переключатели). + * Ключ поля в журнале правок (internalName колонки) и в списке проблем формы + * (FieldIssuesProvider). По умолчанию — htmlFor; задаётся явно у полей без + * htmlFor (люди, мультивыбор, переключатели). */ fieldKey?: string; children: React.ReactNode; } /** - * FormField из ui-kit + подсветка правки согласующего: если поле есть в - * журнале (см. FieldChangesProvider), контрол подсвечивается, а под ним — - * прежнее значение. Без журнала ведёт себя ровно как FormField из ui-kit. + * FormField из ui-kit + две подсветки: + * - правка согласующего (см. FieldChangesProvider): контрол выделен, под ним — прежнее значение; + * - проблема поля (см. FieldIssuesProvider): красная полоса и текст под контролом. + * + * Обёртка с меткой поля есть всегда, а не только при ошибке: иначе при появлении + * и исчезновении ошибки React пересоздавал бы контрол и сбрасывал фокус посреди ввода. */ export const FormField: React.FC = ({ fieldKey, children, ...fieldProps }) => { - const change = useFieldChange(fieldKey ?? fieldProps.htmlFor); - if (!change) { - return {children}; - } + const key = fieldKey ?? fieldProps.htmlFor; + const change = useFieldChange(key); + const issue = useFieldIssue(key); + const anchorProps = key ? { [FIELD_ANCHOR_ATTR]: key } : {}; return ( -
{children}
-
- Было: {change.before} +
+ {change ?
{children}
: children}
+ {change && ( +
+ Было: {change.before} +
+ )} + {issue && ( +
+ {issue} +
+ )} ); }; diff --git a/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx b/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx index 484537a..c08975a 100644 --- a/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx +++ b/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import { Button, Icon, FormActions, Alert } from '@tnsu/ui-kit'; import { IRequestSubmit } from '../useRequestSubmit'; import { isCurrentUserTester } from './testerGroupAccess'; +import { SubmitIssues } from './SubmitIssues'; +import { useFieldIssuesControl } from '../FieldIssues/FieldIssuesContext'; /** * ВРЕМЕННАЯ ЗАМОРОЗКА (включена 2026-09-21 по просьбе Тимура Городецкого): @@ -65,6 +67,11 @@ export interface ISubmitActionsProps { // Логика доступности — здесь, в одном месте, чтобы все формы вели себя одинаково: // • сохранять/отправлять нельзя, пока идёт сохранение или заявка уже read-only; // • «Отмена» доступна всегда (возврат к списку), кроме момента сохранения. +// +// Форма внутри FieldIssuesProvider (см. shared/ui/FieldIssues) ведёт себя иначе: +// кнопки сохранения не гаснут из-за незаполненных полей — нажатие при проблемах +// ничего не сохраняет, а подсвечивает поля и прокручивает к первому. Над кнопками +// список проблем со ссылками на поля. Формы без провайдера — как раньше, по isValid. export const SubmitActions: React.FC = ({ submit, isValid, @@ -98,11 +105,33 @@ export const SubmitActions: React.FC = ({ const frozen = SUBMISSION_FROZEN && !unfrozenForm && !isTester; const locked = saving || readOnly || frozen; - const canSubmit = submitValid ?? isValid; + const issuesControl = useFieldIssuesControl(); + // С провайдером кнопки не гаснут по полям: проблемы показываются по нажатию. + const canSaveDraft = issuesControl ? true : isValid; + const canSubmit = issuesControl ? true : submitValid ?? isValid; + + const handleSaveDraft = (): void => { + if (issuesControl && issuesControl.draftIssues.length > 0) { + issuesControl.reveal('draft'); + return; + } + onSaveDraft(); + }; + const handleSubmit = (): void => { + if (issuesControl && issuesControl.submitIssues.length > 0) { + issuesControl.reveal('submit'); + return; + } + onSubmit(); + }; return ( <> {frozen && !readOnly && Подача заявок временно недоступна.} + {/* При заморозке кнопки неактивны не из-за полей — список только сбил бы с толку. */} + {issuesControl && !frozen && !readOnly && ( + issuesControl.reveal('submit', field)} /> + )} {onDelete && ( - - diff --git a/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitIssues.module.scss b/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitIssues.module.scss new file mode 100644 index 0000000..4736e13 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitIssues.module.scss @@ -0,0 +1,24 @@ +@import '../../../../../styles/design-tokens'; + +.title { + font-weight: 600; +} + +.list { + margin: $sp-1 0 0; + padding-left: $sp-5; +} + +// Пункт списка — ссылка на поле: выглядит как текст с подчёркиванием, без рамки кнопки. +.link { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: left; + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + overflow-wrap: anywhere; +} diff --git a/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitIssues.tsx b/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitIssues.tsx new file mode 100644 index 0000000..9f725f9 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitIssues.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { Alert } from '@tnsu/ui-kit'; +import styles from './SubmitIssues.module.scss'; +import { IFieldIssue, ISSUES_ANCHOR_ATTR, issueListText } from '../FieldIssues/fieldIssues'; + +interface ISubmitIssuesProps { + /** Что мешает отправке — в порядке формы. */ + issues: IFieldIssue[]; + /** Клик по пункту — прокрутка к полю. */ + onSelect: (field: string) => void; +} + +const TITLE = 'Чтобы отправить на согласование, заполните или исправьте:'; + +/** + * Список причин, по которым заявку пока нельзя отправить. Пункт кликабелен — + * ведёт к полю: на длинной форме иначе пришлось бы искать его самому. + */ +export const SubmitIssues: React.FC = ({ issues, onSelect }) => { + if (issues.length === 0) return null; + + const anchorProps = { [ISSUES_ANCHOR_ATTR]: '' }; + return ( +
+ +
{TITLE}
+
    + {issues.map((issue) => ( +
  • + +
  • + ))} +
+
+
+ ); +}; diff --git a/src/webparts/requestPortal/shared/ui/index.ts b/src/webparts/requestPortal/shared/ui/index.ts index 8069eb2..eda43eb 100644 --- a/src/webparts/requestPortal/shared/ui/index.ts +++ b/src/webparts/requestPortal/shared/ui/index.ts @@ -53,6 +53,9 @@ export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead'; export { LinkField } from './LinkField/LinkField'; export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay'; export { SubmitActions } from './SubmitActions/SubmitActions'; +export { FieldIssuesProvider } from './FieldIssues/FieldIssuesContext'; +export type { IFieldIssue } from './FieldIssues/fieldIssues'; +export { issueLabels } from './FieldIssues/fieldIssues'; export { getDraftDeleteHandler } from './SubmitActions/getDraftDeleteHandler'; export { InitiatorLine } from './InitiatorLine/InitiatorLine'; export type { IInitiatorLineProps } from './InitiatorLine/InitiatorLine';