@@ -11,8 +11,20 @@ interface IDecisionPanelProps {
|
||||
service: ApproverService;
|
||||
/** Id заявки (элемент списка данных). */
|
||||
requestId: number;
|
||||
/** Уведомление после сохранения решения (например, обновить статус на экране). */
|
||||
onDecided?: (status: string) => void;
|
||||
/**
|
||||
* Уведомление после сохранения решения (например, обновить статус на экране).
|
||||
*
|
||||
* Может вернуть Promise — тогда панель ДОЖДЁТСЯ его и только потом покажет
|
||||
* «Решение сохранено». Так работают побочные действия решения, без которых
|
||||
* ответ «согласовано» неполон: по согласованному заявлению на увольнение
|
||||
* автоматически создаётся обходной лист (см. forms/Dismissal), и сообщать об
|
||||
* успехе, пока он не создан, — значит показывать успех раньше времени.
|
||||
*
|
||||
* Отказ (reject) панель НЕ считает провалом решения — решение к этому моменту
|
||||
* уже применено на сервере; сообщить о проблеме побочного действия должен сам
|
||||
* обработчик (у него для этого есть своя область экрана).
|
||||
*/
|
||||
onDecided?: (status: string) => void | Promise<void>;
|
||||
/** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные
|
||||
* согласующим). Если бросит — решение не применяется, показывается ошибка. */
|
||||
onBeforeDecide?: () => Promise<void>;
|
||||
@@ -83,11 +95,19 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
||||
await onBeforeDecide();
|
||||
}
|
||||
const status = await service.decide(requestId, pendingRow.id, decision, comment.trim());
|
||||
// Сначала побочные действия решения, потом «сохранено»: пока onDecided не
|
||||
// отработал, кнопки остаются заблокированными (submitting), и пользователь не
|
||||
// видит успеха раньше, чем всё сделано. Провал onDecided на статус решения не
|
||||
// влияет — оно уже применено, поэтому сообщение показываем всё равно.
|
||||
if (onDecided) {
|
||||
try {
|
||||
await onDecided(status);
|
||||
} catch (e) {
|
||||
console.error('Побочное действие после решения не выполнилось', e);
|
||||
}
|
||||
}
|
||||
setResultStatus(status);
|
||||
setSubmitting(false);
|
||||
if (onDecided) {
|
||||
onDecided(status);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`Не удалось сохранить решение: ${(e as Error).message}`);
|
||||
setSubmitting(false);
|
||||
|
||||
@@ -9,6 +9,22 @@ export function isoDatePlusDays(days: number): string {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сегодня + n КАЛЕНДАРНЫХ месяцев в формате YYYY-MM-DD (для min/max у input
|
||||
* type=date) — не приближение через 30 дней: `setMonth` сам разносит месяцы
|
||||
* разной длины (28–31 день) и года на границе декабря/января.
|
||||
*
|
||||
* 31 января + 1 месяц → JS даёт 3 марта (переносит лишние дни на следующий
|
||||
* месяц, а не откатывает до последнего числа) — для месячного уведомления это
|
||||
* не проблема (крайняя ранняя дата и так сдвигается на несколько дней позже,
|
||||
* не раньше), но не считать это «ровно через месяц» в общем случае.
|
||||
*/
|
||||
export function isoDatePlusMonths(months: number): string {
|
||||
const d = new Date();
|
||||
d.setMonth(d.getMonth() + months);
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD → дд.мм.гггг, для текста ошибок валидации. Невалидную строку возвращает как есть. */
|
||||
export function formatIsoDate(iso: string): string {
|
||||
const [y, m, d] = iso.split('-');
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
|
||||
|
||||
/**
|
||||
* Название должности директора по компании — ЗЕРКАЛО того же справочника, что
|
||||
* использует approval-route на бэкенде для маршрута HeadType.CompanyDirectors
|
||||
* (см. tnsu-approval-route TemplateRouteService.DirectorPositionByCompany).
|
||||
* Держим копию здесь, а не тянем с бэкенда: нужен только на фронте, чтобы найти
|
||||
* СТРОКУ МАТРИЦЫ, соответствующую конкретной компании (см.
|
||||
* forms/Dismissal/sections/OrganizationsTable.tsx) — сама матрица уже пришла
|
||||
* готовой от approval-route, эта функция лишь опознаёт в ней директора нужной
|
||||
* компании тем же способом, каким его туда положил бэкенд.
|
||||
*
|
||||
* Если бэкендный справочник изменится — эту копию нужно поправить вручную,
|
||||
* отдельного источника правды на два репозитория нет.
|
||||
*/
|
||||
const DIRECTOR_POSITION_BY_COMPANY: Record<string, string> = {
|
||||
'ТОО KazPromService': 'Главный механик',
|
||||
'ТОО СТ Global': 'Генеральный директор'
|
||||
};
|
||||
const DEFAULT_DIRECTOR_POSITION = 'Директор';
|
||||
|
||||
function normalizeCompany(company: string): string {
|
||||
return company.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const DIRECTOR_POSITION_BY_COMPANY_NORMALIZED: Record<string, string> = Object.keys(
|
||||
DIRECTOR_POSITION_BY_COMPANY
|
||||
).reduce<Record<string, string>>((acc, key) => {
|
||||
acc[normalizeCompany(key)] = DIRECTOR_POSITION_BY_COMPANY[key];
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
/** Название должности директора для компании (регистронезависимо). */
|
||||
export function directorPositionFor(company: string): string {
|
||||
return DIRECTOR_POSITION_BY_COMPANY_NORMALIZED[normalizeCompany(company)] || DEFAULT_DIRECTOR_POSITION;
|
||||
}
|
||||
|
||||
async function getClient(context: WebPartContext): Promise<AadHttpClient> {
|
||||
return context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /Employee/ByPosition — сотрудник на должности в компании (напр. директор).
|
||||
* 404/«не найден» — не ошибка формы, просто вернуть '' (см. вызывающий код:
|
||||
* отсутствие директора не должно ронять форму, только не даст найти строку матрицы).
|
||||
*/
|
||||
export async function getEmployeeEmailByPosition(
|
||||
context: WebPartContext,
|
||||
position: string,
|
||||
company: string
|
||||
): Promise<string> {
|
||||
const client = await getClient(context);
|
||||
const response: HttpClientResponse = await client.get(
|
||||
`${DICTIONARY_API_BASE_URL}/Employee/ByPosition?position=${encodeURIComponent(
|
||||
position
|
||||
)}&company=${encodeURIComponent(company)}`,
|
||||
AadHttpClient.configurations.v1
|
||||
);
|
||||
if (response.status === 404) return '';
|
||||
if (!response.ok) {
|
||||
throw new Error(`Employee/ByPosition: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const body = (await response.json()) as { Email?: string; error?: string };
|
||||
return body.Email || '';
|
||||
}
|
||||
|
||||
/** Директор (или должностной эквивалент, см. directorPositionFor) компании. */
|
||||
export function getCompanyDirectorEmail(context: WebPartContext, company: string): Promise<string> {
|
||||
return getEmployeeEmailByPosition(context, directorPositionFor(company), company);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /Employee/Manager — руководитель сотрудника по типу связи. 'chief' —
|
||||
* непосредственный руководитель (тот же managerType, что использует
|
||||
* approval-route для HeadType.Chief — см. TemplateRouteService.ResolveStepAsync).
|
||||
* Не найден/сотрудник без руководителя — '' (не ошибка).
|
||||
*/
|
||||
export async function getManagerEmail(
|
||||
context: WebPartContext,
|
||||
email: string,
|
||||
managerType: 'chief' | 'directionHead' = 'chief'
|
||||
): Promise<string> {
|
||||
if (!email) return '';
|
||||
const client = await getClient(context);
|
||||
const response: HttpClientResponse = await client.get(
|
||||
`${DICTIONARY_API_BASE_URL}/Employee/Manager?email=${encodeURIComponent(email)}&managerType=${encodeURIComponent(
|
||||
managerType
|
||||
)}`,
|
||||
AadHttpClient.configurations.v1
|
||||
);
|
||||
if (response.status === 404) return '';
|
||||
if (!response.ok) {
|
||||
throw new Error(`Employee/Manager: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const body = (await response.json()) as { managerEmail?: string };
|
||||
return body.managerEmail || '';
|
||||
}
|
||||
@@ -12,3 +12,9 @@ export { CompanyPickerMulti } from './ui/CompanyPickerMulti';
|
||||
export type { ICompanyPickerMultiProps } from './ui/CompanyPickerMulti';
|
||||
export { getEmployeeOrganizations, formatEmployeeOrganizations } from './employeeOrganizations.service';
|
||||
export type { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
||||
export {
|
||||
directorPositionFor,
|
||||
getEmployeeEmailByPosition,
|
||||
getCompanyDirectorEmail,
|
||||
getManagerEmail
|
||||
} from './employeeLookup.service';
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
/** База имени по типу заявки. Ключи — те же, что в реестре форм (forms/registry.ts). */
|
||||
const LIST_TITLE_BASE: Record<RequestTypeId, string> = {
|
||||
'clearance-form': 'Обходной лист',
|
||||
'hr-dismissal': 'Заявление на увольнение',
|
||||
'hr-vacation': 'Заявка на отпуск',
|
||||
'hr-unpaid-leave': 'Отпуск без содержания',
|
||||
'it-1c-ticket': 'Заявка в отдел 1С',
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
IRoute,
|
||||
navigate,
|
||||
navigateToRequestLink,
|
||||
parseRoute,
|
||||
persistOwnStatus,
|
||||
persistOwnTab,
|
||||
persistPage,
|
||||
persistQuery,
|
||||
requestLinkUrl,
|
||||
routeUrl,
|
||||
storedOwnStatus,
|
||||
storedOwnTab,
|
||||
@@ -527,3 +529,34 @@ describe('navigate', () => {
|
||||
expect(seen[0]).toContain('rpnav=approvals');
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestLinkUrl / navigateToRequestLink', () => {
|
||||
it('ссылка абсолютна и не тащит посторонние параметры текущей страницы', () => {
|
||||
window.history.replaceState({}, '', '/?foo=bar&rpnav=own');
|
||||
expect(requestLinkUrl('clearance-form', 7, '/sites/requests/Lists/List10')).toBe(
|
||||
'http://localhost/?rpmode=approve&rptype=clearance-form&rpid=7&rplist=%2Fsites%2Frequests%2FLists%2FList10'
|
||||
);
|
||||
});
|
||||
|
||||
it('ссылка на заявку этого же портала открывается навигацией, без перезагрузки', () => {
|
||||
window.history.replaceState({}, '', '/?rpnav=own');
|
||||
expect(navigateToRequestLink(requestLinkUrl('clearance-form', 7))).toBe(true);
|
||||
|
||||
const route = parseRoute();
|
||||
expect(route.mode).toBe('approve');
|
||||
expect(route.formKey).toBe('clearance-form');
|
||||
expect(route.itemId).toBe(7);
|
||||
// Раздел под панелью заявки остаётся тем же, что был открыт.
|
||||
expect(route.nav).toBe('own');
|
||||
});
|
||||
|
||||
it('чужой сайт, другая страница и ссылка не на заявку — клик отдаётся браузеру', () => {
|
||||
expect(navigateToRequestLink('https://example.com/p?rpmode=approve&rptype=a&rpid=1')).toBe(false);
|
||||
expect(navigateToRequestLink('/OtherPage.aspx?rpmode=approve&rptype=a&rpid=1')).toBe(false);
|
||||
expect(navigateToRequestLink('http://localhost/?rpnav=own')).toBe(false);
|
||||
});
|
||||
|
||||
it('мусор вместо адреса не роняет обработчик клика', () => {
|
||||
expect(navigateToRequestLink('не ссылка')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -412,6 +412,69 @@ export function routeUrl(route: IRoute): string {
|
||||
return buildQuery(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* АБСОЛЮТНАЯ ссылка на просмотр заявки — для того, что переживёт текущую сессию:
|
||||
* колонка-гиперссылка в списке SharePoint, письмо, сообщение в чат.
|
||||
*
|
||||
* Отличается от routeUrl() двумя вещами, и обе важны именно для «хранимой» ссылки:
|
||||
* она с origin (в списке относительный путь бесполезен — его открывают из
|
||||
* представления SharePoint, а не со страницы портала) и без посторонних
|
||||
* query-параметров текущей страницы (routeUrl их намеренно сохраняет, потому что
|
||||
* это навигация ВНУТРИ открытой страницы).
|
||||
*
|
||||
* Страница портала берётся из адреса текущей — портал живёт на одной странице,
|
||||
* другого способа узнать её путь у клиентского кода нет.
|
||||
*/
|
||||
export function requestLinkUrl(formKey: string, itemId: number, listId?: string): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set('rpmode', 'approve');
|
||||
params.set('rptype', formKey);
|
||||
params.set('rpid', String(itemId));
|
||||
if (listId) {
|
||||
params.set('rplist', listId);
|
||||
}
|
||||
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Открыть заявку по её АБСОЛЮТНОЙ ссылке (см. requestLinkUrl) БЕЗ перезагрузки
|
||||
* страницы — если ссылка ведёт на эту же страницу портала.
|
||||
*
|
||||
* Зачем: страница SharePoint со SPFx грузится секунды, а ссылка между парными
|
||||
* заявками (заявление на увольнение ↔ обходной лист) — это по сути та же страница
|
||||
* с другими rp-параметрами, то есть ровно то, что умеет navigate(). Полная
|
||||
* перезагрузка здесь ничего не даёт.
|
||||
*
|
||||
* Возвращает false, если ссылка ведёт куда-то ещё (другой сайт, другая страница,
|
||||
* не на просмотр заявки) — тогда вызывающий НЕ должен подавлять клик и обязан
|
||||
* дать браузеру перейти по ней обычным образом.
|
||||
*/
|
||||
export function navigateToRequestLink(url: string): boolean {
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(url, window.location.href);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// Путь в SharePoint регистронезависим, а ссылка могла быть записана в список
|
||||
// когда-то с другим регистром — сравниваем в нижнем.
|
||||
if (
|
||||
target.origin !== window.location.origin ||
|
||||
target.pathname.toLowerCase() !== window.location.pathname.toLowerCase()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const route = parseRoute(target.search);
|
||||
if (route.mode !== 'approve' || !route.formKey || !route.itemId) {
|
||||
return false;
|
||||
}
|
||||
// Раздел бокового меню берём ТЕКУЩИЙ, а не из ссылки: заявка показывается в
|
||||
// панели поверх раздела, и уход по ссылке не должен переключать раздел под ней.
|
||||
navigate({ ...route, nav: parseRoute().nav });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Текущий маршрут, реактивно обновляется по navigate() и кнопке «назад». */
|
||||
export function useRoute(): IRoute {
|
||||
const [route, setRoute] = React.useState<IRoute>(() => parseRoute());
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// Read-only «карточка» со ссылкой — те же радиус/паддинг/приглушённый фон, что у
|
||||
// OrganizationField (сигнал «подставлено автоматически, не редактируется»),
|
||||
// отличается только тем, что содержимое кликабельно.
|
||||
.box {
|
||||
box-sizing: border-box;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
background: $gray-50;
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
color: $navy-700;
|
||||
text-decoration: none;
|
||||
transition: color .18s;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: $gold-600;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react';
|
||||
import styles from './LinkField.module.scss';
|
||||
import { FormField, Icon } from '@tnsu/ui-kit';
|
||||
import { navigateToRequestLink } from '../../routing';
|
||||
|
||||
interface ILinkFieldProps {
|
||||
label: string;
|
||||
/** Адрес. Пусто — поле не рендерится вовсе (см. ниже). */
|
||||
url: string;
|
||||
/** Текст ссылки. По умолчанию — сам адрес. */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Поле-ссылка: read-only, кликабельное, руками не редактируется.
|
||||
*
|
||||
* Пустой url — ничего не рендерим (а не пустую рамку): такие поля появляются
|
||||
* только когда связь уже есть (напр. ClearanceUrl заявления на увольнение
|
||||
* заполняется автосозданием обходного листа), и до этого момента их нет смысла
|
||||
* показывать.
|
||||
*
|
||||
* Открывается в ТОЙ ЖЕ вкладке, и если ссылка ведёт на заявку этого же портала —
|
||||
* вообще без перезагрузки страницы (navigateToRequestLink: та же страница, другие
|
||||
* rp-параметры). href при этом настоящий, а не «#»: ссылку можно скопировать,
|
||||
* открыть в новой вкладке через Ctrl/⌘+клик или среднюю кнопку — такие клики
|
||||
* перехват намеренно пропускает.
|
||||
*/
|
||||
export const LinkField: React.FC<ILinkFieldProps> = ({ label, url, text }) => {
|
||||
if (!url) return null;
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>): void => {
|
||||
// Клик с модификатором или не левой кнопкой — осознанное «открыть отдельно»,
|
||||
// отдаём браузеру. Иначе пробуем перейти внутри портала.
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
||||
return;
|
||||
}
|
||||
if (navigateToRequestLink(url)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormField label={label} full>
|
||||
<div className={styles.box}>
|
||||
<a className={styles.link} href={url} onClick={handleClick}>
|
||||
<span>{text || url}</span>
|
||||
<Icon name="chevron-right" size="sm" />
|
||||
</a>
|
||||
</div>
|
||||
</FormField>
|
||||
);
|
||||
};
|
||||
@@ -41,6 +41,7 @@ export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
||||
export { OrganizationField } from './OrganizationField/OrganizationField';
|
||||
export { PrintOrgSelect } from './PrintOrgSelect/PrintOrgSelect';
|
||||
export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
|
||||
export { LinkField } from './LinkField/LinkField';
|
||||
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
|
||||
export { SubmitActions } from './SubmitActions/SubmitActions';
|
||||
export { InitiatorLine } from './InitiatorLine/InitiatorLine';
|
||||
|
||||
Reference in New Issue
Block a user