From e6203e7093a7a5b021e4713f35ccd6e2c1bb0b4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Mon, 3 Aug 2026 14:37:42 +0500 Subject: [PATCH] fix: changed resolve endpoint contract form get to post --- ROADMAP.md | 15 ++- .../components/RequestPortal.tsx | 18 ++- .../approve/ApproveFeature.module.scss | 14 ++ .../features/approve/ApproveFeature.tsx | 32 +++++ .../RecruitingHireView.module.scss | 8 ++ .../RecruitingHire/RecruitingHireView.tsx | 91 +++++++++++++ .../forms/RecruitingHire/load.ts | 47 +++++++ .../forms/RecruitingHire/submit.ts | 4 +- .../requestPortal/forms/viewRegistry.ts | 25 ++++ .../shared/approval/approvalRoute.service.ts | 22 +++- .../requestPortal/shared/approval/index.ts | 1 + .../approval/ui/DecisionPanel.module.scss | 33 +++++ .../shared/approval/ui/DecisionPanel.tsx | 120 ++++++++++++++++++ src/webparts/requestPortal/shared/routing.ts | 31 +++++ .../requestPortal/shared/ui/FormMode.tsx | 16 +++ .../shared/ui/PeopleField/PeopleField.tsx | 8 +- .../ui/PeopleField/PeopleFieldMulti.tsx | 9 +- .../requestPortal/shared/ui/Select/Select.tsx | 8 +- .../shared/ui/TextArea/TextArea.tsx | 8 +- .../shared/ui/TextInput/TextInput.tsx | 15 ++- .../requestPortal/shared/ui/Toggle/Toggle.tsx | 8 +- src/webparts/requestPortal/shared/ui/index.ts | 1 + 22 files changed, 512 insertions(+), 22 deletions(-) create mode 100644 src/webparts/requestPortal/features/approve/ApproveFeature.module.scss create mode 100644 src/webparts/requestPortal/features/approve/ApproveFeature.tsx create mode 100644 src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.module.scss create mode 100644 src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx create mode 100644 src/webparts/requestPortal/forms/RecruitingHire/load.ts create mode 100644 src/webparts/requestPortal/forms/viewRegistry.ts create mode 100644 src/webparts/requestPortal/shared/approval/ui/DecisionPanel.module.scss create mode 100644 src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx create mode 100644 src/webparts/requestPortal/shared/routing.ts create mode 100644 src/webparts/requestPortal/shared/ui/FormMode.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 8fa9dd0..1fc6fc3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -55,9 +55,18 @@ SharePoint; у части заявок есть **согласование** (м ### Фаза A — Закрыть согласование целиком Довести до конца одну сквозную вертикаль, чтобы механизм был полным. -- [ ] Экран «Мои согласования» (список заявок, ждущих моего решения) -- [ ] Режим просмотра заявки для согласующего (read-only форма + панель решения) -- [ ] Кнопки решения: Согласовать / Вернуть / Отклонить + комментарий + +Согласующий приходит **по ссылке из потока Power Automate** (не ищет заявку сам). +Контракт ссылки (портал — одностраничник, поэтому нужен и тип формы): +`…/СтраницаПортала.aspx?mode=approve&type=<форма>&id=` + +- [x] `readOnly` у примитивов формы +- [x] Скелет маршрутизации: `shared/routing.ts` (parseRoute) + ветка в оболочке → + `features/approve/ApproveFeature` (пока заглушка, читает type/id) +- [ ] Загрузка заявки по `id` из списка данных → форма в режиме чтения + (уточнить: `RowID` = id элемента списка ДАННЫХ?) +- [ ] Панель решения: Согласовать / Вернуть / Отклонить + комментарий + (логика — `approverDecision.service`) - [ ] (опц.) Делегирование - [ ] Проверка статусов end-to-end на «Заявке на прием» diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 75cc30f..6a43257 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -2,15 +2,17 @@ import * as React from 'react'; import styles from './RequestPortal.module.scss'; import type { IRequestPortalProps } from './IRequestPortalProps'; import { IconSprite } from '../shared/ui/Icon/IconSprite'; +import { parseRoute } from '../shared/routing'; import SubmitFeature from '../features/submit/SubmitFeature'; +import ApproveFeature from '../features/approve/ApproveFeature'; // Оболочка портала. Логики фич тут нет — файл общий, меняется редко. +// Роутинг по URL: mode=approve&type=&id= → фича согласования, иначе — подача. // -// Пока в UI только формы подачи (features/submit). Боковая панель «Мои заявки» -// (features/myRequests) готова как компонент, но НЕ смонтирована — её подключает -// второй разработчик, когда наполнит. Для этого: +// Панель «Мои заявки» (features/myRequests) готова как компонент, но НЕ смонтирована — +// её подключает второй разработчик, когда наполнит. Для этого: // 1) import MyRequestsPanel from '../features/myRequests/MyRequestsPanel'; -// 2) обернуть контент в
с двумя колонками: +// 2) обернуть контент подачи в
с двумя колонками: //
//
// (классы layout/main/side уже есть в RequestPortal.module.scss) @@ -19,10 +21,16 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context .filter(Boolean) .join(' '); + const route = parseRoute(); + return (
- + {route.mode === 'approve' && route.formKey && route.itemId ? ( + + ) : ( + + )}
); }; diff --git a/src/webparts/requestPortal/features/approve/ApproveFeature.module.scss b/src/webparts/requestPortal/features/approve/ApproveFeature.module.scss new file mode 100644 index 0000000..ffc5a1d --- /dev/null +++ b/src/webparts/requestPortal/features/approve/ApproveFeature.module.scss @@ -0,0 +1,14 @@ +@import '../../../../styles/design-tokens'; + +.content { + width: 100%; + max-width: 1080px; + margin: 0 auto; +} + +.state { + padding: $sp-8; + color: $gray-500; + font-size: $fs-sm; + text-align: center; +} diff --git a/src/webparts/requestPortal/features/approve/ApproveFeature.tsx b/src/webparts/requestPortal/features/approve/ApproveFeature.tsx new file mode 100644 index 0000000..6ca8103 --- /dev/null +++ b/src/webparts/requestPortal/features/approve/ApproveFeature.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import styles from './ApproveFeature.module.scss'; +import { getViewComponent } from '../../forms/viewRegistry'; + +export interface IApproveFeatureProps { + context: WebPartContext; + /** Ключ формы (тип заявки) из URL. */ + formKey: string; + /** RowID заявки из URL. */ + itemId: number; +} + +// Фича «Согласование»: открывается по ссылке из потока (mode=approve&type=&id=). +// Показывает заявку в режиме чтения; панель решения добавим следующим шагом. +const ApproveFeature: React.FC = ({ context, formKey, itemId }) => { + const ViewComponent = getViewComponent(formKey); + + return ( +
+ {ViewComponent ? ( + Загрузка…
}> + + + ) : ( +
Для формы «{formKey}» режим согласования пока не настроен.
+ )} +
+ ); +}; + +export default ApproveFeature; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.module.scss b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.module.scss new file mode 100644 index 0000000..d921f54 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.module.scss @@ -0,0 +1,8 @@ +@import '../../../../styles/design-tokens'; + +.state { + padding: $sp-8; + color: $gray-500; + font-size: $fs-sm; + text-align: center; +} diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx new file mode 100644 index 0000000..539e4b1 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -0,0 +1,91 @@ +import * as React from 'react'; +import { useState, useEffect, useMemo } from 'react'; +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import styles from './RecruitingHireView.module.scss'; +import { Alert, FormCard, FormModeProvider } from '../../shared/ui'; +import { ApproverService, DecisionPanel } from '../../shared/approval'; +import { IFormData, EMPTY_DATA, SetField } from './model'; +import { loadHireRequest } from './load'; +import { hireApprovalService, DATA_LIST_TITLE } from './submit'; +import { ApplicationSection } from './sections/ApplicationSection'; +import { CandidateSection } from './sections/CandidateSection'; +import { PpeSection } from './sections/PpeSection'; +import { ConditionsSection } from './sections/ConditionsSection'; +import { ResponsibleSection } from './sections/ResponsibleSection'; + +interface IRecruitingHireViewProps { + context: WebPartContext; + itemId: number; +} + +// В режиме чтения поля не редактируются, поэтому обновление — пустышка. +const NOOP_SET: SetField = () => undefined; + +// Заявка «на прием» только для просмотра (режим согласующего). +// Переиспользует те же секции, что и форма подачи; редактирование выключено +// через — поля сами становятся неизменяемыми. +const RecruitingHireView: React.FC = ({ context, itemId }) => { + const [data, setData] = useState(EMPTY_DATA); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Сервис решений согласующего: список согласования + список данных этой формы. + const approverService = useMemo( + () => new ApproverService(hireApprovalService(), DATA_LIST_TITLE), + [] + ); + + useEffect(() => { + let alive = true; + setLoading(true); + loadHireRequest(itemId) + .then((loaded) => { + if (alive) { + setData(loaded); + setError(null); + setLoading(false); + } + }) + .catch((e) => { + if (alive) { + setError(`Не удалось загрузить заявку №${itemId}: ${(e as Error).message}`); + setLoading(false); + } + }); + return () => { + alive = false; + }; + }, [itemId]); + + if (loading) { + return
Загрузка заявки…
; + } + if (error) { + return ( +
+ {error} +
+ ); + } + + const sectionProps = { data, set: NOOP_SET, context }; + + return ( +
+ + + + + + + + + + + {/* Панель решения — ВНЕ FormModeProvider, чтобы комментарий был редактируемым. */} + +
+ ); +}; + +export default RecruitingHireView; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/load.ts b/src/webparts/requestPortal/forms/RecruitingHire/load.ts new file mode 100644 index 0000000..d62ec56 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingHire/load.ts @@ -0,0 +1,47 @@ +import { getSP } from '../../shared/pnp/pnpjs'; +import { IFormData, EMPTY_DATA } from './model'; + +const LIST_TITLE = 'Прием на работу — данные'; + +// Скалярные поля (текст/выбор/число/дата) — читаем как есть, приводим к строке. +const SCALAR_FIELDS: (keyof IFormData)[] = [ + 'Phone', 'HireType', 'ReplacedEmployee', 'CandidateName', 'HireDate', 'Position', 'WorkKind', + 'SiteName', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'SalaryProbation', + 'SalaryAfterProbation', 'WorkSchedule', 'EmploymentForm' +]; + +/** Прочитать заявку «на прием» по id и разложить в модель формы (для режима чтения). */ +export async function loadHireRequest(itemId: number): Promise { + const sp = getSP(); + const item = (await sp.web.lists + .getByTitle(LIST_TITLE) + .items.getById(itemId) + .select( + ...SCALAR_FIELDS, + 'SiteManager/EMail', + 'SecuritySpecialist/EMail' + ) + .expand('SiteManager', 'SecuritySpecialist')()) as Record; + + const data: IFormData = { ...EMPTY_DATA }; + const writable = data as unknown as Record; + + SCALAR_FIELDS.forEach((key) => { + const value = item[key]; + if (value === null || value === undefined) { + writable[key] = ''; + } else if (key === 'HireDate' && typeof value === 'string') { + writable[key] = value.slice(0, 10); // ISO → YYYY-MM-DD для input type=date + } else { + writable[key] = String(value); + } + }); + + // Люди: из expand берём e-mail (по нему picker покажет ФИО в режиме чтения). + const manager = item.SiteManager as { EMail?: string } | undefined; + const security = item.SecuritySpecialist as { EMail?: string } | undefined; + data.SiteManager = manager?.EMail ?? ''; + data.SecuritySpecialist = security?.EMail ?? ''; + + return data; +} diff --git a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts index b5d352b..49a613a 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts @@ -2,7 +2,7 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { ApprovalListService, IApproverToWrite, writeApprovers } from '../../shared/approval'; import { IFormData } from './model'; -const LIST_TITLE = 'Прием на работу — данные'; +export const DATA_LIST_TITLE = 'Прием на работу — данные'; export const APPROVAL_LIST_TITLE = 'Прием на работу — согласование'; // Тип заявки «Заявка на прием» во внешнем approval-route API // (GET /api/request-types → id 5). Захардкожен, как «отпуск = 1» в pto-tracker. @@ -54,7 +54,7 @@ async function writeContent(data: IFormData, status: string): Promise { } } - const added = await getSP().web.lists.getByTitle(LIST_TITLE).items.add(item); + const added = await getSP().web.lists.getByTitle(DATA_LIST_TITLE).items.add(item); return (added as { Id: number }).Id; } diff --git a/src/webparts/requestPortal/forms/viewRegistry.ts b/src/webparts/requestPortal/forms/viewRegistry.ts new file mode 100644 index 0000000..212adc2 --- /dev/null +++ b/src/webparts/requestPortal/forms/viewRegistry.ts @@ -0,0 +1,25 @@ +import * as React from 'react'; +import type { WebPartContext } from '@microsoft/sp-webpart-base'; + +export interface IApprovalViewProps { + context: WebPartContext; + itemId: number; +} + +type ViewComponent = React.ComponentType; + +/** + * Реестр форм «только для чтения» (режим согласующего): ключ формы → ленивый просмотр. + * Добавить просмотр = создать <Форма>View.tsx + строка здесь. + */ +const viewRegistry: Record> = { + 'recruiting-hire': React.lazy( + () => import(/* webpackChunkName: 'view-recruiting-hire' */ './RecruitingHire/RecruitingHireView') + ) +}; + +export function getViewComponent( + formKey: string +): React.LazyExoticComponent | undefined { + return viewRegistry[formKey]; +} diff --git a/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts b/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts index 223c64d..1a9853f 100644 --- a/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts @@ -33,12 +33,22 @@ export async function resolveRoute( userEmail: string ): Promise { const c = await client(context); - const url = - `${APPROVAL_API_BASE_URL}/api/template-routes/resolve` + - `?RequestTypeId=${requestTypeId}` + - `&UserEmail=${encodeURIComponent(userEmail)}` + - `&Status=Approval`; - const response: HttpClientResponse = await c.get(url, AadHttpClient.configurations.v1); + const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`; + + // Эндпоинт сменился с GET (query) на POST (JSON-тело). + const response: HttpClientResponse = await c.post(url, AadHttpClient.configurations.v1, { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify({ + requestTypeId, + userEmail, + projectId: null, + status: 'Approval', + fieldValues: [] + }) + }); if (!response.ok) { throw new Error(`resolve: ${response.status} ${response.statusText}`); } diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index 877a155..13d2ba5 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -19,3 +19,4 @@ export { useMatrixEditor } from './useMatrixEditor'; export type { IUseMatrixEditor } from './useMatrixEditor'; export { ApprovalMatrix } from './ui/ApprovalMatrix'; export { UserPicker } from './ui/UserPicker'; +export { DecisionPanel } from './ui/DecisionPanel'; diff --git a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.module.scss b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.module.scss new file mode 100644 index 0000000..8144f76 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.module.scss @@ -0,0 +1,33 @@ +@import '../../../../../styles/design-tokens'; + +.panel { + margin-top: 16px; + padding: 24px; + background: $white; + border: 1px solid $gray-200; + border-radius: $r-lg; + box-shadow: $sh-1; +} + +.title { + font-family: $font-serif; + font-size: 16px; + font-weight: 600; + color: $navy-900; + margin-bottom: 14px; +} + +.actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 16px; +} + +.state { + margin-top: 16px; + padding: 24px; + color: $gray-500; + font-size: $fs-sm; + text-align: center; +} diff --git a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx new file mode 100644 index 0000000..568c679 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx @@ -0,0 +1,120 @@ +import * as React from 'react'; +import { useState, useEffect } from 'react'; +import styles from './DecisionPanel.module.scss'; +import { Button, Alert, FormField, TextArea, Icon } from '../../ui'; +import { ApproverService, ApproverDecision } from '../approverDecision.service'; + +interface IDecisionPanelProps { + service: ApproverService; + /** Id заявки (элемент списка данных). */ + requestId: number; + /** Уведомление после сохранения решения (например, обновить статус на экране). */ + onDecided?: (status: string) => void; +} + +// Панель решения согласующего: проверяет, его ли сейчас очередь, и если да — +// показывает комментарий и кнопки Согласовать / Вернуть / Отклонить. +export const DecisionPanel: React.FC = ({ service, requestId, onDecided }) => { + const [checking, setChecking] = useState(true); + const [rowId, setRowId] = useState(undefined); + const [comment, setComment] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [resultStatus, setResultStatus] = useState(undefined); + const [error, setError] = useState(null); + + useEffect(() => { + let alive = true; + setChecking(true); + service + .getPendingRowForCurrentUser(requestId) + .then((row) => { + if (alive) { + setRowId(row?.id); + setChecking(false); + } + }) + .catch((e) => { + if (alive) { + setError(`Не удалось проверить очередь согласования: ${(e as Error).message}`); + setChecking(false); + } + }); + return () => { + alive = false; + }; + }, [requestId, service]); + + async function decide(decision: ApproverDecision): Promise { + if (rowId === undefined) { + return; + } + if ((decision === 'return' || decision === 'reject') && comment.trim().length === 0) { + setError('Для возврата или отклонения укажите комментарий.'); + return; + } + setError(null); + setSubmitting(true); + try { + const status = await service.decide(requestId, rowId, decision, comment.trim()); + setResultStatus(status); + setSubmitting(false); + if (onDecided) { + onDecided(status); + } + } catch (e) { + setError(`Не удалось сохранить решение: ${(e as Error).message}`); + setSubmitting(false); + } + } + + if (checking) { + return
Проверяем вашу очередь на согласование…
; + } + + if (resultStatus) { + return ( +
+ Решение сохранено. Статус заявки: «{resultStatus}». +
+ ); + } + + if (rowId === undefined) { + return ( +
+ + Сейчас не ваша очередь на согласование, либо заявка уже обработана. + +
+ ); + } + + return ( +
+
Ваше решение
+ {error && {error}} + +