From 286b449099c9b2acac091d376ca4bd6920da539a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Thu, 24 Sep 2026 17:36:39 +0500 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D0=B4=D1=81=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=BB=D0=B5=D0=B9,=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D1=91=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D1=83=D1=8E=D1=89=D0=B8?= =?UTF-8?q?=D0=BC,=20=D1=81=20=D0=BF=D1=80=D0=B5=D0=B6=D0=BD=D0=B8=D0=BC?= =?UTF-8?q?=D0=B8=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=D0=BC?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecruitingHire/RecruitingHireForm.tsx | 23 ++- .../RecruitingHire/RecruitingHireView.tsx | 47 ++++-- .../forms/RecruitingHire/changeTracking.ts | 29 ++++ .../forms/RecruitingHire/load.ts | 12 +- .../forms/RecruitingHire/provisioning.json | 1 + .../RecruitingHire/sections/AgencySection.tsx | 2 +- .../sections/ApplicationSection.tsx | 2 +- .../sections/CompanySharesField.tsx | 2 +- .../forms/RecruitingHire/submit.ts | 15 +- .../RecruitingSelectionForm.tsx | 23 ++- .../RecruitingSelectionView.tsx | 46 ++++-- .../RecruitingSelection/changeTracking.ts | 14 ++ .../forms/RecruitingSelection/load.ts | 8 +- .../RecruitingSelection/provisioning.json | 1 + .../sections/ApplicationSection.tsx | 2 +- .../sections/ConditionsSection.tsx | 14 +- .../sections/HealthTripsSection.tsx | 2 +- .../sections/VacancySection.tsx | 4 +- .../forms/RecruitingSelection/submit.ts | 13 +- .../shared/fieldChanges/fieldChanges.test.ts | 91 +++++++++++ .../shared/fieldChanges/fieldChanges.ts | 143 ++++++++++++++++++ .../ui/FieldChanges/FieldChangesBanner.tsx | 40 +++++ .../ui/FieldChanges/FieldChangesContext.tsx | 24 +++ .../shared/ui/FieldChanges/formatDateTime.ts | 10 ++ .../shared/ui/FormField/FormField.module.scss | 30 ++++ .../shared/ui/FormField/FormField.tsx | 40 +++++ src/webparts/requestPortal/shared/ui/index.ts | 6 +- 27 files changed, 590 insertions(+), 54 deletions(-) create mode 100644 src/webparts/requestPortal/forms/RecruitingHire/changeTracking.ts create mode 100644 src/webparts/requestPortal/forms/RecruitingSelection/changeTracking.ts create mode 100644 src/webparts/requestPortal/shared/fieldChanges/fieldChanges.test.ts create mode 100644 src/webparts/requestPortal/shared/fieldChanges/fieldChanges.ts create mode 100644 src/webparts/requestPortal/shared/ui/FieldChanges/FieldChangesBanner.tsx create mode 100644 src/webparts/requestPortal/shared/ui/FieldChanges/FieldChangesContext.tsx create mode 100644 src/webparts/requestPortal/shared/ui/FieldChanges/formatDateTime.ts create mode 100644 src/webparts/requestPortal/shared/ui/FormField/FormField.module.scss create mode 100644 src/webparts/requestPortal/shared/ui/FormField/FormField.tsx diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index a7304c7..196852c 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -6,6 +6,8 @@ import { FormCard, InitiatorLine, FormModeProvider, + FieldChangesProvider, + FieldChangesBanner, FileDropzone, AttachmentList, CollapseSection, @@ -16,6 +18,7 @@ import { getDraftDeleteHandler, useRequestSubmit } from '../../shared/ui'; +import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges'; import { ApprovalMatrix, StatusBadge, @@ -87,6 +90,8 @@ const RecruitingHireForm: React.FC = ({ const [requestId, setRequestId] = useState(editId); const [requestStatus, setRequestStatus] = useState(undefined); const [requestCode, setRequestCode] = useState(undefined); + // Журнал правок согласующего — виден и здесь, когда заявку вернули на доработку. + const [approverChanges, setApproverChanges] = useState([]); const submit = useRequestSubmit(); // onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка // может меняться на каждом рендере, поэтому в зависимости эффектов идёт @@ -112,6 +117,7 @@ const RecruitingHireForm: React.FC = ({ .then(([loaded, atts]) => { if (alive) { setData(loaded.data); + setApproverChanges(loaded.approverChanges); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); if (loaded.code) notifyCode(loaded.code); @@ -405,13 +411,16 @@ const RecruitingHireForm: React.FC = ({ )} {submit.error && {submit.error}} - - - - - - - + + + + + + + + + + {!readOnly && ( diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index c334f84..158401c 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -2,7 +2,22 @@ import * as React from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; import styles from './RecruitingHireView.module.scss'; -import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, CollapseSection, FormField, AttachmentList, Icon, PrintButton } from '../../shared/ui'; +import { + Alert, + Button, + FormCard, + InitiatorLine, + FormModeProvider, + CollapseSection, + FormField, + AttachmentList, + Icon, + PrintButton, + FieldChangesProvider, + FieldChangesBanner +} from '../../shared/ui'; +import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges'; +import { HIRE_CHANGE_TRACKING } from './changeTracking'; import { ApproverService, DecisionPanel, @@ -91,6 +106,11 @@ const RecruitingHireView: React.FC = ({ context, itemI // Все поля заявки правит на своём активном шаге согласующий из // shared/approval/fullEditRights.ts (сейчас — Алтынай Бултурукова). const [fullEditable, setFullEditable] = useState(false); + // То, что сейчас лежит в заявке на сервере (до несохранённых правок), — с ним + // сравниваем при сохранении, чтобы записать в журнал только реальные правки. + const [savedData, setSavedData] = useState(EMPTY_DATA); + // Журнал правок согласующего с полным правом — подсветка полей и «Было: …». + const [approverChanges, setApproverChanges] = useState([]); const [saveState, setSaveState] = useState('idle'); const [saveErrorMessage, setSaveErrorMessage] = useState(''); // Право удалять согласующих из уже поданного маршрута — см. @@ -180,7 +200,11 @@ const RecruitingHireView: React.FC = ({ context, itemI // формы, загруженным при открытии экрана. const saveApproverFields = async (): Promise => { if (fullEditable) { - await updateAllHireFields(itemId, data, listId); + const editor = { name: context.pageContext.user.displayName, email: effectiveUserEmail(context) }; + const nextChanges = mergeFieldChanges(approverChanges, savedData, data, HIRE_CHANGE_TRACKING, editor, new Date()); + await updateAllHireFields(itemId, data, nextChanges, listId); + setApproverChanges(nextChanges); + setSavedData(data); return; } const fields: Partial = {}; @@ -247,6 +271,8 @@ const RecruitingHireView: React.FC = ({ context, itemI .then(([loaded, files]) => { if (alive) { setData(loaded.data); + setSavedData(loaded.data); + setApproverChanges(loaded.approverChanges); setStatus(loaded.status); setCode(loaded.code); notifyCode(loaded.code); @@ -334,14 +360,17 @@ const RecruitingHireView: React.FC = ({ context, itemI } headerRight={status ? : undefined} > + {/* Вложения ниже — вне этого блока: полное право открывает только поля. */} - - - - - - - + + + + + + + + +
diff --git a/src/webparts/requestPortal/forms/RecruitingHire/changeTracking.ts b/src/webparts/requestPortal/forms/RecruitingHire/changeTracking.ts new file mode 100644 index 0000000..90d0272 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingHire/changeTracking.ts @@ -0,0 +1,29 @@ +import { IChangeTrackingConfig, formatFieldValue, EMPTY_VALUE } from '../../shared/fieldChanges/fieldChanges'; +import { IFormData, ICompanyShare } from './model'; + +/** Колонка журнала правок согласующего (Note, JSON) — см. provisioning.json. */ +export const APPROVER_CHANGES_FIELD = 'HireApproverChangesJson'; + +function companyName(data: IFormData, companyId: string): string { + const company = data.Companies.find((c) => c.Id === companyId); + return company ? company.Name : companyId; +} + +/** + * Что пишем в журнал правок согласующего (shared/fieldChanges). + * + * Без своего поля на форме (не пишем): Id/код проекта — меняются вместе с + * «Проект»; связь с подбором — вместе с её кодом (1.1); HireOnArrival — флажок + * внутри поля 2.2, его правку видно по самой дате. + */ +export const HIRE_CHANGE_TRACKING: IChangeTrackingConfig = { + ignore: ['ProjectId', 'ProjectCode', 'SelectionRequestId', 'SelectionRequestListId', 'HireOnArrival'], + formatters: { + MainCompanyId: (value, data) => (value ? companyName(data, String(value)) : EMPTY_VALUE), + CompanyShares: (value, data) => { + const shares = (value as ICompanyShare[]) || []; + if (shares.length === 0) return EMPTY_VALUE; + return shares.map((s) => `${companyName(data, s.companyId)}: ${formatFieldValue(s.percent)}%`).join(', '); + } + } +}; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/load.ts b/src/webparts/requestPortal/forms/RecruitingHire/load.ts index de438c7..196f226 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/load.ts @@ -1,7 +1,9 @@ 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 { APPROVER_CHANGES_FIELD } from './changeTracking'; /** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */ function parseJsonArray(raw: unknown): T[] { @@ -30,6 +32,8 @@ export interface ILoadedRequest { status: string; /** Уникальный код заявки; '' — если запись создана до появления поля. */ code: string; + /** Журнал правок согласующего (подсветка полей) — см. shared/fieldChanges. */ + approverChanges: IFieldChange[]; } /** @@ -51,6 +55,7 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise< 'CompanySharesJson', 'RecruitingAgency', 'HireOnArrival', + APPROVER_CHANGES_FIELD, 'RequestStatus', 'RequestUniqueCode' )()) as Record; @@ -77,5 +82,10 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise< } }); - return { data, status: (item.RequestStatus as string) || '', code: (item.RequestUniqueCode as string) || '' }; + return { + data, + status: (item.RequestStatus as string) || '', + code: (item.RequestUniqueCode as string) || '', + approverChanges: parseFieldChanges(item[APPROVER_CHANGES_FIELD]) + }; } diff --git a/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json b/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json index d2e92e8..dcadc87 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json @@ -22,6 +22,7 @@ { "internalName": "HireCandidateName", "title": "Ф.И.О. кандидата", "type": "Text", "required": true }, { "internalName": "HireHireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true }, + { "internalName": "HireApproverChangesJson", "title": "Изменения согласующего — приём (JSON)", "type": "Note" }, { "internalName": "HireOnArrival", "title": "Дата приёма — по приезду на объект", "type": "Boolean" }, { "internalName": "Position", "title": "Должность", "type": "Text", "required": true }, { "internalName": "SiteName", "title": "Отдел / участок (устарело)", "type": "Text" }, diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/AgencySection.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/AgencySection.tsx index 0937b71..4b4d480 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/AgencySection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/AgencySection.tsx @@ -18,7 +18,7 @@ export const AgencySection: React.FC = ({ data, set }) => { return ( - + {data.RecruitingAgency && ( diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx index b6e8a2e..1d085a7 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/ApplicationSection.tsx @@ -39,7 +39,7 @@ export const ApplicationSection: React.FC = ({ data, set, context return ( - + diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/CompanySharesField.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/CompanySharesField.tsx index 8bd7453..e9e63e7 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/CompanySharesField.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/CompanySharesField.tsx @@ -46,7 +46,7 @@ export const CompanySharesField: React.FC = ({ placeholder="— выберите —" /> - +
{companies.map((c) => (
diff --git a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts index 3ab6dda..19814df 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts @@ -1,6 +1,8 @@ import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList'; +import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/fieldChanges'; import { FORM_ID, IFormData } from './model'; +import { APPROVER_CHANGES_FIELD } from './changeTracking'; // Числовые поля: пустая строка → null, иначе Number(). const NUMBER_FIELDS: (keyof IFormData)[] = ['PpeHeight', 'PpeShoeSize', 'SalaryProbation', 'SalaryAfterProbation', 'SelectionRequestId']; @@ -53,11 +55,18 @@ export type ISaveResult = ISaveItemResult; /** * Перезаписать ВСЕ поля заявки — для согласующего с полным правом правки - * (см. shared/approval/fullEditRights.ts). Статус, маршрут и вложения не + * (см. shared/approval/fullEditRights.ts) — вместе с журналом его правок + * (подсветка полей, см. shared/fieldChanges). Статус, маршрут и вложения не * трогаются: заявка остаётся на том же шаге согласования. */ -export async function updateAllHireFields(id: number, data: IFormData, listId?: string): Promise { - await resolveDataList(FORM_ID, listId).items.getById(id).update(buildFields(data)); +export async function updateAllHireFields( + id: number, + data: IFormData, + approverChanges: IFieldChange[], + listId?: string +): Promise { + const item = { ...buildFields(data), [APPROVER_CHANGES_FIELD]: serializeFieldChanges(approverChanges) }; + await resolveDataList(FORM_ID, listId).items.getById(id).update(item); } /** diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index 636009f..65407b1 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -6,6 +6,8 @@ import { FormCard, InitiatorLine, FormModeProvider, + FieldChangesProvider, + FieldChangesBanner, FileDropzone, AttachmentList, CollapseSection, @@ -16,6 +18,7 @@ import { getDraftDeleteHandler, useRequestSubmit } from '../../shared/ui'; +import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges'; import { ApprovalMatrix, StatusBadge, @@ -74,6 +77,8 @@ const RecruitingSelectionForm: React.FC = ({ const [requestId, setRequestId] = useState(editId); const [requestStatus, setRequestStatus] = useState(undefined); const [requestCode, setRequestCode] = useState(undefined); + // Журнал правок согласующего — виден и здесь, когда заявку вернули на доработку. + const [approverChanges, setApproverChanges] = useState([]); const submit = useRequestSubmit(); // onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка // может меняться на каждом рендере, поэтому в зависимости эффектов идёт @@ -98,6 +103,7 @@ const RecruitingSelectionForm: React.FC = ({ .then(([loaded, atts]) => { if (alive) { setData(loaded.data); + setApproverChanges(loaded.approverChanges); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); if (loaded.code) notifyCode(loaded.code); @@ -418,13 +424,16 @@ const RecruitingSelectionForm: React.FC = ({ )} {submit.error && {submit.error}} - - - - - - - + + + + + + + + + + {!readOnly && ( diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx index 0663d5a..c84c3d7 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx @@ -2,7 +2,21 @@ import * as React from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; import styles from './RecruitingSelectionView.module.scss'; -import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, CollapseSection, FormField, AttachmentList, PrintButton } from '../../shared/ui'; +import { + Alert, + Button, + FormCard, + InitiatorLine, + FormModeProvider, + CollapseSection, + FormField, + AttachmentList, + PrintButton, + FieldChangesProvider, + FieldChangesBanner +} from '../../shared/ui'; +import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges'; +import { SELECTION_CHANGE_TRACKING } from './changeTracking'; import { ApproverService, DecisionPanel, @@ -79,6 +93,11 @@ const RecruitingSelectionView: React.FC = ({ cont // Все поля заявки правит на своём АКТИВНОМ шаге согласующий из // shared/approval/fullEditRights.ts (сейчас — Алтынай Бултурукова). const [fullEditable, setFullEditable] = useState(false); + // То, что сейчас лежит в заявке на сервере (до несохранённых правок), — с ним + // сравниваем при сохранении, чтобы записать в журнал только реальные правки. + const [savedData, setSavedData] = useState(EMPTY_DATA); + // Журнал правок согласующего с полным правом — подсветка полей и «Было: …». + const [approverChanges, setApproverChanges] = useState([]); const [saveState, setSaveState] = useState('idle'); const [saveErrorMessage, setSaveErrorMessage] = useState(''); @@ -141,7 +160,11 @@ const RecruitingSelectionView: React.FC = ({ cont const saveAllFields = async (): Promise => { if (!fullEditable) return; - await updateAllSelectionFields(itemId, data, listId); + const editor = { name: context.pageContext.user.displayName, email: effectiveUserEmail(context) }; + const nextChanges = mergeFieldChanges(approverChanges, savedData, data, SELECTION_CHANGE_TRACKING, editor, new Date()); + await updateAllSelectionFields(itemId, data, nextChanges, listId); + setApproverChanges(nextChanges); + setSavedData(data); }; const handleSaveFields = async (): Promise => { @@ -184,6 +207,8 @@ const RecruitingSelectionView: React.FC = ({ cont .then(([loaded, files]) => { if (alive) { setData(loaded.data); + setSavedData(loaded.data); + setApproverChanges(loaded.approverChanges); setStatus(loaded.status); setCode(loaded.code); notifyCode(loaded.code); @@ -256,14 +281,17 @@ const RecruitingSelectionView: React.FC = ({ cont } headerRight={status ? : undefined} > + {/* Вложения ниже — вне этого блока: полное право открывает только поля. */} - - - - - - - + + + + + + + + +
diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/changeTracking.ts b/src/webparts/requestPortal/forms/RecruitingSelection/changeTracking.ts new file mode 100644 index 0000000..04f0345 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingSelection/changeTracking.ts @@ -0,0 +1,14 @@ +import { IChangeTrackingConfig } from '../../shared/fieldChanges/fieldChanges'; +import { IFormData } from './model'; + +/** Колонка журнала правок согласующего (Note, JSON) — см. provisioning.json. */ +export const APPROVER_CHANGES_FIELD = 'SelectionApproverChangesJson'; + +/** + * Что пишем в журнал правок согласующего (shared/fieldChanges). Id/код + * проекта и Id подразделения своего поля не имеют — меняются вместе с + * «Объект/Проект» и «Подразделение». + */ +export const SELECTION_CHANGE_TRACKING: IChangeTrackingConfig = { + ignore: ['ObjectProjectId', 'ObjectProjectCode', 'DepartmentId'] +}; diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/load.ts b/src/webparts/requestPortal/forms/RecruitingSelection/load.ts index 5a363df..bb109d7 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/load.ts @@ -1,7 +1,9 @@ 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, ILanguageLevel } from './model'; +import { APPROVER_CHANGES_FIELD } from './changeTracking'; /** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */ function parseJsonArray(raw: unknown): T[] { @@ -32,6 +34,8 @@ export interface ILoadedRequest { status: string; /** Уникальный код заявки; '' — если запись создана до появления поля. */ code: string; + /** Журнал правок согласующего (подсветка полей) — см. shared/fieldChanges. */ + approverChanges: IFieldChange[]; } /** @@ -63,6 +67,7 @@ export async function loadSelectionRequest(itemId: number, listId?: string): Pro 'LanguagesJson', 'ProgramsJson', 'WorkplaceProvisionJson', + APPROVER_CHANGES_FIELD, 'RequestStatus', 'RequestUniqueCode', ...personSelects @@ -102,6 +107,7 @@ export async function loadSelectionRequest(itemId: number, listId?: string): Pro return { data, status: (item.RequestStatus as string) || '', - code: (item.RequestUniqueCode as string) || '' + code: (item.RequestUniqueCode as string) || '', + approverChanges: parseFieldChanges(item[APPROVER_CHANGES_FIELD]) }; } diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json b/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json index d82649c..02eaa90 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json @@ -8,6 +8,7 @@ { "internalName": "VacancyOpenReason", "title": "Причина открытия вакансии", "type": "Choice", "choices": ["Замена уволенного сотрудника", "Перевод сотрудника", "Новая штатная единица", "Декрет", "Другое"] }, { "internalName": "VacancyOpenReasonDetails", "title": "Причина открытия — ФИО / уточнение", "type": "Text" }, { "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" }, + { "internalName": "SelectionApproverChangesJson", "title": "Изменения согласующего — подбор (JSON)", "type": "Note" }, { "internalName": "ObjectProject", "title": "Объект/Проект", "type": "Text" }, { "internalName": "ObjectProjectId", "title": "Объект/Проект — Id", "type": "Text" }, { "internalName": "ObjectProjectCode", "title": "Объект/Проект — код", "type": "Text" }, diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx index 8cdc98a..2107bdd 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx @@ -24,7 +24,7 @@ export const ApplicationSection: React.FC = ({ data, set, context return ( - + = ({ onChange={(v) => set("RateAfterProbation", v)} /> - + set("PieceworkPay", v)} @@ -110,14 +110,14 @@ export const ConditionsSection: React.FC = ({ /> )} - + set("MaterialResponsibility", v)} onLabel="Да" /> - + set("WorkplaceProvision", v)} @@ -126,14 +126,14 @@ export const ConditionsSection: React.FC = ({ placeholder="Выберите категорию" /> - + set("ReferralProgram", v)} onLabel="Да" /> - + = ({ placeholder={PERSON_PLACEHOLDER} /> - + = ({ placeholder={PERSON_PLACEHOLDER} /> - + = ({ data, set }) => ( - + set('BusinessTrips', v)} onLabel="Да" /> {data.BusinessTrips && ( diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/sections/VacancySection.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/sections/VacancySection.tsx index 2c11501..a9f9e4c 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/sections/VacancySection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/sections/VacancySection.tsx @@ -60,7 +60,7 @@ export const VacancySection: React.FC = ({ data, set, context }) placeholder="— выберите —" /> - + set('Programs', v)} @@ -69,7 +69,7 @@ export const VacancySection: React.FC = ({ data, set, context }) placeholder="Выберите программу" /> - + set('Languages', v)} /> diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts index b88d3d1..93b8342 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts @@ -1,7 +1,9 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList'; +import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/fieldChanges'; import { FORM_ID, IFormData } from './model'; +import { APPROVER_CHANGES_FIELD } from './changeTracking'; // Поля-люди (один человек): логин → ensureUser → Id в колонку Id. const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager']; @@ -87,12 +89,19 @@ export type ISaveResult = ISaveItemResult; /** * Перезаписать ВСЕ поля заявки — для согласующего с полным правом правки - * (см. shared/approval/fullEditRights.ts). Статус, маршрут и вложения не + * (см. shared/approval/fullEditRights.ts) — вместе с журналом его правок + * (подсветка полей, см. shared/fieldChanges). Статус, маршрут и вложения не * трогаются: заявка остаётся на том же шаге согласования. */ -export async function updateAllSelectionFields(id: number, data: IFormData, listId?: string): Promise { +export async function updateAllSelectionFields( + id: number, + data: IFormData, + approverChanges: IFieldChange[], + listId?: string +): Promise { const item = buildScalarBody(data); await applyPeople(item, data); + item[APPROVER_CHANGES_FIELD] = serializeFieldChanges(approverChanges); await resolveDataList(FORM_ID, listId).items.getById(id).update(item); } diff --git a/src/webparts/requestPortal/shared/fieldChanges/fieldChanges.test.ts b/src/webparts/requestPortal/shared/fieldChanges/fieldChanges.test.ts new file mode 100644 index 0000000..864c9ed --- /dev/null +++ b/src/webparts/requestPortal/shared/fieldChanges/fieldChanges.test.ts @@ -0,0 +1,91 @@ +import { formatFieldValue, mergeFieldChanges, parseFieldChanges, IChangeTrackingConfig } from './fieldChanges'; + +interface IData { + Position: string; + PositionId: string; + Salary: string; + StartDate: string; + Remote: boolean; + Companies: { Id: string; Name: string }[]; +} + +const base: IData = { + Position: 'Инженер', + PositionId: '1', + Salary: '300000', + StartDate: '2026-10-01', + Remote: false, + Companies: [{ Id: 'a', Name: 'ТОО А' }] +}; + +const config: IChangeTrackingConfig = { ignore: ['PositionId'] }; +const editor = { name: 'Алтынай Бултурукова', email: 'a.bulturukova@tnsu.kz' }; +const now = new Date('2026-09-24T10:00:00Z'); + +describe('formatFieldValue', () => { + it('форматирует дату, булево, пустое и списки', () => { + expect(formatFieldValue('2026-10-01')).toBe('01.10.2026'); + expect(formatFieldValue(true)).toBe('Да'); + expect(formatFieldValue('')).toBe('—'); + expect(formatFieldValue([])).toBe('—'); + expect(formatFieldValue([{ Id: 'a', Name: 'ТОО А' }, { Id: 'b', Name: 'ТОО Б' }])).toBe('ТОО А, ТОО Б'); + expect(formatFieldValue([{ language: 'Английский', level: 'Средний' }])).toBe('Английский (Средний)'); + }); +}); + +describe('mergeFieldChanges', () => { + it('пишет изменённые поля и пропускает технические', () => { + const edited = { ...base, Position: 'Ведущий инженер', PositionId: '2' }; + const result = mergeFieldChanges([], base, edited, config, editor, now); + expect(result).toEqual([ + { + field: 'Position', + before: 'Инженер', + after: 'Ведущий инженер', + byName: editor.name, + byEmail: editor.email, + at: now.toISOString() + } + ]); + }); + + it('при повторной правке сохраняет исходное «было»', () => { + const first = mergeFieldChanges([], base, { ...base, Salary: '350000' }, config, editor, now); + const saved = { ...base, Salary: '350000' }; + const second = mergeFieldChanges(first, saved, { ...saved, Salary: '400000' }, config, editor, now); + expect(second).toHaveLength(1); + expect(second[0].before).toBe('300000'); + expect(second[0].after).toBe('400000'); + }); + + it('убирает запись, если поле вернули к исходному значению', () => { + const first = mergeFieldChanges([], base, { ...base, Remote: true }, config, editor, now); + const saved = { ...base, Remote: true }; + expect(mergeFieldChanges(first, saved, base, config, editor, now)).toEqual([]); + }); + + it('не трогает прежние записи по полям, которые в этот раз не менялись', () => { + const first = mergeFieldChanges([], base, { ...base, StartDate: '2026-11-01' }, config, editor, now); + const saved = { ...base, StartDate: '2026-11-01' }; + const second = mergeFieldChanges(first, saved, { ...saved, Companies: [] }, config, editor, now); + expect(second.map((c) => c.field)).toEqual(['StartDate', 'Companies']); + expect(second[1]).toMatchObject({ before: 'ТОО А', after: '—' }); + }); + + it('использует свой форматтер поля', () => { + const withFormatter: IChangeTrackingConfig = { + ignore: [], + formatters: { PositionId: (v) => `#${String(v)}` } + }; + const result = mergeFieldChanges([], base, { ...base, PositionId: '7' }, withFormatter, editor, now); + expect(result[0]).toMatchObject({ before: '#1', after: '#7' }); + }); +}); + +describe('parseFieldChanges', () => { + it('битый или пустой JSON — пустой журнал', () => { + expect(parseFieldChanges('')).toEqual([]); + expect(parseFieldChanges('{не json')).toEqual([]); + expect(parseFieldChanges(null)).toEqual([]); + }); +}); diff --git a/src/webparts/requestPortal/shared/fieldChanges/fieldChanges.ts b/src/webparts/requestPortal/shared/fieldChanges/fieldChanges.ts new file mode 100644 index 0000000..93a4c04 --- /dev/null +++ b/src/webparts/requestPortal/shared/fieldChanges/fieldChanges.ts @@ -0,0 +1,143 @@ +/** + * Журнал правок согласующего: какие поля заявки он поменял на своём шаге и + * какими они были до этого. Хранится JSON-массивом в Note-колонке заявки + * (у каждой формы своя — см. provisioning.json), показывается подсветкой поля + * и строкой «Было: …» (см. shared/ui/FormField). + * + * Сейчас пишется только при полном праве правки (shared/approval/fullEditRights.ts). + */ + +export interface IFieldChange { + /** Ключ поля = internalName колонки = htmlFor (или fieldKey) поля формы. */ + field: string; + /** Значение ДО первой правки согласующего — в читаемом виде. */ + before: string; + /** Значение после последней правки — в читаемом виде. */ + after: string; + byName: string; + byEmail: string; + /** ISO-время последней правки этого поля. */ + at: string; +} + +export type FieldChangeMap = Record; + +/** Кто правит — для записи в журнал. */ +export interface IFieldEditor { + name: string; + email: string; +} + +/** Своё отображение значения поля: получает значение и весь снимок формы. */ +export type FieldFormatter = (value: unknown, data: T) => string; + +export interface IChangeTrackingConfig { + /** Технические ключи без своего поля на форме (Id из справочников и т.п.) — их правки не пишем. */ + ignore: ReadonlyArray; + /** Поля, у которых значение само по себе нечитаемо (Id компании → её название). */ + formatters?: Partial>>; +} + +/** Пустое значение в журнале и в строке «Было: …». */ +export const EMPTY_VALUE = '—'; + +const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/; + +function formatItem(item: unknown): string { + if (typeof item === 'string') return item; + if (item && typeof item === 'object') { + const record = item as Record; + // Компании справочника — { Id, Name }. + if (typeof record.Name === 'string') return record.Name; + // Языки — { language, level }. + if (typeof record.language === 'string') { + return record.level ? `${record.language} (${String(record.level)})` : record.language; + } + } + return JSON.stringify(item); +} + +/** Значение поля формы → читаемая строка (дата ДД.ММ.ГГГГ, «Да/Нет», списки через запятую). */ +export function formatFieldValue(value: unknown): string { + if (value === null || value === undefined) return EMPTY_VALUE; + if (typeof value === 'boolean') return value ? 'Да' : 'Нет'; + if (typeof value === 'number') return String(value); + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return EMPTY_VALUE; + const date = trimmed.match(ISO_DATE); + return date ? `${date[3]}.${date[2]}.${date[1]}` : trimmed; + } + if (Array.isArray(value)) { + return value.length > 0 ? value.map(formatItem).join(', ') : EMPTY_VALUE; + } + return JSON.stringify(value); +} + +function isFieldChange(item: unknown): item is IFieldChange { + const record = item as Record | null; + return !!record && typeof record.field === 'string' && typeof record.before === 'string'; +} + +/** Разбор JSON журнала из колонки; битое/пустое значение — пустой журнал. */ +export function parseFieldChanges(raw: unknown): IFieldChange[] { + if (typeof raw !== 'string' || raw.trim() === '') return []; + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(isFieldChange) : []; + } catch { + return []; + } +} + +/** Журнал → JSON для колонки (null — журнал пуст, колонку очищаем). */ +export function serializeFieldChanges(changes: IFieldChange[]): string | null { + return changes.length > 0 ? JSON.stringify(changes) : null; +} + +export function toChangeMap(changes: IFieldChange[]): FieldChangeMap { + const map: FieldChangeMap = {}; + changes.forEach((c) => { + map[c.field] = c; + }); + return map; +} + +/** + * Дописать в журнал правки одного сохранения. + * + * saved — то, что сейчас лежит в заявке (до этого сохранения), edited — то, + * что сохраняем. В «было» остаётся значение ДО ПЕРВОЙ правки поля — при + * повторных сохранениях обновляется только «стало». Если поле вернули к + * исходному значению, запись о нём убирается. + */ +export function mergeFieldChanges( + previous: IFieldChange[], + saved: T, + edited: T, + config: IChangeTrackingConfig, + editor: IFieldEditor, + now: Date +): IFieldChange[] { + const display = (key: keyof T, data: T): string => { + const formatter = config.formatters?.[key]; + return formatter ? formatter(data[key], data) : formatFieldValue(data[key]); + }; + + const byField = toChangeMap(previous); + (Object.keys(edited) as (keyof T)[]).forEach((key) => { + if (config.ignore.indexOf(key) !== -1) return; + if (JSON.stringify(saved[key]) === JSON.stringify(edited[key])) return; + + const field = String(key); + const after = display(key, edited); + const before = byField[field] ? byField[field].before : display(key, saved); + if (before === after) { + delete byField[field]; + return; + } + byField[field] = { field, before, after, byName: editor.name, byEmail: editor.email, at: now.toISOString() }; + }); + + return Object.keys(byField).map((field) => byField[field]); +} diff --git a/src/webparts/requestPortal/shared/ui/FieldChanges/FieldChangesBanner.tsx b/src/webparts/requestPortal/shared/ui/FieldChanges/FieldChangesBanner.tsx new file mode 100644 index 0000000..178c553 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/FieldChanges/FieldChangesBanner.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { Alert } from '@tnsu/ui-kit'; +import { IFieldChange } from '../../fieldChanges/fieldChanges'; +import { formatDateTime } from './formatDateTime'; + +interface IFieldChangesBannerProps { + changes: IFieldChange[]; +} + +/** «поле / поля / полей» по числу. */ +function fieldsWord(count: number): string { + const mod10 = count % 10; + const mod100 = count % 100; + if (mod10 === 1 && mod100 !== 11) return 'поле'; + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'поля'; + return 'полей'; +} + +/** + * Сводка над формой: сколько полей поменял согласующий и когда. Сами поля + * подсвечены по месту (см. shared/ui/FormField). На печати не выводится. + */ +export const FieldChangesBanner: React.FC = ({ changes }) => { + if (changes.length === 0) return null; + + const names = changes + .map((c) => c.byName) + .filter((name, i, all) => !!name && all.indexOf(name) === i) + .join(', '); + const lastAt = changes.reduce((latest, c) => (c.at > latest ? c.at : latest), changes[0].at); + + return ( +
+ + Согласующий{names ? ` (${names})` : ''} изменил {changes.length} {fieldsWord(changes.length)}, последнее + изменение — {formatDateTime(lastAt)}. Изменённые поля подсвечены, под каждым указано прежнее значение. + +
+ ); +}; diff --git a/src/webparts/requestPortal/shared/ui/FieldChanges/FieldChangesContext.tsx b/src/webparts/requestPortal/shared/ui/FieldChanges/FieldChangesContext.tsx new file mode 100644 index 0000000..8beebd0 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/FieldChanges/FieldChangesContext.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { FieldChangeMap, IFieldChange } from '../../fieldChanges/fieldChanges'; + +/** + * Журнал правок согласующего для всей формы сразу: каждое поле само находит + * свою правку по ключу (см. shared/ui/FormField), поэтому секции форм про + * журнал ничего не знают. + */ +const FieldChangesContext = React.createContext({}); + +interface IFieldChangesProviderProps { + changes: FieldChangeMap; + children: React.ReactNode; +} + +export const FieldChangesProvider: React.FC = ({ changes, children }) => ( + {children} +); + +/** Правка поля по ключу; undefined — поле не менялось (или журнала нет). */ +export function useFieldChange(fieldKey: string | undefined): IFieldChange | undefined { + const changes = React.useContext(FieldChangesContext); + return fieldKey ? changes[fieldKey] : undefined; +} diff --git a/src/webparts/requestPortal/shared/ui/FieldChanges/formatDateTime.ts b/src/webparts/requestPortal/shared/ui/FieldChanges/formatDateTime.ts new file mode 100644 index 0000000..eebb800 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/FieldChanges/formatDateTime.ts @@ -0,0 +1,10 @@ +function pad(n: number): string { + return n < 10 ? `0${n}` : String(n); +} + +/** ISO-время → «ДД.ММ.ГГГГ ЧЧ:ММ» по локальному времени; битое значение — как есть. */ +export function formatDateTime(iso: string): string { + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} diff --git a/src/webparts/requestPortal/shared/ui/FormField/FormField.module.scss b/src/webparts/requestPortal/shared/ui/FormField/FormField.module.scss new file mode 100644 index 0000000..cd913eb --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/FormField/FormField.module.scss @@ -0,0 +1,30 @@ +@import '../../../../../styles/design-tokens'; + +// Поле, изменённое согласующим (см. shared/fieldChanges): золотая полоса слева +// и лёгкая подложка — заметно, но не спорит с ошибками (красный) и фокусом. +.changed { + padding: $sp-1 0 $sp-1 $sp-2; + border-left: 3px solid $gold-500; + border-radius: $r-sm; + background: $gold-50; +} + +.before { + margin-top: $sp-1; + font-size: $fs-caption; + color: $gray-700; + overflow-wrap: anywhere; // длинные значения (списки компаний) не распирают узкую колонку +} + +.beforeLabel { + font-weight: 600; +} + +// На печати подсветка не нужна — бумажная форма показывает итоговые значения. +@media print { + .changed { + padding: 0; + border-left: 0; + background: none; + } +} diff --git a/src/webparts/requestPortal/shared/ui/FormField/FormField.tsx b/src/webparts/requestPortal/shared/ui/FormField/FormField.tsx new file mode 100644 index 0000000..892dde9 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/FormField/FormField.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { FormField as KitFormField } from '@tnsu/ui-kit'; +import styles from './FormField.module.scss'; +import { useFieldChange } from '../FieldChanges/FieldChangesContext'; +import { formatDateTime } from '../FieldChanges/formatDateTime'; + +interface IFormFieldProps { + label: string; + htmlFor?: string; + required?: boolean; + /** Занять обе колонки сетки. */ + full?: boolean; + /** + * Ключ поля в журнале правок (internalName колонки). По умолчанию — htmlFor; + * задаётся явно у полей без htmlFor (люди, мультивыбор, переключатели). + */ + fieldKey?: string; + children: React.ReactNode; +} + +/** + * FormField из ui-kit + подсветка правки согласующего: если поле есть в + * журнале (см. FieldChangesProvider), контрол подсвечивается, а под ним — + * прежнее значение. Без журнала ведёт себя ровно как FormField из ui-kit. + */ +export const FormField: React.FC = ({ fieldKey, children, ...fieldProps }) => { + const change = useFieldChange(fieldKey ?? fieldProps.htmlFor); + if (!change) { + return {children}; + } + + return ( + +
{children}
+
+ Было: {change.before} +
+
+ ); +}; diff --git a/src/webparts/requestPortal/shared/ui/index.ts b/src/webparts/requestPortal/shared/ui/index.ts index eaa7af1..68ab21e 100644 --- a/src/webparts/requestPortal/shared/ui/index.ts +++ b/src/webparts/requestPortal/shared/ui/index.ts @@ -9,7 +9,6 @@ export { Alert, FormCard, FormGrid, - FormField, TextInput, PhoneInput, MoneyInput, @@ -32,6 +31,11 @@ export { export type { ISelectOption, IConfirmOptions } from '@tnsu/ui-kit'; // --- Пока локальные (не перенесены: SP/бизнес/отложенные) --- +// FormField — обёртка над ui-kit с подсветкой правок согласующего (журнал в +// FieldChangesProvider); без журнала ведёт себя как FormField из ui-kit. +export { FormField } from './FormField/FormField'; +export { FieldChangesProvider } from './FieldChanges/FieldChangesContext'; +export { FieldChangesBanner } from './FieldChanges/FieldChangesBanner'; export { ItemsTable } from './ItemsTable/ItemsTable'; export type { IItemsTableProps } from './ItemsTable/ItemsTable'; export type { ILineItem } from './ItemsTable/ILineItem'; From 1884007161392053643686358290baefcbbc5020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Thu, 24 Sep 2026 17:58:34 +0500 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=20=C2=AB?= =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=20=E2=80=94=20=D0=94=D0=BE?= =?UTF-8?q?=D0=BB=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=E2=80=94=20=D0=9F?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BA=D1=82=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../requestPortal/forms/RecruitingHire/submit.ts | 5 +++-- .../forms/RecruitingSelection/submit.ts | 10 ++++++++-- .../requestPortal/shared/requestTitle.test.ts | 12 ++++++++++++ src/webparts/requestPortal/shared/requestTitle.ts | 12 ++++++++++++ 4 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 src/webparts/requestPortal/shared/requestTitle.test.ts create mode 100644 src/webparts/requestPortal/shared/requestTitle.ts diff --git a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts index 19814df..bee6fec 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts @@ -1,6 +1,7 @@ import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList'; import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/fieldChanges'; +import { buildRequestTitle } from '../../shared/requestTitle'; import { FORM_ID, IFormData } from './model'; import { APPROVER_CHANGES_FIELD } from './changeTracking'; @@ -16,9 +17,9 @@ export function hireApprovalService(listId?: string): ApprovalListService { return approvalServiceFor(FORM_ID, listId); } -/** Осмысленный заголовок элемента списка (системная колонка Title). */ +/** Название заявки (Title): «Прием — ФИО кандидата — Должность». */ function buildTitle(data: IFormData): string { - return ['Прием', data.HireCandidateName, data.Position].filter(Boolean).join(' — '); + return buildRequestTitle('Прием', [data.HireCandidateName, data.Position]); } /** Поля элемента списка данных из модели формы (текст/выбор/даты/числа), без статуса. */ diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts index 93b8342..8b1950c 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts @@ -2,6 +2,7 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList'; import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/fieldChanges'; +import { buildRequestTitle } from '../../shared/requestTitle'; import { FORM_ID, IFormData } from './model'; import { APPROVER_CHANGES_FIELD } from './changeTracking'; @@ -27,9 +28,14 @@ async function resolveUserId(login: string): Promise { return ensured.Id; } -/** Скалярные/числовые/да-нет поля из модели (людей и статус пропускаем — они отдельно). */ +/** Название заявки (Title): «Подбор — Должность — Объект/Проект». */ +function buildTitle(data: IFormData): string { + return buildRequestTitle('Подбор', [data.Position, data.ObjectProject]); +} + +/** Скалярные/числовые/да-нет поля из модели + название (людей и статус пропускаем — они отдельно). */ function buildScalarBody(data: IFormData): Record { - const item: Record = {}; + const item: Record = { Title: buildTitle(data) }; (Object.keys(data) as (keyof IFormData)[]).forEach((key) => { if (PERSON_FIELDS.indexOf(key) !== -1 || MULTI_PERSON_FIELDS.indexOf(key) !== -1) { return; diff --git a/src/webparts/requestPortal/shared/requestTitle.test.ts b/src/webparts/requestPortal/shared/requestTitle.test.ts new file mode 100644 index 0000000..2497a67 --- /dev/null +++ b/src/webparts/requestPortal/shared/requestTitle.test.ts @@ -0,0 +1,12 @@ +import { buildRequestTitle } from './requestTitle'; + +describe('buildRequestTitle', () => { + it('склеивает тип и части через тире', () => { + expect(buildRequestTitle('Подбор', ['Инженер', 'Проект X'])).toBe('Подбор — Инженер — Проект X'); + }); + + it('пропускает пустые и пробельные части', () => { + expect(buildRequestTitle('Подбор', ['', ' Проект X '])).toBe('Подбор — Проект X'); + expect(buildRequestTitle('Прием', ['', ''])).toBe('Прием'); + }); +}); diff --git a/src/webparts/requestPortal/shared/requestTitle.ts b/src/webparts/requestPortal/shared/requestTitle.ts new file mode 100644 index 0000000..63c7157 --- /dev/null +++ b/src/webparts/requestPortal/shared/requestTitle.ts @@ -0,0 +1,12 @@ +/** Разделитель частей названия заявки. */ +const TITLE_SEPARATOR = ' — '; + +/** + * Название заявки (системная колонка Title списка данных): тип + ключевые + * поля через тире, пустые части пропускаются — «Подбор — Инженер — Проект X». + * По нему заявку узнают в списке SP, в столбце «Заявка» списка согласования + * и в колонке «Название» таблиц портала. + */ +export function buildRequestTitle(typeLabel: string, parts: string[]): string { + return [typeLabel, ...parts.map((p) => p.trim())].filter(Boolean).join(TITLE_SEPARATOR); +}