fix: changed resolve endpoint contract form get to post
This commit is contained in:
+12
-3
@@ -55,9 +55,18 @@ SharePoint; у части заявок есть **согласование** (м
|
|||||||
|
|
||||||
### Фаза A — Закрыть согласование целиком
|
### Фаза A — Закрыть согласование целиком
|
||||||
Довести до конца одну сквозную вертикаль, чтобы механизм был полным.
|
Довести до конца одну сквозную вертикаль, чтобы механизм был полным.
|
||||||
- [ ] Экран «Мои согласования» (список заявок, ждущих моего решения)
|
|
||||||
- [ ] Режим просмотра заявки для согласующего (read-only форма + панель решения)
|
Согласующий приходит **по ссылке из потока Power Automate** (не ищет заявку сам).
|
||||||
- [ ] Кнопки решения: Согласовать / Вернуть / Отклонить + комментарий
|
Контракт ссылки (портал — одностраничник, поэтому нужен и тип формы):
|
||||||
|
`…/СтраницаПортала.aspx?mode=approve&type=<форма>&id=<RowID>`
|
||||||
|
|
||||||
|
- [x] `readOnly` у примитивов формы
|
||||||
|
- [x] Скелет маршрутизации: `shared/routing.ts` (parseRoute) + ветка в оболочке →
|
||||||
|
`features/approve/ApproveFeature` (пока заглушка, читает type/id)
|
||||||
|
- [ ] Загрузка заявки по `id` из списка данных → форма в режиме чтения
|
||||||
|
(уточнить: `RowID` = id элемента списка ДАННЫХ?)
|
||||||
|
- [ ] Панель решения: Согласовать / Вернуть / Отклонить + комментарий
|
||||||
|
(логика — `approverDecision.service`)
|
||||||
- [ ] (опц.) Делегирование
|
- [ ] (опц.) Делегирование
|
||||||
- [ ] Проверка статусов end-to-end на «Заявке на прием»
|
- [ ] Проверка статусов end-to-end на «Заявке на прием»
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ import * as React from 'react';
|
|||||||
import styles from './RequestPortal.module.scss';
|
import styles from './RequestPortal.module.scss';
|
||||||
import type { IRequestPortalProps } from './IRequestPortalProps';
|
import type { IRequestPortalProps } from './IRequestPortalProps';
|
||||||
import { IconSprite } from '../shared/ui/Icon/IconSprite';
|
import { IconSprite } from '../shared/ui/Icon/IconSprite';
|
||||||
|
import { parseRoute } from '../shared/routing';
|
||||||
import SubmitFeature from '../features/submit/SubmitFeature';
|
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';
|
// 1) import MyRequestsPanel from '../features/myRequests/MyRequestsPanel';
|
||||||
// 2) обернуть контент в <div className={styles.layout}> с двумя колонками:
|
// 2) обернуть контент подачи в <div className={styles.layout}> с двумя колонками:
|
||||||
// <main className={styles.main}><SubmitFeature .../></main>
|
// <main className={styles.main}><SubmitFeature .../></main>
|
||||||
// <div className={styles.side}><MyRequestsPanel context={context} /></div>
|
// <div className={styles.side}><MyRequestsPanel context={context} /></div>
|
||||||
// (классы layout/main/side уже есть в RequestPortal.module.scss)
|
// (классы layout/main/side уже есть в RequestPortal.module.scss)
|
||||||
@@ -19,10 +21,16 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
|
const route = parseRoute();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={rootClasses}>
|
<section className={rootClasses}>
|
||||||
<IconSprite />
|
<IconSprite />
|
||||||
<SubmitFeature context={context} />
|
{route.mode === 'approve' && route.formKey && route.itemId ? (
|
||||||
|
<ApproveFeature context={context} formKey={route.formKey} itemId={route.itemId} />
|
||||||
|
) : (
|
||||||
|
<SubmitFeature context={context} />
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<IApproveFeatureProps> = ({ context, formKey, itemId }) => {
|
||||||
|
const ViewComponent = getViewComponent(formKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.content}>
|
||||||
|
{ViewComponent ? (
|
||||||
|
<React.Suspense fallback={<div className={styles.state}>Загрузка…</div>}>
|
||||||
|
<ViewComponent context={context} itemId={itemId} />
|
||||||
|
</React.Suspense>
|
||||||
|
) : (
|
||||||
|
<div className={styles.state}>Для формы «{formKey}» режим согласования пока не настроен.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApproveFeature;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.state {
|
||||||
|
padding: $sp-8;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
// Заявка «на прием» только для просмотра (режим согласующего).
|
||||||
|
// Переиспользует те же секции, что и форма подачи; редактирование выключено
|
||||||
|
// через <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||||
|
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId }) => {
|
||||||
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={styles.state}>
|
||||||
|
<Alert variant="err">{error}</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionProps = { data, set: NOOP_SET, context };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FormCard title="Заявка на прием" subtitle={`№${itemId} · режим согласования`}>
|
||||||
|
<FormModeProvider readOnly>
|
||||||
|
<ApplicationSection {...sectionProps} />
|
||||||
|
<CandidateSection {...sectionProps} />
|
||||||
|
<PpeSection {...sectionProps} />
|
||||||
|
<ConditionsSection {...sectionProps} />
|
||||||
|
<ResponsibleSection {...sectionProps} />
|
||||||
|
</FormModeProvider>
|
||||||
|
</FormCard>
|
||||||
|
|
||||||
|
{/* Панель решения — ВНЕ FormModeProvider, чтобы комментарий был редактируемым. */}
|
||||||
|
<DecisionPanel service={approverService} requestId={itemId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecruitingHireView;
|
||||||
@@ -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<IFormData> {
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
const data: IFormData = { ...EMPTY_DATA };
|
||||||
|
const writable = data as unknown as Record<string, string>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { getSP } from '../../shared/pnp/pnpjs';
|
|||||||
import { ApprovalListService, IApproverToWrite, writeApprovers } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, writeApprovers } from '../../shared/approval';
|
||||||
import { IFormData } from './model';
|
import { IFormData } from './model';
|
||||||
|
|
||||||
const LIST_TITLE = 'Прием на работу — данные';
|
export const DATA_LIST_TITLE = 'Прием на работу — данные';
|
||||||
export const APPROVAL_LIST_TITLE = 'Прием на работу — согласование';
|
export const APPROVAL_LIST_TITLE = 'Прием на работу — согласование';
|
||||||
// Тип заявки «Заявка на прием» во внешнем approval-route API
|
// Тип заявки «Заявка на прием» во внешнем approval-route API
|
||||||
// (GET /api/request-types → id 5). Захардкожен, как «отпуск = 1» в pto-tracker.
|
// (GET /api/request-types → id 5). Захардкожен, как «отпуск = 1» в pto-tracker.
|
||||||
@@ -54,7 +54,7 @@ async function writeContent(data: IFormData, status: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
return (added as { Id: number }).Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<IApprovalViewProps>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реестр форм «только для чтения» (режим согласующего): ключ формы → ленивый просмотр.
|
||||||
|
* Добавить просмотр = создать <Форма>View.tsx + строка здесь.
|
||||||
|
*/
|
||||||
|
const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
|
||||||
|
'recruiting-hire': React.lazy(
|
||||||
|
() => import(/* webpackChunkName: 'view-recruiting-hire' */ './RecruitingHire/RecruitingHireView')
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getViewComponent(
|
||||||
|
formKey: string
|
||||||
|
): React.LazyExoticComponent<ViewComponent> | undefined {
|
||||||
|
return viewRegistry[formKey];
|
||||||
|
}
|
||||||
@@ -33,12 +33,22 @@ export async function resolveRoute(
|
|||||||
userEmail: string
|
userEmail: string
|
||||||
): Promise<IResolvedRoute> {
|
): Promise<IResolvedRoute> {
|
||||||
const c = await client(context);
|
const c = await client(context);
|
||||||
const url =
|
const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`;
|
||||||
`${APPROVAL_API_BASE_URL}/api/template-routes/resolve` +
|
|
||||||
`?RequestTypeId=${requestTypeId}` +
|
// Эндпоинт сменился с GET (query) на POST (JSON-тело).
|
||||||
`&UserEmail=${encodeURIComponent(userEmail)}` +
|
const response: HttpClientResponse = await c.post(url, AadHttpClient.configurations.v1, {
|
||||||
`&Status=Approval`;
|
headers: {
|
||||||
const response: HttpClientResponse = await c.get(url, AadHttpClient.configurations.v1);
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
requestTypeId,
|
||||||
|
userEmail,
|
||||||
|
projectId: null,
|
||||||
|
status: 'Approval',
|
||||||
|
fieldValues: []
|
||||||
|
})
|
||||||
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`resolve: ${response.status} ${response.statusText}`);
|
throw new Error(`resolve: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ export { useMatrixEditor } from './useMatrixEditor';
|
|||||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||||
export { UserPicker } from './ui/UserPicker';
|
export { UserPicker } from './ui/UserPicker';
|
||||||
|
export { DecisionPanel } from './ui/DecisionPanel';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<IDecisionPanelProps> = ({ service, requestId, onDecided }) => {
|
||||||
|
const [checking, setChecking] = useState<boolean>(true);
|
||||||
|
const [rowId, setRowId] = useState<number | undefined>(undefined);
|
||||||
|
const [comment, setComment] = useState<string>('');
|
||||||
|
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||||
|
const [resultStatus, setResultStatus] = useState<string | undefined>(undefined);
|
||||||
|
const [error, setError] = useState<string | null>(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<void> {
|
||||||
|
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 <div className={styles.state}>Проверяем вашу очередь на согласование…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultStatus) {
|
||||||
|
return (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<Alert variant="ok">Решение сохранено. Статус заявки: «{resultStatus}».</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowId === undefined) {
|
||||||
|
return (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<Alert variant="warn">
|
||||||
|
Сейчас не ваша очередь на согласование, либо заявка уже обработана.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<div className={styles.title}>Ваше решение</div>
|
||||||
|
{error && <Alert variant="err">{error}</Alert>}
|
||||||
|
<FormField label="Комментарий" htmlFor="decisionComment">
|
||||||
|
<TextArea
|
||||||
|
id="decisionComment"
|
||||||
|
value={comment}
|
||||||
|
onChange={setComment}
|
||||||
|
placeholder="Комментарий (обязателен при возврате или отклонении)"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button variant="accent" disabled={submitting} onClick={() => decide('approve')}>
|
||||||
|
<Icon name="check" size="sm" />
|
||||||
|
Согласовать
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" disabled={submitting} onClick={() => decide('return')}>
|
||||||
|
<Icon name="arrow-left" size="sm" />
|
||||||
|
Вернуть
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" disabled={submitting} onClick={() => decide('reject')}>
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
Отклонить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Маршрутизация портала по параметрам URL.
|
||||||
|
//
|
||||||
|
// Обычный режим (нет параметров) — подача заявок (features/submit).
|
||||||
|
// Режим согласующего — переход по ссылке из потока Power Automate:
|
||||||
|
// …/СтраницаПортала.aspx?mode=approve&type=<форма>&id=<RowID>
|
||||||
|
// где type = ключ формы в реестре (forms/registry), id = RowID заявки в её списке данных.
|
||||||
|
|
||||||
|
export type PortalMode = 'submit' | 'approve';
|
||||||
|
|
||||||
|
export interface IRoute {
|
||||||
|
mode: PortalMode;
|
||||||
|
/** Ключ формы (тип заявки) — только для mode='approve'. */
|
||||||
|
formKey?: string;
|
||||||
|
/** Id заявки в списке данных — только для mode='approve'. */
|
||||||
|
itemId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */
|
||||||
|
export function parseRoute(search: string = window.location.search): IRoute {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const mode = params.get('mode');
|
||||||
|
const formKey = params.get('type') ?? undefined;
|
||||||
|
const idRaw = params.get('id');
|
||||||
|
const itemId = idRaw ? Number(idRaw) : undefined;
|
||||||
|
|
||||||
|
// Режим согласующего валиден только при полном наборе параметров.
|
||||||
|
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
|
||||||
|
return { mode: 'approve', formKey, itemId };
|
||||||
|
}
|
||||||
|
return { mode: 'submit' };
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
// Режим формы «только чтение». Проставляется один раз оборачиванием
|
||||||
|
// (<FormModeProvider readOnly>...), а поля-примитивы сами его подхватывают —
|
||||||
|
// не нужно тащить readOnly через каждую секцию и каждое поле.
|
||||||
|
const FormModeContext = React.createContext<boolean>(false);
|
||||||
|
|
||||||
|
export const FormModeProvider: React.FC<{ readOnly: boolean; children: React.ReactNode }> = ({
|
||||||
|
readOnly,
|
||||||
|
children
|
||||||
|
}) => <FormModeContext.Provider value={readOnly}>{children}</FormModeContext.Provider>;
|
||||||
|
|
||||||
|
/** true, если форма отрисовывается в режиме «только чтение». */
|
||||||
|
export function useFormReadOnly(): boolean {
|
||||||
|
return React.useContext(FormModeContext);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
|||||||
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
|
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
|
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
|
||||||
|
import { useFormReadOnly } from '../FormMode';
|
||||||
|
|
||||||
interface IPeopleFieldProps {
|
interface IPeopleFieldProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -9,10 +10,14 @@ interface IPeopleFieldProps {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (login: string) => void;
|
onChange: (login: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** Только чтение (режим согласующего). */
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Выбор ОДНОГО сотрудника. Обёртка над PeoplePicker из @pnp/spfx-controls-react. */
|
/** Выбор ОДНОГО сотрудника. Обёртка над PeoplePicker из @pnp/spfx-controls-react. */
|
||||||
export const PeopleField: React.FC<IPeopleFieldProps> = ({ context, value, onChange, placeholder }) => {
|
export const PeopleField: React.FC<IPeopleFieldProps> = ({ context, value, onChange, placeholder, readOnly }) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
const handleChange = (items: IPickedUser[]): void => {
|
const handleChange = (items: IPickedUser[]): void => {
|
||||||
const first = items && items.length > 0 ? items[0] : undefined;
|
const first = items && items.length > 0 ? items[0] : undefined;
|
||||||
onChange(first ? pickedLogin(first) : '');
|
onChange(first ? pickedLogin(first) : '');
|
||||||
@@ -26,6 +31,7 @@ export const PeopleField: React.FC<IPeopleFieldProps> = ({ context, value, onCha
|
|||||||
defaultSelectedUsers={value ? [value] : []}
|
defaultSelectedUsers={value ? [value] : []}
|
||||||
resolveDelay={300}
|
resolveDelay={300}
|
||||||
ensureUser
|
ensureUser
|
||||||
|
disabled={effectiveReadOnly}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
|||||||
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
|
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
|
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
|
||||||
|
import { useFormReadOnly } from '../FormMode';
|
||||||
|
|
||||||
interface IPeopleFieldMultiProps {
|
interface IPeopleFieldMultiProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -11,6 +12,8 @@ interface IPeopleFieldMultiProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
/** Максимум выбираемых людей. */
|
/** Максимум выбираемых людей. */
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
/** Только чтение (режим согласующего). */
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Выбор НЕСКОЛЬКИХ сотрудников (для колонок «Пользователь или группа (несколько)»). */
|
/** Выбор НЕСКОЛЬКИХ сотрудников (для колонок «Пользователь или группа (несколько)»). */
|
||||||
@@ -19,8 +22,11 @@ export const PeopleFieldMulti: React.FC<IPeopleFieldMultiProps> = ({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
limit = 10
|
limit = 10,
|
||||||
|
readOnly
|
||||||
}) => {
|
}) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
const handleChange = (items: IPickedUser[]): void => {
|
const handleChange = (items: IPickedUser[]): void => {
|
||||||
const logins = (items || []).map(pickedLogin).filter((login) => login.length > 0);
|
const logins = (items || []).map(pickedLogin).filter((login) => login.length > 0);
|
||||||
onChange(logins);
|
onChange(logins);
|
||||||
@@ -34,6 +40,7 @@ export const PeopleFieldMulti: React.FC<IPeopleFieldMultiProps> = ({
|
|||||||
defaultSelectedUsers={value}
|
defaultSelectedUsers={value}
|
||||||
resolveDelay={300}
|
resolveDelay={300}
|
||||||
ensureUser
|
ensureUser
|
||||||
|
disabled={effectiveReadOnly}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './Select.module.scss';
|
import styles from './Select.module.scss';
|
||||||
|
import { useFormReadOnly } from '../FormMode';
|
||||||
|
|
||||||
export interface ISelectOption {
|
export interface ISelectOption {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -12,15 +13,20 @@ interface ISelectProps {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
options: ISelectOption[];
|
options: ISelectOption[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** Только чтение (режим согласующего). У select нет readonly — используем disabled. */
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Выпадающий список (SharePoint Choice). */
|
/** Выпадающий список (SharePoint Choice). */
|
||||||
export const Select: React.FC<ISelectProps> = ({ id, value, onChange, options, placeholder }) => {
|
export const Select: React.FC<ISelectProps> = ({ id, value, onChange, options, placeholder, readOnly }) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
id={id}
|
id={id}
|
||||||
className={styles.select}
|
className={styles.select}
|
||||||
value={value}
|
value={value}
|
||||||
|
disabled={effectiveReadOnly}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
>
|
>
|
||||||
{placeholder && <option value="">{placeholder}</option>}
|
{placeholder && <option value="">{placeholder}</option>}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './TextArea.module.scss';
|
import styles from './TextArea.module.scss';
|
||||||
|
import { useFormReadOnly } from '../FormMode';
|
||||||
|
|
||||||
interface ITextAreaProps {
|
interface ITextAreaProps {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -7,10 +8,14 @@ interface ITextAreaProps {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
|
/** Только чтение (режим согласующего). */
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Многострочный ввод (SharePoint Note). */
|
/** Многострочный ввод (SharePoint Note). */
|
||||||
export const TextArea: React.FC<ITextAreaProps> = ({ id, value, onChange, placeholder, rows = 3 }) => {
|
export const TextArea: React.FC<ITextAreaProps> = ({ id, value, onChange, placeholder, rows = 3, readOnly }) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
id={id}
|
id={id}
|
||||||
@@ -18,6 +23,7 @@ export const TextArea: React.FC<ITextAreaProps> = ({ id, value, onChange, placeh
|
|||||||
value={value}
|
value={value}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
|
readOnly={effectiveReadOnly}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './TextInput.module.scss';
|
import styles from './TextInput.module.scss';
|
||||||
|
import { useFormReadOnly } from '../FormMode';
|
||||||
|
|
||||||
type InputType = 'text' | 'number' | 'date';
|
type InputType = 'text' | 'number' | 'date';
|
||||||
|
|
||||||
@@ -9,10 +10,21 @@ interface ITextInputProps {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** Только чтение (режим согласующего). */
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Однострочный ввод. type='number'|'date' — для числовых полей и дат. */
|
/** Однострочный ввод. type='number'|'date' — для числовых полей и дат. */
|
||||||
export const TextInput: React.FC<ITextInputProps> = ({ id, type = 'text', value, onChange, placeholder }) => {
|
export const TextInput: React.FC<ITextInputProps> = ({
|
||||||
|
id,
|
||||||
|
type = 'text',
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
readOnly
|
||||||
|
}) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
@@ -20,6 +32,7 @@ export const TextInput: React.FC<ITextInputProps> = ({ id, type = 'text', value,
|
|||||||
type={type}
|
type={type}
|
||||||
value={value}
|
value={value}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
readOnly={effectiveReadOnly}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './Toggle.module.scss';
|
import styles from './Toggle.module.scss';
|
||||||
|
import { useFormReadOnly } from '../FormMode';
|
||||||
|
|
||||||
interface IToggleProps {
|
interface IToggleProps {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -7,10 +8,14 @@ interface IToggleProps {
|
|||||||
onChange: (checked: boolean) => void;
|
onChange: (checked: boolean) => void;
|
||||||
/** Подпись справа от переключателя (да/нет-поля). */
|
/** Подпись справа от переключателя (да/нет-поля). */
|
||||||
onLabel?: string;
|
onLabel?: string;
|
||||||
|
/** Только чтение (режим согласующего). */
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Переключатель да/нет (SharePoint Boolean). */
|
/** Переключатель да/нет (SharePoint Boolean). */
|
||||||
export const Toggle: React.FC<IToggleProps> = ({ id, checked, onChange, onLabel }) => {
|
export const Toggle: React.FC<IToggleProps> = ({ id, checked, onChange, onLabel, readOnly }) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
return (
|
return (
|
||||||
<label className={styles.wrap}>
|
<label className={styles.wrap}>
|
||||||
<input
|
<input
|
||||||
@@ -18,6 +23,7 @@ export const Toggle: React.FC<IToggleProps> = ({ id, checked, onChange, onLabel
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
|
disabled={effectiveReadOnly}
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span className={styles.track}>
|
<span className={styles.track}>
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ export { CollapseSection } from './CollapseSection/CollapseSection';
|
|||||||
export { FormActions } from './FormActions/FormActions';
|
export { FormActions } from './FormActions/FormActions';
|
||||||
export { PeopleField } from './PeopleField/PeopleField';
|
export { PeopleField } from './PeopleField/PeopleField';
|
||||||
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
||||||
|
export { FormModeProvider, useFormReadOnly } from './FormMode';
|
||||||
export { Icon } from './Icon/Icon';
|
export { Icon } from './Icon/Icon';
|
||||||
|
|||||||
Reference in New Issue
Block a user