права на редактирование определенных полей
This commit is contained in:
@@ -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 — строка не делегирована
|
||||
* (пришла из маршрута напрямую). Позволяет делегировавшему переназначить шаг ещё раз,
|
||||
|
||||
Reference in New Issue
Block a user