fix: changed resolve endpoint contract form get to post
This commit is contained in:
@@ -33,12 +33,22 @@ export async function resolveRoute(
|
||||
userEmail: string
|
||||
): Promise<IResolvedRoute> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
|
||||
import { useFormReadOnly } from '../FormMode';
|
||||
|
||||
interface IPeopleFieldProps {
|
||||
context: WebPartContext;
|
||||
@@ -9,10 +10,14 @@ interface IPeopleFieldProps {
|
||||
value: string;
|
||||
onChange: (login: string) => void;
|
||||
placeholder?: string;
|
||||
/** Только чтение (режим согласующего). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/** Выбор ОДНОГО сотрудника. Обёртка над 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 first = items && items.length > 0 ? items[0] : undefined;
|
||||
onChange(first ? pickedLogin(first) : '');
|
||||
@@ -26,6 +31,7 @@ export const PeopleField: React.FC<IPeopleFieldProps> = ({ context, value, onCha
|
||||
defaultSelectedUsers={value ? [value] : []}
|
||||
resolveDelay={300}
|
||||
ensureUser
|
||||
disabled={effectiveReadOnly}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
||||
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
|
||||
import { useFormReadOnly } from '../FormMode';
|
||||
|
||||
interface IPeopleFieldMultiProps {
|
||||
context: WebPartContext;
|
||||
@@ -11,6 +12,8 @@ interface IPeopleFieldMultiProps {
|
||||
placeholder?: string;
|
||||
/** Максимум выбираемых людей. */
|
||||
limit?: number;
|
||||
/** Только чтение (режим согласующего). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/** Выбор НЕСКОЛЬКИХ сотрудников (для колонок «Пользователь или группа (несколько)»). */
|
||||
@@ -19,8 +22,11 @@ export const PeopleFieldMulti: React.FC<IPeopleFieldMultiProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
limit = 10
|
||||
limit = 10,
|
||||
readOnly
|
||||
}) => {
|
||||
const contextReadOnly = useFormReadOnly();
|
||||
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||
const handleChange = (items: IPickedUser[]): void => {
|
||||
const logins = (items || []).map(pickedLogin).filter((login) => login.length > 0);
|
||||
onChange(logins);
|
||||
@@ -34,6 +40,7 @@ export const PeopleFieldMulti: React.FC<IPeopleFieldMultiProps> = ({
|
||||
defaultSelectedUsers={value}
|
||||
resolveDelay={300}
|
||||
ensureUser
|
||||
disabled={effectiveReadOnly}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Select.module.scss';
|
||||
import { useFormReadOnly } from '../FormMode';
|
||||
|
||||
export interface ISelectOption {
|
||||
value: string;
|
||||
@@ -12,15 +13,20 @@ interface ISelectProps {
|
||||
onChange: (value: string) => void;
|
||||
options: ISelectOption[];
|
||||
placeholder?: string;
|
||||
/** Только чтение (режим согласующего). У select нет readonly — используем disabled. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/** Выпадающий список (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 (
|
||||
<select
|
||||
id={id}
|
||||
className={styles.select}
|
||||
value={value}
|
||||
disabled={effectiveReadOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
{placeholder && <option value="">{placeholder}</option>}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import styles from './TextArea.module.scss';
|
||||
import { useFormReadOnly } from '../FormMode';
|
||||
|
||||
interface ITextAreaProps {
|
||||
id?: string;
|
||||
@@ -7,10 +8,14 @@ interface ITextAreaProps {
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
/** Только чтение (режим согласующего). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/** Многострочный ввод (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 (
|
||||
<textarea
|
||||
id={id}
|
||||
@@ -18,6 +23,7 @@ export const TextArea: React.FC<ITextAreaProps> = ({ id, value, onChange, placeh
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
readOnly={effectiveReadOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import styles from './TextInput.module.scss';
|
||||
import { useFormReadOnly } from '../FormMode';
|
||||
|
||||
type InputType = 'text' | 'number' | 'date';
|
||||
|
||||
@@ -9,10 +10,21 @@ interface ITextInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
/** Только чтение (режим согласующего). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/** Однострочный ввод. 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 (
|
||||
<input
|
||||
id={id}
|
||||
@@ -20,6 +32,7 @@ export const TextInput: React.FC<ITextInputProps> = ({ id, type = 'text', value,
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
readOnly={effectiveReadOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Toggle.module.scss';
|
||||
import { useFormReadOnly } from '../FormMode';
|
||||
|
||||
interface IToggleProps {
|
||||
id?: string;
|
||||
@@ -7,10 +8,14 @@ interface IToggleProps {
|
||||
onChange: (checked: boolean) => void;
|
||||
/** Подпись справа от переключателя (да/нет-поля). */
|
||||
onLabel?: string;
|
||||
/** Только чтение (режим согласующего). */
|
||||
readOnly?: 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 (
|
||||
<label className={styles.wrap}>
|
||||
<input
|
||||
@@ -18,6 +23,7 @@ export const Toggle: React.FC<IToggleProps> = ({ id, checked, onChange, onLabel
|
||||
type="checkbox"
|
||||
className={styles.input}
|
||||
checked={checked}
|
||||
disabled={effectiveReadOnly}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span className={styles.track}>
|
||||
|
||||
@@ -13,4 +13,5 @@ export { CollapseSection } from './CollapseSection/CollapseSection';
|
||||
export { FormActions } from './FormActions/FormActions';
|
||||
export { PeopleField } from './PeopleField/PeopleField';
|
||||
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
||||
export { FormModeProvider, useFormReadOnly } from './FormMode';
|
||||
export { Icon } from './Icon/Icon';
|
||||
|
||||
Reference in New Issue
Block a user