права на редактирование определенных полей

This commit is contained in:
gorbenko.pavel
2026-08-14 12:26:46 +05:00
parent fa551caf5e
commit 42fe721b13
8 changed files with 254 additions and 13 deletions
@@ -70,6 +70,17 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({ requestType, context, onCa
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId]);
// Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай:
// обходной лист оформляют в день увольнения). При открытии на редактирование
// уже сохранённую дату не перезатираем.
useEffect(() => {
if (editId) {
return;
}
const today = new Date().toISOString().slice(0, 10);
setData((prev) => ({ ...prev, TerminationDate: prev.TerminationDate || today }));
}, [editId]);
// Должность/подразделение больше не вводятся вручную — подтягиваются из
// SharePoint User Profile (SPS-JobTitle/SPS-Department) при выборе сотрудника.
// Если профиль не синхронизирован и поле там пусто — пока просто остаётся
@@ -12,3 +12,35 @@
justify-content: flex-end;
margin-bottom: $sp-4;
}
// Полоса «вы можете заполнить свои отметки» для профильного согласующего
// (КДП / отдел подбора) — см. approverRights.ts.
.reviewBar {
display: flex;
align-items: center;
gap: $sp-3;
margin-bottom: $sp-4;
padding: $sp-3 $sp-4;
border: 1px solid $gold-300;
border-radius: $r-md;
background: $gold-50;
}
.reviewHint {
flex: 1;
min-width: 0;
font-size: $fs-sm;
color: $navy-800;
}
.savedNote {
font-size: $fs-sm;
color: $green-600;
font-weight: 600;
}
.saveError {
font-size: $fs-sm;
color: $red-500;
font-weight: 600;
}
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './ClearanceView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui';
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui';
import {
ApproverService,
DecisionPanel,
@@ -10,11 +10,19 @@ import {
ApprovalMatrix,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId
useRequestTypeId,
APPROVAL_PHASE
} from '../../shared/approval';
import { IFormData, EMPTY_DATA, SetField } from './model';
import { loadClearanceRequest } from './load';
import { clearanceApprovalService, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit';
import {
clearanceApprovalService,
updateApproverFields,
IApproverEditableFields,
DATA_LIST_TITLE,
APPROVAL_LIST_TITLE
} from './submit';
import { canEditHrRecordsFields, canEditParkingField } from './approverRights';
import { RequestSection } from './sections/RequestSection';
interface IClearanceViewProps {
@@ -24,18 +32,22 @@ interface IClearanceViewProps {
formKey: string;
}
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined;
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
// Обходной лист только для просмотра (режим согласующего). Переиспользует ту
// же секцию, что и форма подачи; редактирование выключено через
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
// Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые
// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения,
// отдел подбора — парковочная карта (см. approverRights.ts).
const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [hrRecordsEditable, setHrRecordsEditable] = useState<boolean>(false);
const [parkingEditable, setParkingEditable] = useState<boolean>(false);
const [saveState, setSaveState] = useState<SaveState>('idle');
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
const approval = useMemo(() => clearanceApprovalService(), []);
const { id: requestTypeId } = useRequestTypeId(context, formKey);
@@ -73,6 +85,55 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
};
}, [itemId]);
// Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя.
// isDelegator намеренно не даёт прав: делегировавший не решает за делегата
// (см. DecisionPanel), значит и отметки за него ставить не должен.
useEffect(() => {
let alive = true;
approverService
.getPendingRowForCurrentUser(itemId)
.then((pending) => {
if (!alive) return;
const active = !!pending && !pending.isDelegator && pending.phase !== APPROVAL_PHASE.execution;
setHrRecordsEditable(active && canEditHrRecordsFields(pending?.position));
setParkingEditable(active && canEditParkingField(pending?.position));
})
.catch(() => {
if (alive) {
setHrRecordsEditable(false);
setParkingEditable(false);
}
});
return () => {
alive = false;
};
// decisionSeq: после решения/делегирования активный шаг меняется — права пересчитываем.
}, [itemId, approverService, decisionSeq]);
// Пишем ТОЛЬКО те отметки, на которые у согласующего есть права: иначе он
// затёр бы чужие поля значениями, которые видел у себя на экране.
const saveApproverFields = useCallback((): Promise<void> => {
const fields: Partial<IApproverEditableFields> = {};
if (hrRecordsEditable) {
fields.AccessCard = data.AccessCard;
fields.TrainingAmount = data.TrainingAmount;
}
if (parkingEditable) {
fields.ParkingCard = data.ParkingCard;
}
return updateApproverFields(itemId, fields);
}, [itemId, hrRecordsEditable, parkingEditable, data.AccessCard, data.TrainingAmount, data.ParkingCard]);
const handleSaveFields = async (): Promise<void> => {
setSaveState('saving');
try {
await saveApproverFields();
setSaveState('saved');
} catch {
setSaveState('error');
}
};
if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>;
}
@@ -84,7 +145,8 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
);
}
const sectionProps = { data, set: NOOP_SET, context };
const canEditAnything = hrRecordsEditable || parkingEditable;
const sectionProps = { data, set, context, hrRecordsEditable, parkingEditable };
const notify = (): void => {
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
@@ -104,6 +166,21 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
<PrintButton />
</div>
{canEditAnything && (
<div className={`${styles.reviewBar} rp-no-print`}>
<span className={styles.reviewHint}>
{hrRecordsEditable
? 'Как согласующий вы можете отметить карту СКУД и указать сумму за обучение.'
: 'Как согласующий вы можете отметить парковочную карту.'}
</span>
<Button variant="secondary" onClick={handleSaveFields} disabled={saveState === 'saving'}>
Сохранить
</Button>
{saveState === 'saved' && <span className={styles.savedNote}>Сохранено</span>}
{saveState === 'error' && <span className={styles.saveError}>Ошибка сохранения</span>}
</div>
)}
<div className="rp-print-root">
<FormCard
title="Обходной лист"
@@ -125,6 +202,9 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
service={approverService}
requestId={itemId}
search={(q) => approval.searchUsers(q)}
// Отметки сохраняются вместе с решением — иначе согласующий, забывший
// нажать «Сохранить», согласовал бы заявку со старыми значениями.
onBeforeDecide={canEditAnything ? saveApproverFields : undefined}
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
@@ -0,0 +1,53 @@
/**
* Кто из согласующих какие поля обходного листа может править на своём шаге.
*
* Инициатор (HR) отправляет заявку с пустыми отметками — фактические данные знают
* профильные подразделения, каждое своё:
* - отдел кадрового делопроизводства — карта СКУД и сумма за обучение;
* - отдел подбора и адаптации персонала — парковочная карта.
*
* Должность берётся из строки согласования (колонка Title, см. IApprovalRow.position),
* то есть та, что подставил маршрут при планировании. Сравнение — по ключевым словам,
* а не по точной строке: формулировки в справочнике различаются («Начальник отдела…»,
* «Руководитель отдела…»), а тратить на это отдельную настройку в админке смысла нет.
*
* ВАЖНО про требование управленческого маркера (начальник/руководитель): без него
* права получили бы и рядовые сотрудники этих отделов («Специалист по направлению
* кадрового делопроизводства» в справочнике есть), оказавшиеся согласующими на любом
* другом шаге. Если понадобится отдать права и специалистам — убрать MANAGER_MARKERS
* из проверки.
*/
const MANAGER_MARKERS = ['начальник', 'руководитель'];
/** Отдел кадрового делопроизводства (КДП) — правит карту СКУД и сумму обучения. */
const HR_RECORDS_MARKERS = ['кадров', 'делопроизводств'];
/** Отдел подбора и адаптации персонала — правит парковочную карту. */
const RECRUITING_MARKERS = ['подбор', 'адаптац'];
function normalize(position: string | undefined): string {
return (position || '').toLowerCase();
}
function matchesAll(position: string, markers: string[]): boolean {
return markers.every((m) => position.indexOf(m) !== -1);
}
function isManagerOf(position: string | undefined, departmentMarkers: string[]): boolean {
const normalized = normalize(position);
if (!normalized) return false;
return (
MANAGER_MARKERS.some((m) => normalized.indexOf(m) !== -1) && matchesAll(normalized, departmentMarkers)
);
}
/** Карта СКУД и сумма обучения — начальник отдела кадрового делопроизводства. */
export function canEditHrRecordsFields(position: string | undefined): boolean {
return isManagerOf(position, HR_RECORDS_MARKERS);
}
/** Парковочная карта — начальник отдела подбора и адаптации персонала. */
export function canEditParkingField(position: string | undefined): boolean {
return isManagerOf(position, RECRUITING_MARKERS);
}
@@ -7,8 +7,22 @@ import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
// свободный ввод здесь намеренно убран, чтобы не расходиться с профилем.
const NOOP = (): void => undefined;
interface IRequestSectionProps extends ISectionProps {
/** Согласующий из КДП правит карту СКУД и сумму обучения даже в режиме просмотра. */
hrRecordsEditable?: boolean;
/** Согласующий из отдела подбора правит парковочную карту. */
parkingEditable?: boolean;
}
/** Единственная секция формы «Обходной лист»: увольняемый, должность, подразделение, даты, карты, обучение. */
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange }) => (
export const RequestSection: React.FC<IRequestSectionProps> = ({
data,
set,
context,
onEmployeeChange,
hrRecordsEditable,
parkingEditable
}) => (
<CollapseSection title="Обходной лист" icon="people">
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField
@@ -44,14 +58,31 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
onChange={(v) => set('TerminationDate', v)}
/>
</FormField>
{/* Явный readOnly={false} перебивает read-only контекст просмотра (профильный
согласующий правит своё поле); undefined — обычное поведение из FormMode. */}
<FormField label="Обучение: сумма" htmlFor="TrainingAmount">
<MoneyInput id="TrainingAmount" value={data.TrainingAmount} onChange={(v) => set('TrainingAmount', v)} />
<MoneyInput
id="TrainingAmount"
value={data.TrainingAmount}
onChange={(v) => set('TrainingAmount', v)}
readOnly={hrRecordsEditable ? false : undefined}
/>
</FormField>
<FormField label="Парковочная карта">
<Toggle checked={data.ParkingCard} onChange={(v) => set('ParkingCard', v)} onLabel="Да" />
<Toggle
checked={data.ParkingCard}
onChange={(v) => set('ParkingCard', v)}
onLabel="Да"
readOnly={parkingEditable ? false : undefined}
/>
</FormField>
<FormField label="Карта СКУД">
<Toggle checked={data.AccessCard} onChange={(v) => set('AccessCard', v)} onLabel="Да" />
<Toggle
checked={data.AccessCard}
onChange={(v) => set('AccessCard', v)}
onLabel="Да"
readOnly={hrRecordsEditable ? false : undefined}
/>
</FormField>
</CollapseSection>
);
@@ -11,6 +11,34 @@ export function clearanceApprovalService(): ApprovalListService {
return new ApprovalListService(APPROVAL_LIST_TITLE);
}
/** Отметки, которые проставляет не инициатор, а профильный согласующий (см. approverRights.ts). */
export interface IApproverEditableFields {
AccessCard: boolean;
TrainingAmount: string;
ParkingCard: boolean;
}
/**
* Обновить только те отметки, что заполняет согласующий на своём шаге.
* Пишутся ровно переданные поля: у каждого отдела свой набор прав, и лишние
* ключи затёрли бы чужие отметки значениями из формы этого согласующего.
*/
export async function updateApproverFields(
id: number,
fields: Partial<IApproverEditableFields>
): Promise<void> {
const item: Record<string, unknown> = {};
if (fields.AccessCard !== undefined) item.AccessCard = fields.AccessCard;
if (fields.ParkingCard !== undefined) item.ParkingCard = fields.ParkingCard;
if (fields.TrainingAmount !== undefined) {
const amount = Number(fields.TrainingAmount);
item.TrainingAmount =
Number.isFinite(amount) && fields.TrainingAmount.trim() !== '' ? amount : null;
}
if (Object.keys(item).length === 0) return;
await getSP().web.lists.getByTitle(DATA_LIST_TITLE).items.getById(id).update(item);
}
/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */
async function ensureEmployee(login: string): Promise<{ Id: number; Title: string }> {
const full = login.indexOf('|') >= 0 ? login : `i:0#.f|membership|${login}`;
@@ -205,6 +205,7 @@ export class ApprovalListService {
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
.select(
'Id',
'Title',
FIELDS.status,
FIELDS.order,
FIELDS.allowReassign,
@@ -224,6 +225,7 @@ export class ApprovalListService {
approverId: approver?.Id ?? 0,
order: (r[FIELDS.order] as number) ?? 0,
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
position: (r.Title as string) || '',
// Пустая фаза (старые строки без колонки) трактуется как «Согласование».
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
delegatedById: delegatedBy?.Id ?? 0,
@@ -5,6 +5,10 @@ export interface IApprovalRow {
approverId: number; // Id пользователя сайта в поле «Согласующий»
order: number; // колонка Order (очередь согласования)
allowReassign: boolean; // AllowReassignToOtherApprovers
/** Должность/роль шага — колонка Title строки (см. createSteps: `a.position || a.email`).
* Нужна формам, где ПРАВА согласующего зависят от его должности (напр. «Обходной
* лист»: КДП правит СКУД и сумму обучения, подбор — парковочную карту). */
position: string;
phase: string; // Phase: «Согласование» | «Исполнение» (см. APPROVAL_PHASE)
/** Id пользователя сайта, который делегировал ЭТУ строку (DelegatedBy). 0 — строка не делегирована
* (пришла из маршрута напрямую). Позволяет делегировавшему переназначить шаг ещё раз,