feat: подсветка полей, изменённых согласующим, с прежними значениями
Tests / frontend-test (push) Successful in 1m15s
Tests / frontend-test (push) Successful in 1m15s
This commit is contained in:
@@ -6,6 +6,8 @@ import {
|
|||||||
FormCard,
|
FormCard,
|
||||||
InitiatorLine,
|
InitiatorLine,
|
||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
|
FieldChangesProvider,
|
||||||
|
FieldChangesBanner,
|
||||||
FileDropzone,
|
FileDropzone,
|
||||||
AttachmentList,
|
AttachmentList,
|
||||||
CollapseSection,
|
CollapseSection,
|
||||||
@@ -16,6 +18,7 @@ import {
|
|||||||
getDraftDeleteHandler,
|
getDraftDeleteHandler,
|
||||||
useRequestSubmit
|
useRequestSubmit
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
|
import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@@ -87,6 +90,8 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
|
// Журнал правок согласующего — виден и здесь, когда заявку вернули на доработку.
|
||||||
|
const [approverChanges, setApproverChanges] = useState<IFieldChange[]>([]);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
@@ -112,6 +117,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
.then(([loaded, atts]) => {
|
.then(([loaded, atts]) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
|
setApproverChanges(loaded.approverChanges);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) notifyCode(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
@@ -405,13 +411,16 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
)}
|
)}
|
||||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||||
|
|
||||||
<FormModeProvider readOnly={readOnly}>
|
<FieldChangesBanner changes={approverChanges} />
|
||||||
<ApplicationSection {...sectionProps} />
|
<FieldChangesProvider changes={toChangeMap(approverChanges)}>
|
||||||
<CandidateSection {...sectionProps} />
|
<FormModeProvider readOnly={readOnly}>
|
||||||
<PpeSection {...sectionProps} />
|
<ApplicationSection {...sectionProps} />
|
||||||
<ConditionsSection {...sectionProps} />
|
<CandidateSection {...sectionProps} />
|
||||||
<AgencySection {...sectionProps} />
|
<PpeSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
<ConditionsSection {...sectionProps} />
|
||||||
|
<AgencySection {...sectionProps} />
|
||||||
|
</FormModeProvider>
|
||||||
|
</FieldChangesProvider>
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<CollapseSection title="Вложения" icon="upload">
|
<CollapseSection title="Вложения" icon="upload">
|
||||||
|
|||||||
@@ -2,7 +2,22 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './RecruitingHireView.module.scss';
|
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 {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -91,6 +106,11 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
// Все поля заявки правит на своём активном шаге согласующий из
|
// Все поля заявки правит на своём активном шаге согласующий из
|
||||||
// shared/approval/fullEditRights.ts (сейчас — Алтынай Бултурукова).
|
// shared/approval/fullEditRights.ts (сейчас — Алтынай Бултурукова).
|
||||||
const [fullEditable, setFullEditable] = useState<boolean>(false);
|
const [fullEditable, setFullEditable] = useState<boolean>(false);
|
||||||
|
// То, что сейчас лежит в заявке на сервере (до несохранённых правок), — с ним
|
||||||
|
// сравниваем при сохранении, чтобы записать в журнал только реальные правки.
|
||||||
|
const [savedData, setSavedData] = useState<IFormData>(EMPTY_DATA);
|
||||||
|
// Журнал правок согласующего с полным правом — подсветка полей и «Было: …».
|
||||||
|
const [approverChanges, setApproverChanges] = useState<IFieldChange[]>([]);
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||||
const [saveErrorMessage, setSaveErrorMessage] = useState<string>('');
|
const [saveErrorMessage, setSaveErrorMessage] = useState<string>('');
|
||||||
// Право удалять согласующих из уже поданного маршрута — см.
|
// Право удалять согласующих из уже поданного маршрута — см.
|
||||||
@@ -180,7 +200,11 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
// формы, загруженным при открытии экрана.
|
// формы, загруженным при открытии экрана.
|
||||||
const saveApproverFields = async (): Promise<void> => {
|
const saveApproverFields = async (): Promise<void> => {
|
||||||
if (fullEditable) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const fields: Partial<IApproverEditableFields> = {};
|
const fields: Partial<IApproverEditableFields> = {};
|
||||||
@@ -247,6 +271,8 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
.then(([loaded, files]) => {
|
.then(([loaded, files]) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
|
setSavedData(loaded.data);
|
||||||
|
setApproverChanges(loaded.approverChanges);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
notifyCode(loaded.code);
|
notifyCode(loaded.code);
|
||||||
@@ -334,14 +360,17 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
}
|
}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
>
|
>
|
||||||
|
<FieldChangesBanner changes={approverChanges} />
|
||||||
{/* Вложения ниже — вне этого блока: полное право открывает только поля. */}
|
{/* Вложения ниже — вне этого блока: полное право открывает только поля. */}
|
||||||
<FormModeProvider readOnly={!fullEditable}>
|
<FieldChangesProvider changes={toChangeMap(approverChanges)}>
|
||||||
<ApplicationSection {...sectionProps} />
|
<FormModeProvider readOnly={!fullEditable}>
|
||||||
<CandidateSection {...sectionProps} />
|
<ApplicationSection {...sectionProps} />
|
||||||
<PpeSection {...sectionProps} />
|
<CandidateSection {...sectionProps} />
|
||||||
<ConditionsSection {...sectionProps} />
|
<PpeSection {...sectionProps} />
|
||||||
<AgencySection {...sectionProps} />
|
<ConditionsSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
<AgencySection {...sectionProps} />
|
||||||
|
</FormModeProvider>
|
||||||
|
</FieldChangesProvider>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<CollapseSection title="Вложения" icon="upload">
|
<CollapseSection title="Вложения" icon="upload">
|
||||||
|
|||||||
@@ -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<IFormData> = {
|
||||||
|
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(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { ICompany } from '../../shared/dictionary';
|
import { ICompany } from '../../shared/dictionary';
|
||||||
import { dataListTitle } from '../../shared/listTitles';
|
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 } from './model';
|
||||||
|
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
||||||
|
|
||||||
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
|
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
|
||||||
function parseJsonArray<T>(raw: unknown): T[] {
|
function parseJsonArray<T>(raw: unknown): T[] {
|
||||||
@@ -30,6 +32,8 @@ export interface ILoadedRequest {
|
|||||||
status: string;
|
status: string;
|
||||||
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||||
code: string;
|
code: string;
|
||||||
|
/** Журнал правок согласующего (подсветка полей) — см. shared/fieldChanges. */
|
||||||
|
approverChanges: IFieldChange[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,6 +55,7 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
|
|||||||
'CompanySharesJson',
|
'CompanySharesJson',
|
||||||
'RecruitingAgency',
|
'RecruitingAgency',
|
||||||
'HireOnArrival',
|
'HireOnArrival',
|
||||||
|
APPROVER_CHANGES_FIELD,
|
||||||
'RequestStatus',
|
'RequestStatus',
|
||||||
'RequestUniqueCode'
|
'RequestUniqueCode'
|
||||||
)()) as Record<string, unknown>;
|
)()) as Record<string, unknown>;
|
||||||
@@ -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])
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
{ "internalName": "HireCandidateName", "title": "Ф.И.О. кандидата", "type": "Text", "required": true },
|
{ "internalName": "HireCandidateName", "title": "Ф.И.О. кандидата", "type": "Text", "required": true },
|
||||||
{ "internalName": "HireHireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "HireHireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
|
||||||
|
{ "internalName": "HireApproverChangesJson", "title": "Изменения согласующего — приём (JSON)", "type": "Note" },
|
||||||
{ "internalName": "HireOnArrival", "title": "Дата приёма — по приезду на объект", "type": "Boolean" },
|
{ "internalName": "HireOnArrival", "title": "Дата приёма — по приезду на объект", "type": "Boolean" },
|
||||||
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
|
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
|
||||||
{ "internalName": "SiteName", "title": "Отдел / участок (устарело)", "type": "Text" },
|
{ "internalName": "SiteName", "title": "Отдел / участок (устарело)", "type": "Text" },
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const AgencySection: React.FC<ISectionProps> = ({ data, set }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapseSection title="5. Рекрутинговое агентство" icon="building">
|
<CollapseSection title="5. Рекрутинговое агентство" icon="building">
|
||||||
<FormField label="5.1 Привлекается рекрутинговое агентство">
|
<FormField label="5.1 Привлекается рекрутинговое агентство" fieldKey="RecruitingAgency">
|
||||||
<Toggle checked={data.RecruitingAgency} onChange={handleToggle} onLabel="Да" />
|
<Toggle checked={data.RecruitingAgency} onChange={handleToggle} onLabel="Да" />
|
||||||
</FormField>
|
</FormField>
|
||||||
{data.RecruitingAgency && (
|
{data.RecruitingAgency && (
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapseSection title="1. Заявка" icon="clipboard">
|
<CollapseSection title="1. Заявка" icon="clipboard">
|
||||||
<FormField label="1.1 Заявка на подбор" full>
|
<FormField label="1.1 Заявка на подбор" fieldKey="SelectionRequestCode" full>
|
||||||
<SelectionRequestField data={data} set={set} />
|
<SelectionRequestField data={data} set={set} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="1.2 Организация" htmlFor="Companies" required full>
|
<FormField label="1.2 Организация" htmlFor="Companies" required full>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const CompanySharesField: React.FC<ICompanySharesFieldProps> = ({
|
|||||||
placeholder="— выберите —"
|
placeholder="— выберите —"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Распределение ЗП по компаниям" full>
|
<FormField label="Распределение ЗП по компаниям" fieldKey="CompanyShares" full>
|
||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
{companies.map((c) => (
|
{companies.map((c) => (
|
||||||
<div key={c.Id} className={styles.item}>
|
<div key={c.Id} className={styles.item}>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
||||||
|
import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import { FORM_ID, IFormData } from './model';
|
import { FORM_ID, IFormData } from './model';
|
||||||
|
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
||||||
|
|
||||||
// Числовые поля: пустая строка → null, иначе Number().
|
// Числовые поля: пустая строка → null, иначе Number().
|
||||||
const NUMBER_FIELDS: (keyof IFormData)[] = ['PpeHeight', 'PpeShoeSize', 'SalaryProbation', 'SalaryAfterProbation', 'SelectionRequestId'];
|
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<void> {
|
export async function updateAllHireFields(
|
||||||
await resolveDataList(FORM_ID, listId).items.getById(id).update(buildFields(data));
|
id: number,
|
||||||
|
data: IFormData,
|
||||||
|
approverChanges: IFieldChange[],
|
||||||
|
listId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const item = { ...buildFields(data), [APPROVER_CHANGES_FIELD]: serializeFieldChanges(approverChanges) };
|
||||||
|
await resolveDataList(FORM_ID, listId).items.getById(id).update(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
FormCard,
|
FormCard,
|
||||||
InitiatorLine,
|
InitiatorLine,
|
||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
|
FieldChangesProvider,
|
||||||
|
FieldChangesBanner,
|
||||||
FileDropzone,
|
FileDropzone,
|
||||||
AttachmentList,
|
AttachmentList,
|
||||||
CollapseSection,
|
CollapseSection,
|
||||||
@@ -16,6 +18,7 @@ import {
|
|||||||
getDraftDeleteHandler,
|
getDraftDeleteHandler,
|
||||||
useRequestSubmit
|
useRequestSubmit
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
|
import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import {
|
import {
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
@@ -74,6 +77,8 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
|
// Журнал правок согласующего — виден и здесь, когда заявку вернули на доработку.
|
||||||
|
const [approverChanges, setApproverChanges] = useState<IFieldChange[]>([]);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
@@ -98,6 +103,7 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
.then(([loaded, atts]) => {
|
.then(([loaded, atts]) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
|
setApproverChanges(loaded.approverChanges);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) notifyCode(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
@@ -418,13 +424,16 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
)}
|
)}
|
||||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||||
|
|
||||||
<FormModeProvider readOnly={readOnly}>
|
<FieldChangesBanner changes={approverChanges} />
|
||||||
<ApplicationSection {...sectionProps} />
|
<FieldChangesProvider changes={toChangeMap(approverChanges)}>
|
||||||
<DepartmentSection {...sectionProps} />
|
<FormModeProvider readOnly={readOnly}>
|
||||||
<VacancySection {...sectionProps} />
|
<ApplicationSection {...sectionProps} />
|
||||||
<HealthTripsSection {...sectionProps} />
|
<DepartmentSection {...sectionProps} />
|
||||||
<ConditionsSection {...sectionProps} />
|
<VacancySection {...sectionProps} />
|
||||||
</FormModeProvider>
|
<HealthTripsSection {...sectionProps} />
|
||||||
|
<ConditionsSection {...sectionProps} />
|
||||||
|
</FormModeProvider>
|
||||||
|
</FieldChangesProvider>
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<CollapseSection title="Вложения" icon="upload">
|
<CollapseSection title="Вложения" icon="upload">
|
||||||
|
|||||||
@@ -2,7 +2,21 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './RecruitingSelectionView.module.scss';
|
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 {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -79,6 +93,11 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
// Все поля заявки правит на своём АКТИВНОМ шаге согласующий из
|
// Все поля заявки правит на своём АКТИВНОМ шаге согласующий из
|
||||||
// shared/approval/fullEditRights.ts (сейчас — Алтынай Бултурукова).
|
// shared/approval/fullEditRights.ts (сейчас — Алтынай Бултурукова).
|
||||||
const [fullEditable, setFullEditable] = useState<boolean>(false);
|
const [fullEditable, setFullEditable] = useState<boolean>(false);
|
||||||
|
// То, что сейчас лежит в заявке на сервере (до несохранённых правок), — с ним
|
||||||
|
// сравниваем при сохранении, чтобы записать в журнал только реальные правки.
|
||||||
|
const [savedData, setSavedData] = useState<IFormData>(EMPTY_DATA);
|
||||||
|
// Журнал правок согласующего с полным правом — подсветка полей и «Было: …».
|
||||||
|
const [approverChanges, setApproverChanges] = useState<IFieldChange[]>([]);
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
const [saveState, setSaveState] = useState<SaveState>('idle');
|
||||||
const [saveErrorMessage, setSaveErrorMessage] = useState<string>('');
|
const [saveErrorMessage, setSaveErrorMessage] = useState<string>('');
|
||||||
|
|
||||||
@@ -141,7 +160,11 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
|
|
||||||
const saveAllFields = async (): Promise<void> => {
|
const saveAllFields = async (): Promise<void> => {
|
||||||
if (!fullEditable) return;
|
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<void> => {
|
const handleSaveFields = async (): Promise<void> => {
|
||||||
@@ -184,6 +207,8 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
.then(([loaded, files]) => {
|
.then(([loaded, files]) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
|
setSavedData(loaded.data);
|
||||||
|
setApproverChanges(loaded.approverChanges);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
notifyCode(loaded.code);
|
notifyCode(loaded.code);
|
||||||
@@ -256,14 +281,17 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
}
|
}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
>
|
>
|
||||||
|
<FieldChangesBanner changes={approverChanges} />
|
||||||
{/* Вложения ниже — вне этого блока: полное право открывает только поля. */}
|
{/* Вложения ниже — вне этого блока: полное право открывает только поля. */}
|
||||||
<FormModeProvider readOnly={!fullEditable}>
|
<FieldChangesProvider changes={toChangeMap(approverChanges)}>
|
||||||
<ApplicationSection {...sectionProps} />
|
<FormModeProvider readOnly={!fullEditable}>
|
||||||
<DepartmentSection {...sectionProps} />
|
<ApplicationSection {...sectionProps} />
|
||||||
<VacancySection {...sectionProps} />
|
<DepartmentSection {...sectionProps} />
|
||||||
<HealthTripsSection {...sectionProps} />
|
<VacancySection {...sectionProps} />
|
||||||
<ConditionsSection {...sectionProps} />
|
<HealthTripsSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
<ConditionsSection {...sectionProps} />
|
||||||
|
</FormModeProvider>
|
||||||
|
</FieldChangesProvider>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<CollapseSection title="Вложения" icon="upload">
|
<CollapseSection title="Вложения" icon="upload">
|
||||||
|
|||||||
@@ -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<IFormData> = {
|
||||||
|
ignore: ['ObjectProjectId', 'ObjectProjectCode', 'DepartmentId']
|
||||||
|
};
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { ICompany } from '../../shared/dictionary';
|
import { ICompany } from '../../shared/dictionary';
|
||||||
import { dataListTitle } from '../../shared/listTitles';
|
import { dataListTitle } from '../../shared/listTitles';
|
||||||
|
import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, ILanguageLevel } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, ILanguageLevel } from './model';
|
||||||
|
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
||||||
|
|
||||||
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
|
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
|
||||||
function parseJsonArray<T>(raw: unknown): T[] {
|
function parseJsonArray<T>(raw: unknown): T[] {
|
||||||
@@ -32,6 +34,8 @@ export interface ILoadedRequest {
|
|||||||
status: string;
|
status: string;
|
||||||
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||||
code: string;
|
code: string;
|
||||||
|
/** Журнал правок согласующего (подсветка полей) — см. shared/fieldChanges. */
|
||||||
|
approverChanges: IFieldChange[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,6 +67,7 @@ export async function loadSelectionRequest(itemId: number, listId?: string): Pro
|
|||||||
'LanguagesJson',
|
'LanguagesJson',
|
||||||
'ProgramsJson',
|
'ProgramsJson',
|
||||||
'WorkplaceProvisionJson',
|
'WorkplaceProvisionJson',
|
||||||
|
APPROVER_CHANGES_FIELD,
|
||||||
'RequestStatus',
|
'RequestStatus',
|
||||||
'RequestUniqueCode',
|
'RequestUniqueCode',
|
||||||
...personSelects
|
...personSelects
|
||||||
@@ -102,6 +107,7 @@ export async function loadSelectionRequest(itemId: number, listId?: string): Pro
|
|||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
status: (item.RequestStatus as string) || '',
|
status: (item.RequestStatus as string) || '',
|
||||||
code: (item.RequestUniqueCode as string) || ''
|
code: (item.RequestUniqueCode as string) || '',
|
||||||
|
approverChanges: parseFieldChanges(item[APPROVER_CHANGES_FIELD])
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
{ "internalName": "VacancyOpenReason", "title": "Причина открытия вакансии", "type": "Choice", "choices": ["Замена уволенного сотрудника", "Перевод сотрудника", "Новая штатная единица", "Декрет", "Другое"] },
|
{ "internalName": "VacancyOpenReason", "title": "Причина открытия вакансии", "type": "Choice", "choices": ["Замена уволенного сотрудника", "Перевод сотрудника", "Новая штатная единица", "Декрет", "Другое"] },
|
||||||
{ "internalName": "VacancyOpenReasonDetails", "title": "Причина открытия — ФИО / уточнение", "type": "Text" },
|
{ "internalName": "VacancyOpenReasonDetails", "title": "Причина открытия — ФИО / уточнение", "type": "Text" },
|
||||||
{ "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" },
|
{ "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" },
|
||||||
|
{ "internalName": "SelectionApproverChangesJson", "title": "Изменения согласующего — подбор (JSON)", "type": "Note" },
|
||||||
{ "internalName": "ObjectProject", "title": "Объект/Проект", "type": "Text" },
|
{ "internalName": "ObjectProject", "title": "Объект/Проект", "type": "Text" },
|
||||||
{ "internalName": "ObjectProjectId", "title": "Объект/Проект — Id", "type": "Text" },
|
{ "internalName": "ObjectProjectId", "title": "Объект/Проект — Id", "type": "Text" },
|
||||||
{ "internalName": "ObjectProjectCode", "title": "Объект/Проект — код", "type": "Text" },
|
{ "internalName": "ObjectProjectCode", "title": "Объект/Проект — код", "type": "Text" },
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapseSection title="1. Заявка" icon="clipboard">
|
<CollapseSection title="1. Заявка" icon="clipboard">
|
||||||
<FormField label="1.1 Ответственный за подачу заявки" required full>
|
<FormField label="1.1 Ответственный за подачу заявки" fieldKey="Responsible" required full>
|
||||||
<PeopleField
|
<PeopleField
|
||||||
context={context}
|
context={context}
|
||||||
value={data.Responsible}
|
value={data.Responsible}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
|
|||||||
onChange={(v) => set("RateAfterProbation", v)}
|
onChange={(v) => set("RateAfterProbation", v)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="5.4 Сдельная оплата труда">
|
<FormField label="5.4 Сдельная оплата труда" fieldKey="PieceworkPay">
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={data.PieceworkPay}
|
checked={data.PieceworkPay}
|
||||||
onChange={(v) => set("PieceworkPay", v)}
|
onChange={(v) => set("PieceworkPay", v)}
|
||||||
@@ -110,14 +110,14 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
<FormField label="5.7 Материальная ответственность (строительные площадки, склады)">
|
<FormField label="5.7 Материальная ответственность (строительные площадки, склады)" fieldKey="MaterialResponsibility">
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={data.MaterialResponsibility}
|
checked={data.MaterialResponsibility}
|
||||||
onChange={(v) => set("MaterialResponsibility", v)}
|
onChange={(v) => set("MaterialResponsibility", v)}
|
||||||
onLabel="Да"
|
onLabel="Да"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="5.8 Обеспечение рабочего места" required full>
|
<FormField label="5.8 Обеспечение рабочего места" fieldKey="WorkplaceProvision" required full>
|
||||||
<MultiChoiceField
|
<MultiChoiceField
|
||||||
value={data.WorkplaceProvision}
|
value={data.WorkplaceProvision}
|
||||||
onChange={(v) => set("WorkplaceProvision", v)}
|
onChange={(v) => set("WorkplaceProvision", v)}
|
||||||
@@ -126,14 +126,14 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
|
|||||||
placeholder="Выберите категорию"
|
placeholder="Выберите категорию"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="5.9 Охватывается реферальной программой">
|
<FormField label="5.9 Охватывается реферальной программой" fieldKey="ReferralProgram">
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={data.ReferralProgram}
|
checked={data.ReferralProgram}
|
||||||
onChange={(v) => set("ReferralProgram", v)}
|
onChange={(v) => set("ReferralProgram", v)}
|
||||||
onLabel="Да"
|
onLabel="Да"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="5.10 Предполагаемый наставник">
|
<FormField label="5.10 Предполагаемый наставник" fieldKey="ProposedMentor">
|
||||||
<PeopleField
|
<PeopleField
|
||||||
context={context}
|
context={context}
|
||||||
value={data.ProposedMentor}
|
value={data.ProposedMentor}
|
||||||
@@ -141,7 +141,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
|
|||||||
placeholder={PERSON_PLACEHOLDER}
|
placeholder={PERSON_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="5.11 Непосредственный руководитель" required>
|
<FormField label="5.11 Непосредственный руководитель" fieldKey="DirectManager" required>
|
||||||
<PeopleField
|
<PeopleField
|
||||||
context={context}
|
context={context}
|
||||||
value={data.DirectManager}
|
value={data.DirectManager}
|
||||||
@@ -149,7 +149,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({
|
|||||||
placeholder={PERSON_PLACEHOLDER}
|
placeholder={PERSON_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="5.12 Кто принимает решение о приеме (собеседования)" required full>
|
<FormField label="5.12 Кто принимает решение о приеме (собеседования)" fieldKey="HiringDecisionMaker" required full>
|
||||||
<PeopleFieldMulti
|
<PeopleFieldMulti
|
||||||
context={context}
|
context={context}
|
||||||
value={data.HiringDecisionMaker}
|
value={data.HiringDecisionMaker}
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import { ISectionProps, BUSINESS_TRIP_OPTIONS, OTHER_VALUE } from '../model';
|
|||||||
|
|
||||||
export const HealthTripsSection: React.FC<ISectionProps> = ({ data, set }) => (
|
export const HealthTripsSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||||
<CollapseSection title="4. Командировки" icon="send">
|
<CollapseSection title="4. Командировки" icon="send">
|
||||||
<FormField label="4.1 Командировки">
|
<FormField label="4.1 Командировки" fieldKey="BusinessTrips">
|
||||||
<Toggle checked={data.BusinessTrips} onChange={(v) => set('BusinessTrips', v)} onLabel="Да" />
|
<Toggle checked={data.BusinessTrips} onChange={(v) => set('BusinessTrips', v)} onLabel="Да" />
|
||||||
</FormField>
|
</FormField>
|
||||||
{data.BusinessTrips && (
|
{data.BusinessTrips && (
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const VacancySection: React.FC<ISectionProps> = ({ data, set, context })
|
|||||||
placeholder="— выберите —"
|
placeholder="— выберите —"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="3.8 Необходимые знания в программах" full>
|
<FormField label="3.8 Необходимые знания в программах" fieldKey="Programs" full>
|
||||||
<MultiChoiceField
|
<MultiChoiceField
|
||||||
value={data.Programs}
|
value={data.Programs}
|
||||||
onChange={(v) => set('Programs', v)}
|
onChange={(v) => set('Programs', v)}
|
||||||
@@ -69,7 +69,7 @@ export const VacancySection: React.FC<ISectionProps> = ({ data, set, context })
|
|||||||
placeholder="Выберите программу"
|
placeholder="Выберите программу"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="3.9 Иностранные языки" full>
|
<FormField label="3.9 Иностранные языки" fieldKey="Languages" full>
|
||||||
<LanguagesField value={data.Languages} onChange={(v) => set('Languages', v)} />
|
<LanguagesField value={data.Languages} onChange={(v) => set('Languages', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="3.10 Проживание" htmlFor="Residence" required>
|
<FormField label="3.10 Проживание" htmlFor="Residence" required>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
||||||
|
import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import { FORM_ID, IFormData } from './model';
|
import { FORM_ID, IFormData } from './model';
|
||||||
|
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
||||||
|
|
||||||
// Поля-люди (один человек): логин → ensureUser → Id в колонку <Field>Id.
|
// Поля-люди (один человек): логин → ensureUser → Id в колонку <Field>Id.
|
||||||
const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager'];
|
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<void> {
|
export async function updateAllSelectionFields(
|
||||||
|
id: number,
|
||||||
|
data: IFormData,
|
||||||
|
approverChanges: IFieldChange[],
|
||||||
|
listId?: string
|
||||||
|
): Promise<void> {
|
||||||
const item = buildScalarBody(data);
|
const item = buildScalarBody(data);
|
||||||
await applyPeople(item, data);
|
await applyPeople(item, data);
|
||||||
|
item[APPROVER_CHANGES_FIELD] = serializeFieldChanges(approverChanges);
|
||||||
await resolveDataList(FORM_ID, listId).items.getById(id).update(item);
|
await resolveDataList(FORM_ID, listId).items.getById(id).update(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<IData> = { 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<IData> = {
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, IFieldChange>;
|
||||||
|
|
||||||
|
/** Кто правит — для записи в журнал. */
|
||||||
|
export interface IFieldEditor {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Своё отображение значения поля: получает значение и весь снимок формы. */
|
||||||
|
export type FieldFormatter<T> = (value: unknown, data: T) => string;
|
||||||
|
|
||||||
|
export interface IChangeTrackingConfig<T> {
|
||||||
|
/** Технические ключи без своего поля на форме (Id из справочников и т.п.) — их правки не пишем. */
|
||||||
|
ignore: ReadonlyArray<keyof T>;
|
||||||
|
/** Поля, у которых значение само по себе нечитаемо (Id компании → её название). */
|
||||||
|
formatters?: Partial<Record<keyof T, FieldFormatter<T>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пустое значение в журнале и в строке «Было: …». */
|
||||||
|
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<string, unknown>;
|
||||||
|
// Компании справочника — { 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<string, unknown> | 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<T extends object>(
|
||||||
|
previous: IFieldChange[],
|
||||||
|
saved: T,
|
||||||
|
edited: T,
|
||||||
|
config: IChangeTrackingConfig<T>,
|
||||||
|
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]);
|
||||||
|
}
|
||||||
@@ -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<IFieldChangesBannerProps> = ({ 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 (
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<Alert variant="info">
|
||||||
|
Согласующий{names ? ` (${names})` : ''} изменил {changes.length} {fieldsWord(changes.length)}, последнее
|
||||||
|
изменение — {formatDateTime(lastAt)}. Изменённые поля подсвечены, под каждым указано прежнее значение.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FieldChangeMap, IFieldChange } from '../../fieldChanges/fieldChanges';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Журнал правок согласующего для всей формы сразу: каждое поле само находит
|
||||||
|
* свою правку по ключу (см. shared/ui/FormField), поэтому секции форм про
|
||||||
|
* журнал ничего не знают.
|
||||||
|
*/
|
||||||
|
const FieldChangesContext = React.createContext<FieldChangeMap>({});
|
||||||
|
|
||||||
|
interface IFieldChangesProviderProps {
|
||||||
|
changes: FieldChangeMap;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FieldChangesProvider: React.FC<IFieldChangesProviderProps> = ({ changes, children }) => (
|
||||||
|
<FieldChangesContext.Provider value={changes}>{children}</FieldChangesContext.Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Правка поля по ключу; undefined — поле не менялось (или журнала нет). */
|
||||||
|
export function useFieldChange(fieldKey: string | undefined): IFieldChange | undefined {
|
||||||
|
const changes = React.useContext(FieldChangesContext);
|
||||||
|
return fieldKey ? changes[fieldKey] : undefined;
|
||||||
|
}
|
||||||
@@ -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())}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IFormFieldProps> = ({ fieldKey, children, ...fieldProps }) => {
|
||||||
|
const change = useFieldChange(fieldKey ?? fieldProps.htmlFor);
|
||||||
|
if (!change) {
|
||||||
|
return <KitFormField {...fieldProps}>{children}</KitFormField>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KitFormField {...fieldProps}>
|
||||||
|
<div className={styles.changed}>{children}</div>
|
||||||
|
<div className={`${styles.before} rp-no-print`} title={`Изменено: ${change.byName}, ${formatDateTime(change.at)}`}>
|
||||||
|
<span className={styles.beforeLabel}>Было:</span> {change.before}
|
||||||
|
</div>
|
||||||
|
</KitFormField>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,7 +9,6 @@ export {
|
|||||||
Alert,
|
Alert,
|
||||||
FormCard,
|
FormCard,
|
||||||
FormGrid,
|
FormGrid,
|
||||||
FormField,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
PhoneInput,
|
PhoneInput,
|
||||||
MoneyInput,
|
MoneyInput,
|
||||||
@@ -32,6 +31,11 @@ export {
|
|||||||
export type { ISelectOption, IConfirmOptions } from '@tnsu/ui-kit';
|
export type { ISelectOption, IConfirmOptions } from '@tnsu/ui-kit';
|
||||||
|
|
||||||
// --- Пока локальные (не перенесены: SP/бизнес/отложенные) ---
|
// --- Пока локальные (не перенесены: 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 { ItemsTable } from './ItemsTable/ItemsTable';
|
||||||
export type { IItemsTableProps } from './ItemsTable/ItemsTable';
|
export type { IItemsTableProps } from './ItemsTable/ItemsTable';
|
||||||
export type { ILineItem } from './ItemsTable/ILineItem';
|
export type { ILineItem } from './ItemsTable/ILineItem';
|
||||||
|
|||||||
Reference in New Issue
Block a user