feat: Ербол Шаймерденов правит все поля и вложения заявки на рабочее место

Полное право правки стало раздельным по формам: раньше общий список открыл
бы Ерболу и подбор с приёмом. В «Организации рабочего места» право действует,
пока он есть в маршруте (согласование или исполнение) и заявка «На
согласовании» или «Выполнение», независимо от того, чья сейчас очередь.
Правки пишутся в журнал (подсветка «Было: …»), перед «Согласовать»
проверяются обязательные поля. Блок вложений вынесен в общий компонент.

Нужна новая колонка WorkplaceApproverChangesJson в списке данных.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-07 12:08:25 +05:00
parent 2ade16ce1d
commit cfbca8c2b2
16 changed files with 301 additions and 106 deletions
@@ -183,7 +183,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
const isExecution = pending?.phase === APPROVAL_PHASE.execution;
setPayrollEditable(isActive && !isExecution && canEditPayrollFields(pending?.position));
setHireDateEditable(isActive && isExecution);
setFullEditable(isActive && !isExecution && canEditAllFields(effectiveUserEmail(context)));
setFullEditable(isActive && !isExecution && canEditAllFields(FORM_ID, effectiveUserEmail(context)));
})
.catch(() => {
if (!alive) return;
@@ -35,7 +35,7 @@ import {
} from '../../shared/approval';
import { getAttachments } from '../../shared/attachments/attachments.service';
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
import { SelectionAttachments } from './SelectionAttachments';
import { EditableAttachments } from '../../shared/attachments/EditableAttachments';
import { effectiveUserEmail } from '../../shared/impersonation';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
@@ -152,7 +152,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
if (!alive) return;
const isActiveApproval =
!!pending && !pending.isDelegator && pending.phase !== APPROVAL_PHASE.execution;
setFullEditable(isActiveApproval && canEditAllFields(effectiveUserEmail(context)));
setFullEditable(isActiveApproval && canEditAllFields(FORM_ID, effectiveUserEmail(context)));
})
.catch(() => {
if (alive) setFullEditable(false);
@@ -348,7 +348,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
</FieldChangesProvider>
<div className="rp-no-print">
<SelectionAttachments editor={attachments} editable={fullEditable} />
<EditableAttachments editor={attachments} editable={fullEditable} />
</div>
</FormCard>
@@ -15,6 +15,9 @@
ФИО, телефон, должность, отдел/участок, вид работ, местоположение, дата приёма
(`loadPrefillFromHire`). Это снимок на момент создания; ссылка на источник
сохраняется в колонке-lookup `SourceHire`.
- **Полное право правки** (`shared/approval/fullEditRights.ts`, сейчас Ербол
Шаймерденов): все поля и вложения, если он есть в маршруте и заявка «На
согласовании»/«Выполнение» (`useApproverRights.ts`).
- **Поля «Необходимое ПО» и «Доступы» заполняет согласующий-руководитель** на своём
этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до
**первого** шага согласования и это текущий пользователь; сохраняются кнопкой
@@ -14,8 +14,11 @@ import {
RequestNumber,
SubmitActions,
getDraftDeleteHandler,
useRequestSubmit
useRequestSubmit,
FieldChangesProvider,
FieldChangesBanner
} from '../../shared/ui';
import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
import {
composeRemoveRowRule,
ApprovalMatrix,
@@ -53,6 +56,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { saveWorkplaceRequest, workplaceApprovalService } from './submit';
import { loadPrefillFromHire } from './prefill';
import { loadWorkplaceRequest } from './load';
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
import { EmployeeSection } from './sections/EmployeeSection';
import { EquipmentSection } from './sections/EquipmentSection';
import { useStableCallback } from '../../shared/useStableCallback';
@@ -75,6 +79,8 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
// Журнал правок согласующего — виден и здесь, когда заявку вернули на доработку.
const [approverChanges, setApproverChanges] = useState<IFieldChange[]>([]);
const submit = useRequestSubmit();
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
@@ -102,6 +108,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
.then(([loaded, atts]) => {
if (alive) {
setData(loaded.data);
setApproverChanges(loaded.approverChanges);
setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined);
if (loaded.code) notifyCode(loaded.code);
@@ -219,13 +226,9 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
(login) => approval.getJobTitle(login)
);
const isValid =
data.FullName.trim().length > 0 &&
data.NameLatin.trim().length > 0 &&
data.Position.trim().length > 0 &&
data.Companies.length > 0;
const isValid = hasRequiredFields(data);
const validate = (): string | undefined => {
if (!isValid) return 'Заполните обязательные поля: Ф.И.О., Фамилия и имя (латиница), Должность, Организация.';
if (!isValid) return REQUIRED_FIELDS_MESSAGE;
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
@@ -378,10 +381,13 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
)}
{submit.error && <Alert variant="err">{submit.error}</Alert>}
<FormModeProvider readOnly={readOnly}>
<EmployeeSection {...sectionProps} />
<EquipmentSection {...sectionProps} />
</FormModeProvider>
<FieldChangesBanner changes={approverChanges} />
<FieldChangesProvider changes={toChangeMap(approverChanges)}>
<FormModeProvider readOnly={readOnly}>
<EmployeeSection {...sectionProps} />
<EquipmentSection {...sectionProps} />
</FormModeProvider>
</FieldChangesProvider>
{!readOnly && (
<CollapseSection title="Вложения" icon="upload">
@@ -18,6 +18,7 @@
.reviewBar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-3;
margin-bottom: $sp-4;
@@ -8,12 +8,11 @@ import {
FormCard,
InitiatorLine,
FormModeProvider,
CollapseSection,
FormField,
AttachmentList,
PrintSplitButton,
OrgLetterhead,
Icon
Icon,
FieldChangesProvider,
FieldChangesBanner
} from '../../shared/ui';
import { navigate } from '../../shared/routing';
import {
@@ -26,9 +25,14 @@ import {
notifyActiveStep,
useRequestTypeId,
isEditableStatus,
APPROVAL_PHASE
APPROVAL_PHASE,
ApproverDecision
} from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { getAttachments } from '../../shared/attachments/attachments.service';
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
import { EditableAttachments } from '../../shared/attachments/EditableAttachments';
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
import { effectiveUserEmail } from '../../shared/impersonation';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
@@ -38,7 +42,10 @@ import {
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, organizationText } from './model';
import { loadWorkplaceRequest } from './load';
import { workplaceApprovalService, updateReviewFields } from './submit';
import { workplaceApprovalService, updateReviewFields, updateAllWorkplaceFields } from './submit';
import { WORKPLACE_CHANGE_TRACKING } from './changeTracking';
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
import { useApproverRights } from './useApproverRights';
import { EmployeeSection } from './sections/EmployeeSection';
import { EquipmentSection } from './sections/EquipmentSection';
import { useStableCallback } from '../../shared/useStableCallback';
@@ -56,9 +63,14 @@ interface IWorkplaceSetupViewProps {
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
const REVIEW_FIELDS_HINT = 'Как согласующий вы можете заполнить «Необходимое ПО» и «Доступы».';
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
// Заявка «орг. рабочего места» только для просмотра (режим согласующего).
// Исключение: первый согласующий шаг (по маршруту — непосредственный руководитель)
// на своём этапе может заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает).
// Исключения (оба — только на своём активном шаге, см. useApproverRights):
// - первый согласующий шаг (по маршруту — непосредственный руководитель) может
// заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает);
// - согласующий из shared/approval/fullEditRights.ts правит все поля и вложения.
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
@@ -66,13 +78,17 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
// при каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA);
// То, что сейчас лежит в заявке на сервере (до несохранённых правок), — с ним
// сравниваем при сохранении, чтобы записать в журнал только реальные правки.
const [savedData, setSavedData] = useState<IFormData>(EMPTY_DATA);
// Журнал правок согласующего с полным правом — подсветка полей и «Было: …».
const [approverChanges, setApproverChanges] = useState<IFieldChange[]>([]);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [canEditFields, setCanEditFields] = useState<boolean>(false);
const [saveState, setSaveState] = useState<SaveState>('idle');
const [saveErrorMessage, setSaveErrorMessage] = useState<string>('');
// Какая из организаций заявителя (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintSplitButton, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
@@ -85,10 +101,11 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(
() => new ApproverService(workplaceApprovalService(listId), dataListRef),
[listId, dataListRef]
);
const approval = useMemo(() => workplaceApprovalService(listId), [listId]);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
const attachments = useAttachmentEditor(dataListRef, itemId);
// Сеттер стабилен (useState) — безопасно класть в зависимости эффекта загрузки.
const { setItems: setAttachmentItems } = attachments;
// Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам.
//
@@ -98,7 +115,6 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
// перечитывает матрицу. Иначе на экране остались бы данные, загруженные при
// открытии формы, и пользователь видел бы свой шаг всё ещё «На согласовании».
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const approval = useMemo(() => workplaceApprovalService(listId), [listId]);
const loadApprovers = useCallback(
() =>
approval
@@ -120,17 +136,28 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
approval.getJobTitle(l)
);
const { canEditReviewFields, canEditAll } = useApproverRights(
context,
approval,
approverService,
itemId,
status,
decisionSeq
);
useEffect(() => {
let alive = true;
setLoading(true);
Promise.all([loadWorkplaceRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)])
Promise.all([loadWorkplaceRequest(itemId, listId), getAttachments(dataListRef, itemId)])
.then(([loaded, files]) => {
if (alive) {
setData(loaded.data);
setSavedData(loaded.data);
setApproverChanges(loaded.approverChanges);
setStatus(loaded.status);
setCode(loaded.code);
notifyCode(loaded.code);
setAttachments(files);
setAttachmentItems(files);
setError(null);
setLoading(false);
}
@@ -144,47 +171,45 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
return () => {
alive = false;
};
}, [itemId, listId, notifyCode]);
}, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]);
// Правка «ПО»/«Доступы» доступна, когда сейчас очередь ПЕРВОГО шага согласования
// (по маршруту — непосредственного руководителя) и это текущий пользователь.
useEffect(() => {
let alive = true;
Promise.all([
approval.getForRequest(itemId),
approverService.getPendingRowForCurrentUser(itemId)
])
.then(([rows, pending]) => {
if (!alive) return;
const orders = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution).map((r) => r.order);
const firstOrder = orders.length > 0 ? Math.min(...orders) : undefined;
setCanEditFields(
!!pending && pending.phase !== APPROVAL_PHASE.execution && pending.order === firstOrder
);
})
.catch(() => {
if (alive) setCanEditFields(false);
});
return () => {
alive = false;
};
}, [itemId, approval, approverService]);
const saveAllFields = async (): Promise<void> => {
const editor = { name: context.pageContext.user.displayName, email: effectiveUserEmail(context) };
const nextChanges = mergeFieldChanges(approverChanges, savedData, data, WORKPLACE_CHANGE_TRACKING, editor, new Date());
await updateAllWorkplaceFields(itemId, data, nextChanges, listId);
setApproverChanges(nextChanges);
setSavedData(data);
await attachments.uploadPending();
};
const saveReviewFields = useCallback(
() => updateReviewFields(itemId, { Software: data.Software, AccessRights: data.AccessRights }, listId),
[itemId, data.Software, data.AccessRights, listId]
);
const saveReviewFields = (): Promise<void> =>
updateReviewFields(itemId, { Software: data.Software, AccessRights: data.AccessRights }, listId);
// Полное право шире: оно включает и «ПО»/«Доступы».
const saveFields = (): Promise<void> => (canEditAll ? saveAllFields() : saveReviewFields());
const handleSave = async (): Promise<void> => {
setSaveState('saving');
setSaveErrorMessage('');
try {
await saveReviewFields();
await saveFields();
setSaveState('saved');
} catch {
} catch (e) {
setSaveErrorMessage(friendlyErrorMessage(e));
setSaveState('error');
}
};
// Правки сохраняются вместе с решением — иначе решение ушло бы со старыми
// данными. Согласовать с очищенным обязательным полем нельзя; вернуть/
// отклонить — можно (бросок отменяет решение, текст показывает DecisionPanel).
const handleBeforeDecide = async (decision: ApproverDecision): Promise<void> => {
if (canEditAll && decision === 'approve' && !hasRequiredFields(data)) {
throw new Error(REQUIRED_FIELDS_MESSAGE);
}
await saveFields();
};
if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>;
}
@@ -197,6 +222,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
}
const sectionProps = { data, set, context, printOrgIndex };
const canEditAnything = canEditAll || canEditReviewFields;
const notify = (): void => {
if (!requestTypeId) return;
@@ -222,16 +248,16 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
<PrintSplitButton value={organizationText(data)} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
</div>
{canEditFields && (
{canEditAnything && (
<div className={`${styles.reviewBar} rp-no-print`}>
<span className={styles.reviewHint}>
Как согласующий вы можете заполнить «Необходимое ПО» и «Доступы».
</span>
<span className={styles.reviewHint}>{canEditAll ? FULL_EDIT_HINT : REVIEW_FIELDS_HINT}</span>
<Button variant="secondary" onClick={handleSave} disabled={saveState === 'saving'}>
Сохранить
</Button>
{saveState === 'saved' && <span className={styles.savedNote}>Сохранено</span>}
{saveState === 'error' && <span className={styles.saveError}>Ошибка сохранения</span>}
{saveState === 'error' && (
<span className={styles.saveError}>{saveErrorMessage || 'Ошибка сохранения'}</span>
)}
</div>
)}
@@ -248,17 +274,16 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
}
headerRight={status ? <StatusBadge status={status} /> : undefined}
>
<FormModeProvider readOnly>
<EmployeeSection {...sectionProps} />
<EquipmentSection {...sectionProps} approverEditable={canEditFields} />
</FormModeProvider>
<FieldChangesBanner changes={approverChanges} />
<FieldChangesProvider changes={toChangeMap(approverChanges)}>
<FormModeProvider readOnly={!canEditAll}>
<EmployeeSection {...sectionProps} />
<EquipmentSection {...sectionProps} approverEditable={canEditReviewFields} />
</FormModeProvider>
</FieldChangesProvider>
<div className="rp-no-print">
<CollapseSection title="Вложения" icon="upload">
<FormField label="Файлы" full>
<AttachmentList items={attachments} />
</FormField>
</CollapseSection>
<EditableAttachments editor={attachments} editable={canEditAll} />
</div>
</FormCard>
@@ -286,7 +311,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
service={approverService}
requestId={itemId}
search={(q) => approval.searchUsers(q)}
onBeforeDecide={canEditFields ? saveReviewFields : undefined}
onBeforeDecide={canEditAnything ? handleBeforeDecide : undefined}
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
@@ -0,0 +1,14 @@
import { IChangeTrackingConfig } from '../../shared/fieldChanges/fieldChanges';
import { IFormData } from './model';
/** Колонка журнала правок согласующего (Note, JSON) — см. provisioning.json. */
export const APPROVER_CHANGES_FIELD = 'WorkplaceApproverChangesJson';
/**
* Что пишем в журнал правок согласующего (shared/fieldChanges). Id отдела и
* проекта своего поля не имеют — меняются вместе с «Подразделение» и «Проект»;
* Organization — текстовая копия Companies (см. model.ts), правка видна по Companies.
*/
export const WORKPLACE_CHANGE_TRACKING: IChangeTrackingConfig<IFormData> = {
ignore: ['DepartmentId', 'ProjectId', 'Organization']
};
@@ -2,7 +2,9 @@ import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles';
import { spDateOnlyToIso } from '../../shared/dateUtils';
import { ICompany } from '../../shared/dictionary';
import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges';
import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, EMPTY_DATA } from './model';
import { APPROVER_CHANGES_FIELD } from './changeTracking';
/**
* JSON выбранных компаний → массив. Пусто — заявка до мультивыбора (тогда
@@ -28,6 +30,8 @@ export interface ILoadedRequest {
status: string;
/** Уникальный код заявки; '' — если запись создана до появления поля. */
code: string;
/** Журнал правок согласующего (подсветка полей) — см. shared/fieldChanges. */
approverChanges: IFieldChange[];
}
/**
@@ -42,7 +46,14 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list
.items.getById(itemId)
.select(...SCALAR_FIELDS, 'NeedsEquipment', COMPANIES_JSON_FIELD, 'RequestStatus', 'RequestUniqueCode')()
.select(
...SCALAR_FIELDS,
'NeedsEquipment',
COMPANIES_JSON_FIELD,
APPROVER_CHANGES_FIELD,
'RequestStatus',
'RequestUniqueCode'
)()
) as Record<string, unknown>;
const data: IFormData = { ...EMPTY_DATA };
@@ -62,5 +73,10 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro
data.NeedsEquipment = item.NeedsEquipment === true;
data.Companies = parseCompanies(item[COMPANIES_JSON_FIELD]);
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])
};
}
@@ -21,6 +21,7 @@
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
{ "internalName": "Software", "title": "Необходимое ПО", "type": "Note" },
{ "internalName": "AccessRights", "title": "Доступы", "type": "Note" },
{ "internalName": "WorkplaceApproverChangesJson", "title": "Изменения согласующего — рабочее место (JSON)", "type": "Note" },
{ "internalName": "SourceHire", "title": "Источник (заявка на прием)", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" }
]
@@ -15,7 +15,7 @@ export const EquipmentSection: React.FC<IEquipmentSectionProps> = ({ data, set,
return (
<CollapseSection title="Оборудование и доступы" icon="monitor">
<FormField label="Нужна ли техника">
<FormField label="Нужна ли техника" fieldKey="NeedsEquipment">
<Toggle checked={data.NeedsEquipment} onChange={(v) => set('NeedsEquipment', v)} onLabel="Да" />
</FormField>
<FormField label="Необходимое ПО" htmlFor="Software" full>
@@ -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 { COMPANIES_JSON_FIELD, FORM_ID, IFormData, organizationText } from './model';
import { APPROVER_CHANGES_FIELD } from './changeTracking';
/**
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
@@ -30,14 +32,31 @@ export async function updateReviewFields(
});
}
/**
* Записать все поля заявки, которые поправил согласующий с полным правом
* (см. shared/approval/fullEditRights.ts), — вместе с журналом его правок
* (подсветка полей, см. shared/fieldChanges). Статус, маршрут и вложения не
* трогаются: заявка остаётся на том же шаге согласования.
*/
export async function updateAllWorkplaceFields(
id: number,
data: IFormData,
approverChanges: IFieldChange[],
listId?: string
): Promise<void> {
const item = buildContentFields(data);
item[APPROVER_CHANGES_FIELD] = serializeFieldChanges(approverChanges);
await resolveDataList(FORM_ID, listId).items.getById(id).update(item);
}
/** Осмысленный заголовок элемента (системная колонка Title). */
function buildTitle(data: IFormData): string {
return ['Раб. место', data.FullName, data.Position].filter(Boolean).join(' — ');
}
/** Поля элемента списка данных из модели формы. */
function buildItem(data: IFormData, status: string, sourceHireId?: number): Record<string, unknown> {
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
/** Содержательные поля элемента списка данных из модели формы (без статуса и источника). */
function buildContentFields(data: IFormData): Record<string, unknown> {
const item: Record<string, unknown> = { Title: buildTitle(data) };
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
if (key === 'Companies') {
@@ -58,6 +77,14 @@ function buildItem(data: IFormData, status: string, sourceHireId?: number): Reco
}
});
return item;
}
/** Поля элемента списка данных при сохранении заявки целиком. */
function buildItem(data: IFormData, status: string, sourceHireId?: number): Record<string, unknown> {
const item = buildContentFields(data);
item.RequestStatus = status;
if (sourceHireId) {
item.SourceHireId = sourceHireId; // lookup → «Прием на работу — данные»
}
@@ -0,0 +1,79 @@
import { useEffect, useState } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import {
ApprovalListService,
ApproverService,
APPROVAL_PHASE,
REQUEST_STATUS,
canEditAllFields
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { FORM_ID } from './model';
export interface IApproverRights {
/** Первый шаг согласования (руководитель) может заполнить «Необходимое ПО» и «Доступы». */
canEditReviewFields: boolean;
/** Участник маршрута из shared/approval/fullEditRights.ts правит все поля и вложения. */
canEditAll: boolean;
}
const NO_RIGHTS: IApproverRights = { canEditReviewFields: false, canEditAll: false };
/**
* Статусы заявки, в которых работает полное право. Закрытую/отклонённую
* задним числом не правим, а на доработке заявку правит автор — правки
* перезаписывали бы друг друга.
*/
const FULL_EDIT_STATUSES: ReadonlyArray<string> = [REQUEST_STATUS.onApproval, REQUEST_STATUS.execution];
/**
* Что текущий пользователь может править в поданной заявке.
* - «ПО»/«Доступы» — только на СВОЁМ активном шаге согласования, если это первый шаг.
* - Все поля — если пользователь есть в маршруте (согласование или исполнение)
* НЕЗАВИСИМО от того, чья сейчас очередь, пока заявка в работе (решение
* Тимура 2026-10-07: так в «Организации рабочего места» работает Ербол).
* reloadKey — после решения/делегирования маршрут меняется, права пересчитываются.
*/
export function useApproverRights(
context: WebPartContext,
approval: ApprovalListService,
approverService: ApproverService,
itemId: number,
requestStatus: string,
reloadKey: number
): IApproverRights {
const [rights, setRights] = useState<IApproverRights>(NO_RIGHTS);
useEffect(() => {
let alive = true;
Promise.all([
approval.getForRequest(itemId),
approverService.getPendingRowForCurrentUser(itemId),
approval.getCurrentUserId()
])
.then(([rows, pending, userId]) => {
if (!alive) return;
const approvalOrders = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution).map((r) => r.order);
const firstOrder = approvalOrders.length > 0 ? Math.min(...approvalOrders) : undefined;
const isFirstApprovalStep =
!!pending && pending.phase !== APPROVAL_PHASE.execution && pending.order === firstOrder;
const isInRoute = rows.some((r) => r.approverId === userId);
setRights({
canEditReviewFields: isFirstApprovalStep,
canEditAll:
isInRoute &&
FULL_EDIT_STATUSES.indexOf(requestStatus) !== -1 &&
canEditAllFields(FORM_ID, effectiveUserEmail(context))
});
})
.catch(() => {
if (alive) setRights(NO_RIGHTS);
});
return () => {
alive = false;
};
}, [context, approval, approverService, itemId, requestStatus, reloadKey]);
return rights;
}
@@ -0,0 +1,14 @@
import { IFormData } from './model';
/** Заполнены ли обязательные поля заявки. */
export function hasRequiredFields(data: IFormData): boolean {
return (
data.FullName.trim().length > 0 &&
data.NameLatin.trim().length > 0 &&
data.Position.trim().length > 0 &&
data.Companies.length > 0
);
}
export const REQUIRED_FIELDS_MESSAGE =
'Заполните обязательные поля: Ф.И.О., Фамилия и имя (латиница), Должность, Организация.';
@@ -1,21 +1,25 @@
import { RequestTypeId } from '../../models/types';
/**
* Кто на СВОЁМ АКТИВНОМ шаге согласования может править ВСЕ поля заявки
* (а не только отдельные, как расчётная группа в RecruitingHire).
*
* Пока фиксированный email одного человека (Алтынай Бултурукова, начальник
* отдела подбора и адаптации персонала) — группы SharePoint для этого ещё
* нет, как и в routeEditRights.ts. ВРЕМЕННО.
* Пока фиксированные email по формам — групп SharePoint для этого ещё нет,
* как и в routeEditRights.ts. ВРЕМЕННО. Список свой у каждой формы: право в
* одной форме не должно молча открывать правку в другой.
*
* Подключено только в «Заявке на подбор» и «Заявке на прием» (Реферальная
* рекомендация — нет, по решению Тимура 2026-09-24). С 2026-10-06 право
* открывает и вложения: добавлять и удалять файлы (см. shared/attachments/
* useAttachmentEditor).
* Реферальная рекомендация — без права (решение Тимура 2026-09-24). С
* 2026-10-06 право открывает и вложения: добавлять и удалять файлы (см.
* shared/attachments/useAttachmentEditor).
*/
const FULL_FIELD_EDITOR_EMAILS: ReadonlySet<string> = new Set([
'a.bulturukova@tnsu.kz' // Алтынай Бултурукова
]);
const FULL_FIELD_EDITORS: Partial<Record<RequestTypeId, ReadonlyArray<string>>> = {
'recruiting-selection': ['a.bulturukova@tnsu.kz'], // Алтынай Бултурукова
'recruiting-hire': ['a.bulturukova@tnsu.kz'], // Алтынай Бултурукова
'it-workplace-setup': ['shaimerdenov.yerbol@tnsu.kz'] // Ербол Шаймерденов
};
/** Может ли этот email править все поля заявки на своём активном шаге (см. выше). */
export function canEditAllFields(email: string | undefined): boolean {
return !!email && FULL_FIELD_EDITOR_EMAILS.has(email.trim().toLowerCase());
/** Может ли этот email править все поля заявки этой формы на своём активном шаге (см. выше). */
export function canEditAllFields(formId: RequestTypeId, email: string | undefined): boolean {
const editors = FULL_FIELD_EDITORS[formId];
return !!email && !!editors && editors.indexOf(email.trim().toLowerCase()) !== -1;
}
@@ -1,9 +1,9 @@
import * as React from 'react';
import { Alert, AttachmentList, CollapseSection, FileDropzone, FormField } from '../../shared/ui';
import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
import { Alert, AttachmentList, CollapseSection, FileDropzone, FormField } from '../ui';
import { IAttachmentEditor } from './useAttachmentEditor';
/** Единственная группа новых файлов в этой форме (категорий нет). */
export const SELECTION_FILES_GROUP = 'files';
/** Единственная группа новых файлов (категорий нет — в отличие от заявки на приём). */
export const ATTACHMENTS_FILES_GROUP = 'files';
interface Props {
editor: IAttachmentEditor;
@@ -11,9 +11,12 @@ interface Props {
editable: boolean;
}
/** Блок «Вложения» в просмотре заявки на подбор. */
export const SelectionAttachments: React.FC<Props> = ({ editor, editable }) => {
const newFiles = editor.pending[SELECTION_FILES_GROUP] || [];
/**
* Блок «Вложения» в просмотре заявки без категорий файлов: список, а при
* полном праве правки (shared/approval/fullEditRights.ts) — удаление и загрузка.
*/
export const EditableAttachments: React.FC<Props> = ({ editor, editable }) => {
const newFiles = editor.pending[ATTACHMENTS_FILES_GROUP] || [];
return (
<CollapseSection title="Вложения" icon="upload">
@@ -23,7 +26,7 @@ export const SelectionAttachments: React.FC<Props> = ({ editor, editable }) => {
</FormField>
{editable && (
<FormField label="Добавить файлы" full>
<FileDropzone files={newFiles} onChange={(files) => editor.setPending(SELECTION_FILES_GROUP, files)} />
<FileDropzone files={newFiles} onChange={(files) => editor.setPending(ATTACHMENTS_FILES_GROUP, files)} />
</FormField>
)}
</CollapseSection>