feat: ссылки между заявлением на увольнение и обходным листом видны на формах
Поле-ссылка LinkField (shared/ui): read-only «карточка» как у OrganizationField,
но кликабельная, открывается в новой вкладке (портал — одна страница, переход в
текущей означал бы перезагрузку с потерей открытого). Пустой адрес — поле не
рендерится: связь есть не у всех заявок.
В заявлении показывается «Обходной лист» (ClearanceUrl), в обходном листе —
«Заявление на увольнение» (DismissalUrl); обе колонки читаются в load.ts (адрес из
объекта {Url, Description}) и не пишутся формами. Согласующему, который только что
одобрил заявление, ссылка дописывается в модель сразу после автосоздания листа —
иначе появилась бы только после перезагрузки.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,8 +49,10 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова
|
|||||||
заявления в портале.
|
заявления в портале.
|
||||||
|
|
||||||
Пара связана колонками-гиперссылками: `DismissalUrl` здесь → на заявление,
|
Пара связана колонками-гиперссылками: `DismissalUrl` здесь → на заявление,
|
||||||
`ClearanceUrl` в заявлении → на лист. Форма их не показывает и не пишет — их
|
`ClearanceUrl` в заявлении → на лист. Форма их не пишет (заполняет автосоздание либо
|
||||||
заполняет автосоздание (или вручную в SharePoint).
|
вручную в SharePoint), но **показывает**: поле «Заявление на увольнение» —
|
||||||
|
`LinkField` из `shared/ui`, read-only, открывается в новой вкладке; у листов,
|
||||||
|
заведённых руками, ссылки нет и поле не рендерится.
|
||||||
|
|
||||||
## Секции формы
|
## Секции формы
|
||||||
RequestSection (сотрудник · должность · подразделение · дата расторжения · сумма обучения · парковочная карта · карта СКУД)
|
RequestSection (сотрудник · должность · подразделение · дата расторжения · сумма обучения · парковочная карта · карта СКУД)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
|
|||||||
'AccessCard',
|
'AccessCard',
|
||||||
'TrainingAmount',
|
'TrainingAmount',
|
||||||
'Organization',
|
'Organization',
|
||||||
|
'DismissalUrl',
|
||||||
'RequestStatus',
|
'RequestStatus',
|
||||||
'RequestUniqueCode',
|
'RequestUniqueCode',
|
||||||
'Employee/Title',
|
'Employee/Title',
|
||||||
@@ -52,7 +53,9 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro
|
|||||||
ParkingCard: !!item.ParkingCard,
|
ParkingCard: !!item.ParkingCard,
|
||||||
AccessCard: !!item.AccessCard,
|
AccessCard: !!item.AccessCard,
|
||||||
TrainingAmount: amount === null || amount === undefined ? '' : String(amount),
|
TrainingAmount: amount === null || amount === undefined ? '' : String(amount),
|
||||||
Organization: (item.Organization as string) || ''
|
Organization: (item.Organization as string) || '',
|
||||||
|
// Гиперссылка приходит объектом {Url, Description} — модели нужен только адрес.
|
||||||
|
DismissalUrl: (item.DismissalUrl as { Url?: string } | undefined)?.Url || ''
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ export interface IFormData {
|
|||||||
* read-only (см. shared/ui/OrganizationField).
|
* read-only (см. shared/ui/OrganizationField).
|
||||||
*/
|
*/
|
||||||
Organization: string;
|
Organization: string;
|
||||||
|
/**
|
||||||
|
* Ссылка на заявление на увольнение, по которому создан этот лист — колонка-
|
||||||
|
* гиперссылка `DismissalUrl`. Форма её не пишет (заполняет автосоздание, см.
|
||||||
|
* forms/Dismissal/createClearance.ts) и не даёт править — только показывает.
|
||||||
|
*/
|
||||||
|
DismissalUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Обновление одного поля формы с сохранением типа значения. */
|
/** Обновление одного поля формы с сохранением типа значения. */
|
||||||
@@ -56,7 +62,8 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
ParkingCard: false,
|
ParkingCard: false,
|
||||||
AccessCard: false,
|
AccessCard: false,
|
||||||
TrainingAmount: '',
|
TrainingAmount: '',
|
||||||
Organization: ''
|
Organization: '',
|
||||||
|
DismissalUrl: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Плейсхолдер для поля выбора сотрудника. */
|
/** Плейсхолдер для поля выбора сотрудника. */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField, LinkField } from '../../../shared/ui';
|
||||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
|
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
|
||||||
|
|
||||||
// Подразделение подтягивается автоматически при выборе сотрудника (см.
|
// Подразделение подтягивается автоматически при выборе сотрудника (см.
|
||||||
@@ -89,5 +89,12 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
|
|||||||
readOnly={hrRecordsEditable ? false : undefined}
|
readOnly={hrRecordsEditable ? false : undefined}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
{/* Есть только у листов, созданных автоматически по согласованному заявлению
|
||||||
|
на увольнение (см. forms/Dismissal/createClearance.ts). */}
|
||||||
|
<LinkField
|
||||||
|
label="Заявление на увольнение"
|
||||||
|
url={data.DismissalUrl}
|
||||||
|
text="Открыть заявление, по которому создан этот лист"
|
||||||
|
/>
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -124,13 +124,17 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
const createClearance = (): void => {
|
const createClearance = (): void => {
|
||||||
setClearanceError('');
|
setClearanceError('');
|
||||||
ensureClearanceForDismissal(context, itemId, listId)
|
ensureClearanceForDismissal(context, itemId, listId)
|
||||||
.then((result) =>
|
.then((result) => {
|
||||||
setClearance(
|
setClearance(
|
||||||
result.existing
|
result.existing
|
||||||
? 'Обходной лист по этому заявлению уже был создан.'
|
? 'Обходной лист по этому заявлению уже был создан.'
|
||||||
: `Обходной лист создан автоматически${result.code ? ` — №${result.code}` : ''} и отправлен на согласование.`
|
: `Обходной лист создан автоматически${result.code ? ` — №${result.code}` : ''} и отправлен на согласование.`
|
||||||
)
|
);
|
||||||
)
|
// Поле-ссылка в секции заполняется из данных заявки, а они прочитаны ДО
|
||||||
|
// создания листа — дописываем адрес в модель, иначе ссылка появилась бы
|
||||||
|
// только после перезагрузки страницы.
|
||||||
|
setData((prev) => ({ ...prev, ClearanceUrl: result.url }));
|
||||||
|
})
|
||||||
.catch((e) =>
|
.catch((e) =>
|
||||||
setClearanceError(
|
setClearanceError(
|
||||||
`Заявление согласовано, но обходной лист создать не удалось: ${friendlyErrorMessage(e)} — заведите его вручную.`
|
`Заявление согласовано, но обходной лист создать не удалось: ${friendlyErrorMessage(e)} — заведите его вручную.`
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ approval-route (`getPlannedMatrix` для фаз «Согласование» и
|
|||||||
(`…?rpmode=approve&rptype=…&rpid=…&rplist=…`, см. `docs/routing.md`), а не на элемент
|
(`…?rpmode=approve&rptype=…&rpid=…&rplist=…`, см. `docs/routing.md`), а не на элемент
|
||||||
списка, и не ломается, когда парная заявка уезжает в другую партицию.
|
списка, и не ломается, когда парная заявка уезжает в другую партицию.
|
||||||
|
|
||||||
|
Ссылки **видны на обеих формах** и кликабельны: поле «Обходной лист» в заявлении и
|
||||||
|
«Заявление на увольнение» в обходном листе (`LinkField`, `shared/ui`) — read-only,
|
||||||
|
открываются в новой вкладке и появляются только когда связь есть. У согласующего,
|
||||||
|
только что одобрившего заявление, ссылка появляется сразу, без перезагрузки.
|
||||||
|
|
||||||
Что важно знать про это поведение:
|
Что важно знать про это поведение:
|
||||||
- **Идемпотентность — по непустой `ClearanceUrl`.** Заявление можно согласовать
|
- **Идемпотентность — по непустой `ClearanceUrl`.** Заявление можно согласовать
|
||||||
повторно (возврат на доработку → отправка → согласование), и без этой проверки
|
повторно (возврат на доработку → отправка → согласование), и без этой проверки
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export async function loadDismissalRequest(itemId: number, listId?: string): Pro
|
|||||||
'Phone',
|
'Phone',
|
||||||
'Comment',
|
'Comment',
|
||||||
'Organization',
|
'Organization',
|
||||||
|
'ClearanceUrl',
|
||||||
'RequestStatus',
|
'RequestStatus',
|
||||||
'RequestUniqueCode',
|
'RequestUniqueCode',
|
||||||
'Employee/Title',
|
'Employee/Title',
|
||||||
@@ -51,7 +52,9 @@ export async function loadDismissalRequest(itemId: number, listId?: string): Pro
|
|||||||
LastWorkingDay: lastDay.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
|
LastWorkingDay: lastDay.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
|
||||||
Phone: (item.Phone as string) || '',
|
Phone: (item.Phone as string) || '',
|
||||||
Comment: (item.Comment as string) || '',
|
Comment: (item.Comment as string) || '',
|
||||||
Organization: (item.Organization as string) || ''
|
Organization: (item.Organization as string) || '',
|
||||||
|
// Гиперссылка приходит объектом {Url, Description} — модели нужен только адрес.
|
||||||
|
ClearanceUrl: (item.ClearanceUrl as { Url?: string } | undefined)?.Url || ''
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ export interface IFormData {
|
|||||||
* read-only (см. shared/ui/OrganizationField).
|
* read-only (см. shared/ui/OrganizationField).
|
||||||
*/
|
*/
|
||||||
Organization: string;
|
Organization: string;
|
||||||
|
/**
|
||||||
|
* Ссылка на обходной лист, созданный по этому заявлению — колонка-гиперссылка
|
||||||
|
* `ClearanceUrl`. Форма её не пишет (заполняет автосоздание, см.
|
||||||
|
* createClearance.ts) и не даёт править — только показывает переходом.
|
||||||
|
*/
|
||||||
|
ClearanceUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Обновление одного поля формы с сохранением типа значения. */
|
/** Обновление одного поля формы с сохранением типа значения. */
|
||||||
@@ -64,7 +70,8 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
LastWorkingDay: '',
|
LastWorkingDay: '',
|
||||||
Phone: '',
|
Phone: '',
|
||||||
Comment: '',
|
Comment: '',
|
||||||
Organization: ''
|
Organization: '',
|
||||||
|
ClearanceUrl: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Плейсхолдер для поля выбора сотрудника. */
|
/** Плейсхолдер для поля выбора сотрудника. */
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
TextArea,
|
TextArea,
|
||||||
PhoneInput,
|
PhoneInput,
|
||||||
PeopleField,
|
PeopleField,
|
||||||
OrganizationField
|
OrganizationField,
|
||||||
|
LinkField
|
||||||
} from '../../../shared/ui';
|
} from '../../../shared/ui';
|
||||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
|
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
|
||||||
import { isoDatePlusDays } from '../../../shared/dateUtils';
|
import { isoDatePlusDays } from '../../../shared/dateUtils';
|
||||||
@@ -76,5 +77,12 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
|
|||||||
<FormField label="Комментарий" htmlFor="Comment" full>
|
<FormField label="Комментарий" htmlFor="Comment" full>
|
||||||
<TextArea id="Comment" value={data.Comment} onChange={(v) => set('Comment', v)} />
|
<TextArea id="Comment" value={data.Comment} onChange={(v) => set('Comment', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
{/* Появляется, только когда обходной лист уже создан (автоматически после
|
||||||
|
согласования заявления — см. createClearance.ts). */}
|
||||||
|
<LinkField
|
||||||
|
label="Обходной лист"
|
||||||
|
url={data.ClearanceUrl}
|
||||||
|
text="Открыть обходной лист по этому увольнению"
|
||||||
|
/>
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,38 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './LinkField.module.scss';
|
||||||
|
import { FormField, Icon } from '@tnsu/ui-kit';
|
||||||
|
|
||||||
|
interface ILinkFieldProps {
|
||||||
|
label: string;
|
||||||
|
/** Адрес. Пусто — поле не рендерится вовсе (см. ниже). */
|
||||||
|
url: string;
|
||||||
|
/** Текст ссылки. По умолчанию — сам адрес. */
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поле-ссылка: read-only, кликабельное, руками не редактируется.
|
||||||
|
*
|
||||||
|
* Пустой url — ничего не рендерим (а не пустую рамку): такие поля появляются
|
||||||
|
* только когда связь уже есть (напр. ClearanceUrl заявления на увольнение
|
||||||
|
* заполняется автосозданием обходного листа), и до этого момента их нет смысла
|
||||||
|
* показывать.
|
||||||
|
*
|
||||||
|
* Открывается в НОВОЙ вкладке: портал — одна страница, и переход по ссылке в
|
||||||
|
* текущей означал бы полную перезагрузку с потерей того, что открыто и, возможно,
|
||||||
|
* не сохранено.
|
||||||
|
*/
|
||||||
|
export const LinkField: React.FC<ILinkFieldProps> = ({ label, url, text }) => {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField label={label} full>
|
||||||
|
<div className={styles.box}>
|
||||||
|
<a className={styles.link} href={url} target="_blank" rel="noreferrer">
|
||||||
|
<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 { PeopleField } from './PeopleField/PeopleField';
|
||||||
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
||||||
export { OrganizationField } from './OrganizationField/OrganizationField';
|
export { OrganizationField } from './OrganizationField/OrganizationField';
|
||||||
|
export { LinkField } from './LinkField/LinkField';
|
||||||
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
|
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
|
||||||
export { SubmitActions } from './SubmitActions/SubmitActions';
|
export { SubmitActions } from './SubmitActions/SubmitActions';
|
||||||
export { InitiatorLine } from './InitiatorLine/InitiatorLine';
|
export { InitiatorLine } from './InitiatorLine/InitiatorLine';
|
||||||
|
|||||||
Reference in New Issue
Block a user