diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx index 13f5a13..d0a4ce8 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx @@ -70,6 +70,17 @@ const ClearanceForm: React.FC = ({ 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) при выборе сотрудника. // Если профиль не синхронизирован и поле там пусто — пока просто остаётся diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceView.module.scss b/src/webparts/requestPortal/forms/Clearance/ClearanceView.module.scss index 6a27763..78c0f57 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceView.module.scss +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceView.module.scss @@ -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; +} diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx index 22657de..bb50f22 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx @@ -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'; -// Обходной лист только для просмотра (режим согласующего). Переиспользует ту -// же секцию, что и форма подачи; редактирование выключено через -// — поля сами становятся неизменяемыми. +// Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые +// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения, +// отдел подбора — парковочная карта (см. approverRights.ts). const ClearanceView: React.FC = ({ context, itemId, formKey }) => { const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [hrRecordsEditable, setHrRecordsEditable] = useState(false); + const [parkingEditable, setParkingEditable] = useState(false); + const [saveState, setSaveState] = useState('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 = ({ 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 => { + const fields: Partial = {}; + 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 => { + setSaveState('saving'); + try { + await saveApproverFields(); + setSaveState('saved'); + } catch { + setSaveState('error'); + } + }; + if (loading) { return
Загрузка заявки…
; } @@ -84,7 +145,8 @@ const ClearanceView: React.FC = ({ 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 = ({ context, itemId, formKey + {canEditAnything && ( +
+ + {hrRecordsEditable + ? 'Как согласующий вы можете отметить карту СКУД и указать сумму за обучение.' + : 'Как согласующий вы можете отметить парковочную карту.'} + + + {saveState === 'saved' && Сохранено} + {saveState === 'error' && Ошибка сохранения} +
+ )} +
= ({ context, itemId, formKey service={approverService} requestId={itemId} search={(q) => approval.searchUsers(q)} + // Отметки сохраняются вместе с решением — иначе согласующий, забывший + // нажать «Сохранить», согласовал бы заявку со старыми значениями. + onBeforeDecide={canEditAnything ? saveApproverFields : undefined} onDecided={(newStatus) => { setStatus(newStatus); setDecisionSeq((n) => n + 1); diff --git a/src/webparts/requestPortal/forms/Clearance/approverRights.ts b/src/webparts/requestPortal/forms/Clearance/approverRights.ts new file mode 100644 index 0000000..52bcc91 --- /dev/null +++ b/src/webparts/requestPortal/forms/Clearance/approverRights.ts @@ -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); +} diff --git a/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx b/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx index 9e84cfe..28ae2d1 100644 --- a/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx +++ b/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx @@ -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 = ({ data, set, context, onEmployeeChange }) => ( +export const RequestSection: React.FC = ({ + data, + set, + context, + onEmployeeChange, + hrRecordsEditable, + parkingEditable +}) => ( = ({ data, set, context, on onChange={(v) => set('TerminationDate', v)} /> + {/* Явный readOnly={false} перебивает read-only контекст просмотра (профильный + согласующий правит своё поле); undefined — обычное поведение из FormMode. */} - set('TrainingAmount', v)} /> + set('TrainingAmount', v)} + readOnly={hrRecordsEditable ? false : undefined} + /> - set('ParkingCard', v)} onLabel="Да" /> + set('ParkingCard', v)} + onLabel="Да" + readOnly={parkingEditable ? false : undefined} + /> - set('AccessCard', v)} onLabel="Да" /> + set('AccessCard', v)} + onLabel="Да" + readOnly={hrRecordsEditable ? false : undefined} + /> ); diff --git a/src/webparts/requestPortal/forms/Clearance/submit.ts b/src/webparts/requestPortal/forms/Clearance/submit.ts index 8b0bca6..a8441ad 100644 --- a/src/webparts/requestPortal/forms/Clearance/submit.ts +++ b/src/webparts/requestPortal/forms/Clearance/submit.ts @@ -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 +): Promise { + const item: Record = {}; + 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}`; diff --git a/src/webparts/requestPortal/shared/approval/approvalList.service.ts b/src/webparts/requestPortal/shared/approval/approvalList.service.ts index aede07c..5668738 100644 --- a/src/webparts/requestPortal/shared/approval/approvalList.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalList.service.ts @@ -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, diff --git a/src/webparts/requestPortal/shared/approval/models/IApprovalRow.ts b/src/webparts/requestPortal/shared/approval/models/IApprovalRow.ts index 69d6d8a..6679460 100644 --- a/src/webparts/requestPortal/shared/approval/models/IApprovalRow.ts +++ b/src/webparts/requestPortal/shared/approval/models/IApprovalRow.ts @@ -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 — строка не делегирована * (пришла из маршрута напрямую). Позволяет делегировавшему переназначить шаг ещё раз,