Заявление на увольнение (#15)
Tests / frontend-test (push) Successful in 1m17s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/15
This commit was merged in pull request #15.
This commit is contained in:
2026-08-26 07:07:03 +00:00
parent f751f03060
commit df29d080ed
39 changed files with 2460 additions and 46 deletions
@@ -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>
);
};
@@ -39,6 +39,7 @@ export { parseExcelRows } from './ItemsTable/parseExcelRows';
export { PeopleField } from './PeopleField/PeopleField';
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
export { OrganizationField } from './OrganizationField/OrganizationField';
export { LinkField } from './LinkField/LinkField';
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
export { SubmitActions } from './SubmitActions/SubmitActions';
export { InitiatorLine } from './InitiatorLine/InitiatorLine';