Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22e37b6e36 | |||
| f654db9bcf | |||
| 5cfe54f33b | |||
| 0c0104f6c9 | |||
| f664b468a2 | |||
| 2bf0ad1da7 | |||
| 6460a2a22d | |||
| edd5008120 | |||
| 5107f62e54 | |||
| 780d92cd3d | |||
| 023c0913f1 | |||
| e976b4e8d4 | |||
| 093bbecd9f | |||
| 59b0fbc569 | |||
| 984eb65f5b | |||
| b3c46f32d5 | |||
| 739f247268 | |||
| 4f0d02fe59 | |||
| ac1b66d0df | |||
| e811186cec | |||
| 91c66bbf81 | |||
| 7fce5a789c | |||
| ea6eb3663b | |||
| 5d2abaf430 | |||
| 99f3558e4a | |||
| a6f4218e7e | |||
| 96f53da020 | |||
| d6bb76fe47 | |||
| 4b05da5469 | |||
| 41cce93b41 | |||
| 664b58a44a | |||
| 755cfc8329 | |||
| e46632d67a | |||
| 8330790bf9 | |||
| af93ae57f4 | |||
| 79963e4035 |
@@ -17,6 +17,7 @@ const READ_VIA_POST = [
|
|||||||
/\/GetItems\b/i,
|
/\/GetItems\b/i,
|
||||||
/\/_api\/search\/postquery/i,
|
/\/_api\/search\/postquery/i,
|
||||||
/\/GetUpdatedFormDigest/i,
|
/\/GetUpdatedFormDigest/i,
|
||||||
|
/\/_api\/web\/GetClientSideComponents/i, // манифесты веб-партов, без них страница не грузит SPFx
|
||||||
/\/_api\/SP\.Utilities\.Utility\.ResolvePrincipalInCurrentContext/i,
|
/\/_api\/SP\.Utilities\.Utility\.ResolvePrincipalInCurrentContext/i,
|
||||||
/\/_api\/SP\.UI\.ApplicationPages\.ClientPeoplePickerWebServiceInterface\.clientPeoplePickerSearchUser/i,
|
/\/_api\/SP\.UI\.ApplicationPages\.ClientPeoplePickerWebServiceInterface\.clientPeoplePickerSearchUser/i,
|
||||||
/\/_api\/sp\.userprofiles\.peoplemanager/i,
|
/\/_api\/sp\.userprofiles\.peoplemanager/i,
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ let AxeBuilder = null;
|
|||||||
try { ({ default: AxeBuilder } = await import('@axe-core/playwright')); } catch { /* axe не установлен */ }
|
try { ({ default: AxeBuilder } = await import('@axe-core/playwright')); } catch { /* axe не установлен */ }
|
||||||
|
|
||||||
await fs.mkdir(out, { recursive: true });
|
await fs.mkdir(out, { recursive: true });
|
||||||
const browser = await chromium.launch();
|
// Local Network Access в свежем Chromium режет загрузку https://localhost:4321 с публичного сайта SharePoint
|
||||||
|
const LOCAL_BUILD_ARGS = ['--disable-features=LocalNetworkAccessChecks,PrivateNetworkAccessSendPreflights,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessRespectPreflightResults'];
|
||||||
|
const browser = await chromium.launch(args['local-build'] ? { args: LOCAL_BUILD_ARGS } : {});
|
||||||
const result = { url, surface: tenant ? 'tenant' : 'harness', date: new Date().toISOString(), axe: Boolean(AxeBuilder), runs: [] };
|
const result = { url, surface: tenant ? 'tenant' : 'harness', date: new Date().toISOString(), axe: Boolean(AxeBuilder), runs: [] };
|
||||||
|
|
||||||
// Признаки непереведённых строк и утёкших значений в тексте
|
// Признаки непереведённых строк и утёкших значений в тексте
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; маршрут лентой (точки = город + объект из справочника + даты, участки с видом транспорта); задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
|
| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; маршрут лентой (точки = город + объект из справочника + даты, участки с видом транспорта); задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
|
||||||
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
||||||
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
||||||
|
| [`acc-payment-request`](../src/webparts/requestPortal/forms/PaymentRequest/README.md) | Заявка на оплату | Бухгалтерия | да | нет | да | основание — согласованная СТ (сайт СТ) или потребность; счёт PDF → библиотека → OCR → ИИ (реквизиты, суммы, позиции); сверка позиций счёта с СТ; подсветка чужого получателя и превышения общей суммы СТ; справочники 1С | ⏳ списки, библиотека, тип (id 24) и маршрут заведены; не проверена на живых данных |
|
||||||
|
|
||||||
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
||||||
**Просмотр** — есть режим согласующего (`<Форма>View`).
|
**Просмотр** — есть режим согласующего (`<Форма>View`).
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import {
|
|||||||
EDO_API_BASE_URL_STAGE,
|
EDO_API_BASE_URL_STAGE,
|
||||||
setApprovalApiBaseUrl,
|
setApprovalApiBaseUrl,
|
||||||
setEdoApiBaseUrl,
|
setEdoApiBaseUrl,
|
||||||
setNotificationSettings
|
setNotificationSettings,
|
||||||
|
setChatSelfNotify
|
||||||
} from './shared/config';
|
} from './shared/config';
|
||||||
import { initListPartitions } from './shared/approval';
|
import { initListPartitions } from './shared/approval';
|
||||||
import { ErrorOverlay } from './shared/ui';
|
import { ErrorOverlay } from './shared/ui';
|
||||||
@@ -54,6 +55,11 @@ export interface IRequestPortalWebPartProps {
|
|||||||
notificationsEnabled?: boolean;
|
notificationsEnabled?: boolean;
|
||||||
/** Непустой адрес — письма только если активный шаг назначен на него (обкатка). Пусто — всем. */
|
/** Непустой адрес — письма только если активный шаг назначен на него (обкатка). Пусто — всем. */
|
||||||
notifyOnlyTo?: string;
|
notifyOnlyTo?: string;
|
||||||
|
/**
|
||||||
|
* Для проверки чата: письма и о СВОИХ сообщениях, и можно отметить себя через «@».
|
||||||
|
* По умолчанию выключено (undefined — тоже «выкл»).
|
||||||
|
*/
|
||||||
|
chatSelfNotify?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> {
|
export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> {
|
||||||
@@ -126,6 +132,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
|
|
||||||
private _applyNotificationSettings(): void {
|
private _applyNotificationSettings(): void {
|
||||||
setNotificationSettings(this.properties.notificationsEnabled !== false, this.properties.notifyOnlyTo || '');
|
setNotificationSettings(this.properties.notificationsEnabled !== false, this.properties.notifyOnlyTo || '');
|
||||||
|
setChatSelfNotify(this.properties.chatSelfNotify === true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getEnvironmentMessage(): Promise<string> {
|
private _getEnvironmentMessage(): Promise<string> {
|
||||||
@@ -259,6 +266,12 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
description: 'Для обкатки: письмо уходит, только если шаг назначен на этот e-mail. Пусто — всем.',
|
description: 'Для обкатки: письмо уходит, только если шаг назначен на этот e-mail. Пусто — всем.',
|
||||||
placeholder: 'user@tnsu.kz',
|
placeholder: 'user@tnsu.kz',
|
||||||
disabled: this.properties.notificationsEnabled === false
|
disabled: this.properties.notificationsEnabled === false
|
||||||
|
}),
|
||||||
|
PropertyPaneToggle('chatSelfNotify', {
|
||||||
|
label: 'Чат: уведомлять о своих сообщениях (для теста)',
|
||||||
|
onText: 'Да — и можно отметить себя через «@»',
|
||||||
|
offText: 'Нет',
|
||||||
|
checked: this.properties.chatSelfNotify === true
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
// Плитки одного ряда — одной высоты, даже если заголовок у соседа в 3 строки.
|
||||||
|
height: 100%;
|
||||||
padding: 22px 24px;
|
padding: 22px 24px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: $white;
|
background: $white;
|
||||||
@@ -122,7 +124,9 @@
|
|||||||
.tileBody {
|
.tileBody {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
// Без этого плашка «В разработке» растягивается на ширину заголовка.
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -468,7 +468,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
<SubmitFeature
|
<SubmitFeature
|
||||||
key={
|
key={
|
||||||
route.mode === 'create' && route.formKey
|
route.mode === 'create' && route.formKey
|
||||||
? `create-${route.formKey}-${route.sourceId ?? route.positionId ?? 'blank'}`
|
? `create-${route.formKey}-${route.sourceId ?? route.positionId ?? 'blank'}-${route.listId ?? ''}`
|
||||||
: 'blank'
|
: 'blank'
|
||||||
}
|
}
|
||||||
context={context}
|
context={context}
|
||||||
@@ -478,6 +478,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
? {
|
? {
|
||||||
formKey: route.formKey,
|
formKey: route.formKey,
|
||||||
sourceId: route.sourceId,
|
sourceId: route.sourceId,
|
||||||
|
sourceListId: route.listId,
|
||||||
positionId: route.positionId,
|
positionId: route.positionId,
|
||||||
nomenclatureId: route.nomenclatureId,
|
nomenclatureId: route.nomenclatureId,
|
||||||
storeName: route.storeName,
|
storeName: route.storeName,
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ export const REQUEST_TYPES: IRequestType[] = [
|
|||||||
title: 'Заявка на услуги',
|
title: 'Заявка на услуги',
|
||||||
icon: 'document'
|
icon: 'document'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'acc-payment-request',
|
||||||
|
categoryId: 'accounting',
|
||||||
|
title: 'Заявка на оплату',
|
||||||
|
icon: 'calculator'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'wh-concrete-request',
|
id: 'wh-concrete-request',
|
||||||
categoryId: 'warehouse',
|
categoryId: 'warehouse',
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const ApproveFeature: React.FC<IApproveFeatureProps> = ({ context, formKey, item
|
|||||||
{/* Доступно всем, кто может открыть заявку: автору, всем согласующим
|
{/* Доступно всем, кто может открыть заявку: автору, всем согласующим
|
||||||
(и уже решившим, и ещё ждущим своей очереди) и исполнителям —
|
(и уже решившим, и ещё ждущим своей очереди) и исполнителям —
|
||||||
в отличие от панели решения, которая видна только текущему шагу.
|
в отличие от панели решения, которая видна только текущему шагу.
|
||||||
Пока включено только для «Обходного листа» — см. isChatEnabledFor. */}
|
Включено не для всех типов заявок — см. isChatEnabledFor. */}
|
||||||
{isChatEnabledFor(formKey) && (
|
{isChatEnabledFor(formKey) && (
|
||||||
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, li
|
|||||||
{/* Обсуждение — то же самое, что видят согласующие (комментарии к строке
|
{/* Обсуждение — то же самое, что видят согласующие (комментарии к строке
|
||||||
заявки). Здесь оно особенно нужно: заявку правят как раз после
|
заявки). Здесь оно особенно нужно: заявку правят как раз после
|
||||||
возврата на доработку, и уточнить причину логично не уходя с формы.
|
возврата на доработку, и уточнить причину логично не уходя с формы.
|
||||||
Пока включено только для «Обходного листа» — см. isChatEnabledFor. */}
|
Включено не для всех типов заявок — см. isChatEnabledFor. */}
|
||||||
{isChatEnabledFor(formKey) && (
|
{isChatEnabledFor(formKey) && (
|
||||||
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ interface ITableColumns {
|
|||||||
assignee: boolean;
|
assignee: boolean;
|
||||||
/** Колонка «Исполнено» — кто из исполнителей уже выполнил (см. showsExecuted). */
|
/** Колонка «Исполнено» — кто из исполнителей уже выполнил (см. showsExecuted). */
|
||||||
executed: boolean;
|
executed: boolean;
|
||||||
/** Колонка «Дата выхода» — только «Все заявки» по организации рабочего места. */
|
/** Колонка «Дата выхода» — только таблица из заявок на организацию рабочего места (см. showsStartDate). */
|
||||||
startDate?: boolean;
|
startDate?: boolean;
|
||||||
/** Заголовок колонки с комментарием; undefined — колонки нет. */
|
/** Заголовок колонки с комментарием; undefined — колонки нет. */
|
||||||
commentLabel?: string;
|
commentLabel?: string;
|
||||||
@@ -178,9 +178,15 @@ function showsExecuted(view: ListView, statusFilter: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Дата выхода сотрудника есть только у заявки на организацию рабочего места, и показываем её во «Все заявки». */
|
/**
|
||||||
function showsStartDate(view: ListView, type: IRequestType | undefined): boolean {
|
* Дата выхода сотрудника есть только у заявки на организацию рабочего места.
|
||||||
return view === 'all' && type?.pageUrl === WORKPLACE_SETUP_FORM_ID;
|
* Показываем её, когда таблица целиком из таких заявок: «Все заявки» этого типа
|
||||||
|
* или любой раздел с фильтром по этому типу. Без фильтра типы вперемешку —
|
||||||
|
* колонка была бы почти пустой.
|
||||||
|
*/
|
||||||
|
function showsStartDate(view: ListView, allRequestsType: IRequestType | undefined, filteredType: IRequestType | undefined): boolean {
|
||||||
|
const tableType = view === 'all' ? allRequestsType : filteredType;
|
||||||
|
return tableType?.pageUrl === WORKPLACE_SETUP_FORM_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
function columnsFor(view: ListView, statusFilter: string, startDate = false): ITableColumns {
|
function columnsFor(view: ListView, statusFilter: string, startDate = false): ITableColumns {
|
||||||
@@ -194,15 +200,15 @@ function columnsFor(view: ListView, statusFilter: string, startDate = false): IT
|
|||||||
? 'Причина возврата'
|
? 'Причина возврата'
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee, executed };
|
if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee, executed, startDate };
|
||||||
if (view === 'rework') return { author: false, status: false, assignee, executed, commentLabel };
|
if (view === 'rework') return { author: false, status: false, assignee, executed, startDate, commentLabel };
|
||||||
if (view === 'own:created') return { author: false, status: !statusFilter, assignee, executed, commentLabel };
|
if (view === 'own:created') return { author: false, status: !statusFilter, assignee, executed, startDate, commentLabel };
|
||||||
// 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус.
|
// 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус.
|
||||||
if (view === 'all') return { author: true, status: true, assignee, executed, startDate };
|
if (view === 'all') return { author: true, status: true, assignee, executed, startDate };
|
||||||
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он
|
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он
|
||||||
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
|
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
|
||||||
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
|
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
|
||||||
return { author: true, status: !decisionStatus, assignee, executed, commentLabel };
|
return { author: true, status: !decisionStatus, assignee, executed, startDate, commentLabel };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IRequestsTableViewProps {
|
export interface IRequestsTableViewProps {
|
||||||
@@ -494,6 +500,8 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
});
|
});
|
||||||
const { localFilters, sort, isRefined } = refinement;
|
const { localFilters, sort, isRefined } = refinement;
|
||||||
const refinementSig = refinement.signature;
|
const refinementSig = refinement.signature;
|
||||||
|
const filteredType = types?.find((t) => t.id === localFilters.typeId);
|
||||||
|
const withStartDate = showsStartDate(view, allRequestsType, filteredType);
|
||||||
|
|
||||||
// Полный список раздела для режима с фильтрами — последний загруженный.
|
// Полный список раздела для режима с фильтрами — последний загруженный.
|
||||||
// Листание страниц и смена фильтра его переиспользуют (заново по сети не
|
// Листание страниц и смена фильтра его переиспользуют (заново по сети не
|
||||||
@@ -664,7 +672,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
decisionStatus ? hydrateDecisionComments(context, types, result.rows, decisionStatus) : undefined,
|
decisionStatus ? hydrateDecisionComments(context, types, result.rows, decisionStatus) : undefined,
|
||||||
showsAssignee(view, statusFilter) ? hydrateCurrentAssignees(context, types, result.rows) : undefined,
|
showsAssignee(view, statusFilter) ? hydrateCurrentAssignees(context, types, result.rows) : undefined,
|
||||||
showsExecuted(view, statusFilter) ? hydrateExecutedSteps(context, types, result.rows) : undefined,
|
showsExecuted(view, statusFilter) ? hydrateExecutedSteps(context, types, result.rows) : undefined,
|
||||||
showsStartDate(view, allRequestsType) ? hydrateStartDates(context, result.rows) : undefined,
|
withStartDate ? hydrateStartDates(context, result.rows) : undefined,
|
||||||
]);
|
]);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
@@ -850,7 +858,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
rows={visible}
|
rows={visible}
|
||||||
view={view}
|
view={view}
|
||||||
statusFilter={statusFilter}
|
statusFilter={statusFilter}
|
||||||
withStartDate={showsStartDate(view, allRequestsType)}
|
withStartDate={withStartDate}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
onSort={refinement.onSort}
|
onSort={refinement.onSort}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function asDay(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Разобрать сохранённое значение; всё, что не прошло проверку, — как «не задано». */
|
/** Разобрать сохранённое значение; всё, что не прошло проверку, — как «не задано». */
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- так отдаёт localStorage.getItem, если ключа нет
|
||||||
export function parseStoredRefinement(raw: string | null): IStoredRefinement | undefined {
|
export function parseStoredRefinement(raw: string | null): IStoredRefinement | undefined {
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
let parsed: { filters?: Record<string, unknown>; sort?: Record<string, unknown> };
|
let parsed: { filters?: Record<string, unknown>; sort?: Record<string, unknown> };
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface ISubmitFeatureProps {
|
|||||||
initialForm?: {
|
initialForm?: {
|
||||||
formKey: string;
|
formKey: string;
|
||||||
sourceId?: number;
|
sourceId?: number;
|
||||||
|
/** Партиция исходной заявки (rplist) — см. IRequestFormProps.sourceListId. */
|
||||||
|
sourceListId?: string;
|
||||||
positionId?: string;
|
positionId?: string;
|
||||||
nomenclatureId?: string;
|
nomenclatureId?: string;
|
||||||
storeName?: string;
|
storeName?: string;
|
||||||
@@ -148,6 +150,7 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
|
|||||||
onCancel={handleBack}
|
onCancel={handleBack}
|
||||||
onSaved={emitRequestsChanged}
|
onSaved={emitRequestsChanged}
|
||||||
sourceId={sourceId}
|
sourceId={sourceId}
|
||||||
|
sourceListId={sourceId ? external?.sourceListId : undefined}
|
||||||
editId={editId}
|
editId={editId}
|
||||||
positionId={external?.positionId}
|
positionId={external?.positionId}
|
||||||
nomenclatureId={external?.nomenclatureId}
|
nomenclatureId={external?.nomenclatureId}
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ import {
|
|||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
IUseMatrixEditor,
|
IUseMatrixEditor,
|
||||||
unassignedStepMessage,
|
unassignedStepMessage,
|
||||||
resolveWriteListId
|
resolveWriteListId,
|
||||||
|
composeRemoveRowRule
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import {
|
import {
|
||||||
@@ -244,7 +246,11 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
|||||||
<ObligationSection {...sectionProps} />
|
<ObligationSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix
|
||||||
|
editor={matrix}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
|
/>
|
||||||
|
|
||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
@@ -253,6 +259,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
|||||||
title="Исполнение (после согласования)"
|
title="Исполнение (после согласования)"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
emptyText="Исполнители не заданы по маршруту."
|
emptyText="Исполнители не заданы по маршруту."
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
|
|||||||
title="Исполнение (после согласования)"
|
title="Исполнение (после согласования)"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
emptyText="Исполнители не заданы по маршруту."
|
emptyText="Исполнители не заданы по маршруту."
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
margin-bottom: $sp-4;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: $navy-700;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .18s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import styles from './PaymentRequestForm.module.scss';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
CollapseSection,
|
||||||
|
FormCard,
|
||||||
|
InitiatorLine,
|
||||||
|
FormModeProvider,
|
||||||
|
Icon,
|
||||||
|
RequestNumber,
|
||||||
|
SubmitActions,
|
||||||
|
getDraftDeleteHandler,
|
||||||
|
useRequestSubmit
|
||||||
|
} from '../../shared/ui';
|
||||||
|
import {
|
||||||
|
composeRemoveRowRule,
|
||||||
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
|
StatusBadge,
|
||||||
|
useMatrixEditor,
|
||||||
|
getPlannedMatrix,
|
||||||
|
loadRoutePhase,
|
||||||
|
isOpenedRouteReady,
|
||||||
|
notifyActiveStep,
|
||||||
|
useRequestTypeId,
|
||||||
|
REQUEST_STATUS,
|
||||||
|
isEditableStatus,
|
||||||
|
IApproverToWrite,
|
||||||
|
unassignedStepMessage,
|
||||||
|
resolveWriteListId,
|
||||||
|
useFormExecutors,
|
||||||
|
ExecutorsMatrix,
|
||||||
|
executorsToWrite,
|
||||||
|
initialSubmitStatus
|
||||||
|
} from '../../shared/approval';
|
||||||
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
|
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||||
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
|
import { todayIsoDate } from '../../shared/dateUtils';
|
||||||
|
import { IRequestFormProps } from '../../models/types';
|
||||||
|
import { approvalListTitle, approvalListTitleForListId, dataListTitle, dataListTitleForListId } from '../../shared/listTitles';
|
||||||
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
|
import { savePaymentRequest, paymentRequestApprovalService } from './submit';
|
||||||
|
import { loadPaymentRequest } from './load';
|
||||||
|
import { loadPreviousPayments } from './services/previousPayments.service';
|
||||||
|
import { validatePayment } from './validation';
|
||||||
|
import { useBasisSelection } from './useBasisSelection';
|
||||||
|
import { useInvoiceFlow } from './useInvoiceFlow';
|
||||||
|
import { HeaderSection } from './sections/HeaderSection';
|
||||||
|
import { BasisSection } from './sections/BasisSection';
|
||||||
|
import { PaymentSection } from './sections/PaymentSection';
|
||||||
|
import { AmountsSection } from './sections/AmountsSection';
|
||||||
|
import { InvoiceSection } from './sections/InvoiceSection';
|
||||||
|
import { PositionsSection } from './sections/PositionsSection';
|
||||||
|
|
||||||
|
const PaymentRequestForm: React.FC<IRequestFormProps> = ({
|
||||||
|
requestType,
|
||||||
|
context,
|
||||||
|
onCancel,
|
||||||
|
onSaved,
|
||||||
|
editId,
|
||||||
|
editListId,
|
||||||
|
onCodeLoaded
|
||||||
|
}) => {
|
||||||
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
|
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||||
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
|
const submit = useRequestSubmit();
|
||||||
|
|
||||||
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
|
|
||||||
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
const patch = useCallback((changes: Partial<IFormData>): void => setData((prev) => ({ ...prev, ...changes })), []);
|
||||||
|
|
||||||
|
const basis = useBasisSelection(context, patch, requestId);
|
||||||
|
const invoice = useInvoiceFlow(context, data, patch);
|
||||||
|
|
||||||
|
// Новая заявка — предзаполняем заявителя текущим пользователем и дату сегодняшней.
|
||||||
|
useEffect(() => {
|
||||||
|
if (editId) return undefined;
|
||||||
|
const requesterEmail = effectiveUserEmail(context);
|
||||||
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
RequesterEmail: requesterEmail,
|
||||||
|
RequesterName: context.pageContext.user.displayName || '',
|
||||||
|
RequestDate: prev.RequestDate || todayIsoDate()
|
||||||
|
}));
|
||||||
|
let alive = true;
|
||||||
|
getEmployeeOrganizations(context, requesterEmail)
|
||||||
|
.then((records) => {
|
||||||
|
if (alive) {
|
||||||
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
Organization: formatEmployeeOrganizations(records),
|
||||||
|
RequesterJobTitle: prev.RequesterJobTitle || records[0]?.position || '',
|
||||||
|
Department: prev.Department || records[0]?.section || '',
|
||||||
|
DepartmentId: prev.DepartmentId || records[0]?.departmentId || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [editId, context]);
|
||||||
|
|
||||||
|
// Выбор другого заявителя подтягивает его ФИО, должность, подразделение и организации.
|
||||||
|
const handleRequesterChange = (login: string): void => {
|
||||||
|
patch({ RequesterEmail: login });
|
||||||
|
if (!login) {
|
||||||
|
patch({ Organization: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getEmployeeOrganizations(context, login)
|
||||||
|
.then((records) =>
|
||||||
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
RequesterName: records[0]?.fio || prev.RequesterName,
|
||||||
|
RequesterJobTitle: records[0]?.position || prev.RequesterJobTitle,
|
||||||
|
Department: records[0]?.section || prev.Department,
|
||||||
|
DepartmentId: records[0]?.departmentId || prev.DepartmentId,
|
||||||
|
Organization: formatEmployeeOrganizations(records)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Открыта СВОЯ заявка на редактирование — подгружаем содержимое и статус.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editId) return undefined;
|
||||||
|
let alive = true;
|
||||||
|
loadPaymentRequest(editId, editListId)
|
||||||
|
.then((loaded) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setData(loaded.data);
|
||||||
|
setRequestStatus(loaded.status);
|
||||||
|
setRequestCode(loaded.code || undefined);
|
||||||
|
if (loaded.code) onCodeLoaded?.(loaded.code);
|
||||||
|
// В элементе лежит снимок прежних оплат на момент сохранения. Пока заявку
|
||||||
|
// ещё правят (черновик, возврат), перечитываем их: по основанию могли
|
||||||
|
// подать новые, а лимит суммы должен считаться по актуальным.
|
||||||
|
if (isEditableStatus(loaded.status) && loaded.data.BasisKey) {
|
||||||
|
loadPreviousPayments(context, loaded.data.BasisKey, editId)
|
||||||
|
.then((previous) => {
|
||||||
|
if (alive) patch({ PreviousPayments: previous });
|
||||||
|
})
|
||||||
|
.catch(() => undefined); // остаётся снимок — лучше, чем пустая таблица
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [editId, editListId]);
|
||||||
|
|
||||||
|
const approval = useMemo(() => paymentRequestApprovalService(editListId), [editListId]);
|
||||||
|
const userEmail = data.RequesterEmail;
|
||||||
|
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||||
|
const requestTypeId = requestTypeIdState.id;
|
||||||
|
// Маршрут зависит от проекта — пока он не выбран, resolve вернёт 404.
|
||||||
|
const projectId = data.ProjectId || null;
|
||||||
|
const matrixReady =
|
||||||
|
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!userEmail && !!projectId && !!requestTypeId));
|
||||||
|
const loadMatrix = useCallback(
|
||||||
|
() =>
|
||||||
|
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
||||||
|
getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, undefined, projectId)
|
||||||
|
),
|
||||||
|
[context, userEmail, approval, editId, requestStatus, projectId, requestTypeId]
|
||||||
|
);
|
||||||
|
const matrix = useMatrixEditor(loadMatrix, `${userEmail}-${editId ?? 'new'}-${projectId}`, !readOnly, matrixReady, (login) =>
|
||||||
|
approval.getJobTitle(login)
|
||||||
|
);
|
||||||
|
const executors = useFormExecutors({
|
||||||
|
readOnly,
|
||||||
|
context,
|
||||||
|
approval,
|
||||||
|
requestTypeId,
|
||||||
|
userEmail: matrixReady ? userEmail : '',
|
||||||
|
editId,
|
||||||
|
requestStatus,
|
||||||
|
projectId
|
||||||
|
});
|
||||||
|
|
||||||
|
const formError = validatePayment(data);
|
||||||
|
const isValid = !formError;
|
||||||
|
const validate = (): string | undefined => {
|
||||||
|
if (formError) return formError;
|
||||||
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||||
|
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign, order: r.order }));
|
||||||
|
|
||||||
|
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||||
|
const rtId = requestTypeId;
|
||||||
|
if (!rtId) {
|
||||||
|
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||||
|
}
|
||||||
|
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||||
|
const { id, code } = await savePaymentRequest(requestId, rtId, data, toApprovers(matrix.rows), executorsToWrite(executors.rows), status, writeListId);
|
||||||
|
setRequestId(id);
|
||||||
|
setRequestStatus(status);
|
||||||
|
if (code) setRequestCode(code);
|
||||||
|
if (onSaved) onSaved();
|
||||||
|
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||||
|
await notifyActiveStep(context, approval, id, {
|
||||||
|
requestTypeId: rtId,
|
||||||
|
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||||
|
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateSubmit = (): string | undefined => {
|
||||||
|
const error = validate();
|
||||||
|
if (error) return error;
|
||||||
|
if (matrix.error) {
|
||||||
|
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
unassignedStepMessage(
|
||||||
|
matrix.rows,
|
||||||
|
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||||
|
) ||
|
||||||
|
unassignedStepMessage(
|
||||||
|
executors.rows,
|
||||||
|
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||||
|
const handleSubmit = (): Promise<void> =>
|
||||||
|
submit.run(
|
||||||
|
'submit',
|
||||||
|
save(executors.rows.length > 0 ? initialSubmitStatus(matrix.rows.length, executors.rows.length) : REQUEST_STATUS.onApproval),
|
||||||
|
validateSubmit
|
||||||
|
);
|
||||||
|
|
||||||
|
const sectionProps = { data, set, patch, context, onRequesterChange: handleRequesterChange };
|
||||||
|
|
||||||
|
const handleDelete = getDraftDeleteHandler({
|
||||||
|
formKey: FORM_ID,
|
||||||
|
requestId,
|
||||||
|
requestStatus,
|
||||||
|
listId: editListId,
|
||||||
|
code: requestCode,
|
||||||
|
submit,
|
||||||
|
onDeleted: onCancel
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button type="button" className={styles.back} onClick={onCancel}>
|
||||||
|
<Icon name="arrow-left" size="sm" />
|
||||||
|
<span>К списку заявок</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<FormCard
|
||||||
|
title={requestType.title}
|
||||||
|
subtitle="Категория: Бухгалтерия"
|
||||||
|
belowSubtitle={
|
||||||
|
<InitiatorLine
|
||||||
|
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
|
||||||
|
itemId={requestId}
|
||||||
|
fallback={{ name: data.RequesterName, email: data.RequesterEmail }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
headerRight={
|
||||||
|
requestId || requestStatus ? (
|
||||||
|
<>
|
||||||
|
{requestStatus && <StatusBadge status={requestStatus} />}
|
||||||
|
{requestCode && <RequestNumber code={requestCode} />}
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{submit.savedKind && !submit.error && (
|
||||||
|
<Alert variant="ok">{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}</Alert>
|
||||||
|
)}
|
||||||
|
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||||
|
|
||||||
|
<FormModeProvider readOnly={readOnly}>
|
||||||
|
<HeaderSection {...sectionProps} />
|
||||||
|
<CollapseSection title="Основание" icon="document">
|
||||||
|
<BasisSection {...sectionProps} basis={basis} />
|
||||||
|
</CollapseSection>
|
||||||
|
<CollapseSection title="Вложения — счёт на оплату" icon="document">
|
||||||
|
<InvoiceSection data={data} invoice={invoice} />
|
||||||
|
</CollapseSection>
|
||||||
|
<CollapseSection title="Реквизиты платежа" icon="calculator">
|
||||||
|
<PaymentSection {...sectionProps} />
|
||||||
|
</CollapseSection>
|
||||||
|
<CollapseSection title="Суммы" icon="calculator">
|
||||||
|
<AmountsSection {...sectionProps} />
|
||||||
|
</CollapseSection>
|
||||||
|
<CollapseSection title="Позиции" icon="clipboard">
|
||||||
|
<PositionsSection rows={data.Positions} compared={!!data.InvoiceFileName} />
|
||||||
|
</CollapseSection>
|
||||||
|
</FormModeProvider>
|
||||||
|
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={matrix}
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
waiting={!matrixReady}
|
||||||
|
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
|
||||||
|
/>
|
||||||
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
|
{editId && (
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory approval={approval} dataListRef={editListId || dataListTitle(FORM_ID)} requestId={editId} reloadKey={requestStatus} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SubmitActions
|
||||||
|
onDelete={handleDelete}
|
||||||
|
submit={submit}
|
||||||
|
isValid={isValid}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onSaveDraft={handleSaveDraft}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
</FormCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaymentRequestForm;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.state {
|
||||||
|
padding: $sp-8;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: $sp-3;
|
||||||
|
margin-bottom: $sp-4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import styles from './PaymentRequestView.module.scss';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
CollapseSection,
|
||||||
|
FormCard,
|
||||||
|
InitiatorLine,
|
||||||
|
FormModeProvider,
|
||||||
|
PrintSplitButton,
|
||||||
|
OrgLetterhead,
|
||||||
|
Icon
|
||||||
|
} from '../../shared/ui';
|
||||||
|
import { navigate } from '../../shared/routing';
|
||||||
|
import {
|
||||||
|
ApproverService,
|
||||||
|
DecisionPanel,
|
||||||
|
StatusBadge,
|
||||||
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
|
useMatrixEditor,
|
||||||
|
useViewExecutors,
|
||||||
|
ExecutorsMatrix,
|
||||||
|
approvalPhaseRows,
|
||||||
|
notifyActiveStep,
|
||||||
|
useRequestTypeId,
|
||||||
|
isEditableStatus
|
||||||
|
} from '../../shared/approval';
|
||||||
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
import { approvalListTitle, approvalListTitleForListId, dataListTitle, dataListTitleForListId } from '../../shared/listTitles';
|
||||||
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
|
import { loadPaymentRequest } from './load';
|
||||||
|
import { paymentRequestApprovalService } from './submit';
|
||||||
|
import { HeaderSection } from './sections/HeaderSection';
|
||||||
|
import { PaymentSection } from './sections/PaymentSection';
|
||||||
|
import { AmountsSection } from './sections/AmountsSection';
|
||||||
|
import { InvoiceSection } from './sections/InvoiceSection';
|
||||||
|
import { PositionsSection } from './sections/PositionsSection';
|
||||||
|
import { IInvoiceFlow } from './useInvoiceFlow';
|
||||||
|
|
||||||
|
interface IPaymentRequestViewProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
itemId: number;
|
||||||
|
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
|
||||||
|
formKey: string;
|
||||||
|
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
|
||||||
|
listId?: string;
|
||||||
|
/** Уведомление о загруженном уникальном коде заявки — см. IApprovalViewProps.onCodeLoaded. */
|
||||||
|
onCodeLoaded?: (code: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** В просмотре счёт не загружается: поток нужен только секции как заглушка пропсов. */
|
||||||
|
const IDLE_INVOICE: IInvoiceFlow = { stage: 'idle', error: '', attach: () => Promise.resolve() };
|
||||||
|
|
||||||
|
// Заявка на оплату только для просмотра (режим согласующего).
|
||||||
|
const PaymentRequestView: React.FC<IPaymentRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
|
const [status, setStatus] = useState<string>('');
|
||||||
|
const [code, setCode] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
|
||||||
|
|
||||||
|
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
||||||
|
const set: SetField = () => undefined;
|
||||||
|
const patch = (): void => undefined;
|
||||||
|
|
||||||
|
const approval = useMemo(() => paymentRequestApprovalService(listId), [listId]);
|
||||||
|
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||||
|
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||||
|
|
||||||
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
|
const loadApprovers = React.useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||||
|
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l));
|
||||||
|
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setLoading(true);
|
||||||
|
loadPaymentRequest(itemId, listId)
|
||||||
|
.then((loaded) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setData(loaded.data);
|
||||||
|
setStatus(loaded.status);
|
||||||
|
setCode(loaded.code);
|
||||||
|
notifyCode(loaded.code);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) {
|
||||||
|
setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
|
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, patch, context, printOrgIndex };
|
||||||
|
|
||||||
|
const notify = (): void => {
|
||||||
|
if (!requestTypeId) return;
|
||||||
|
notifyActiveStep(context, approval, itemId, {
|
||||||
|
requestTypeId,
|
||||||
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||||
|
}).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
|
{isEditableStatus(status) && (
|
||||||
|
<Button variant="secondary" onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId, listId })}>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rp-print-root">
|
||||||
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
|
<FormCard
|
||||||
|
title="Заявка на оплату"
|
||||||
|
subtitle={`№${code || itemId}`}
|
||||||
|
belowSubtitle={<InitiatorLine listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)} itemId={itemId} />}
|
||||||
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
|
>
|
||||||
|
<FormModeProvider readOnly>
|
||||||
|
<HeaderSection {...sectionProps} />
|
||||||
|
<CollapseSection title="Основание" icon="document">
|
||||||
|
<div>
|
||||||
|
{data.BasisTitle} · {data.ProjectName} · {data.CompanyName}
|
||||||
|
</div>
|
||||||
|
</CollapseSection>
|
||||||
|
<CollapseSection title="Вложения — счёт на оплату" icon="document">
|
||||||
|
<InvoiceSection data={data} invoice={IDLE_INVOICE} />
|
||||||
|
</CollapseSection>
|
||||||
|
<CollapseSection title="Реквизиты платежа" icon="calculator">
|
||||||
|
<PaymentSection {...sectionProps} />
|
||||||
|
</CollapseSection>
|
||||||
|
<CollapseSection title="Суммы" icon="calculator">
|
||||||
|
<AmountsSection {...sectionProps} />
|
||||||
|
</CollapseSection>
|
||||||
|
<CollapseSection title="Позиции" icon="clipboard">
|
||||||
|
<PositionsSection rows={data.Positions} compared={!!data.InvoiceFileName} />
|
||||||
|
</CollapseSection>
|
||||||
|
</FormModeProvider>
|
||||||
|
</FormCard>
|
||||||
|
|
||||||
|
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||||
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<DecisionPanel
|
||||||
|
service={approverService}
|
||||||
|
requestId={itemId}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
onDecided={(newStatus) => {
|
||||||
|
setStatus(newStatus);
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
notify();
|
||||||
|
}}
|
||||||
|
onDelegated={() => {
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
notify();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaymentRequestView;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Заявка на оплату (`acc-payment-request`)
|
||||||
|
|
||||||
|
**Назначение:** оплата по согласованной СТ (сравнительной таблице) или по потребности — счёт, получатель, суммы и сверка позиций счёта с СТ.
|
||||||
|
**Категория:** Бухгалтерия
|
||||||
|
**Списки SP:** «Заявка на оплату — данные», «Заявка на оплату — согласование»; библиотека «Счета на оплату» (создаётся вручную, колонка `file_content` — многострочный текст)
|
||||||
|
**RequestTypeId (внешний API маршрутов):** резолвится в рантайме по `pageUrl` (см. `docs/forms.md`); заведён тип 24, раздел «Бухгалтерия», список данных `/sites/requests/Lists/120`
|
||||||
|
|
||||||
|
## Маршрут
|
||||||
|
- Фазы: согласование (исполнителей нет)
|
||||||
|
- Маршрут зависит от проекта (`ProjectId`) — как у «Заявки на услуги»: пока проект не выбран, матрица не строится.
|
||||||
|
- Маршрут 25 (`byProject: true`, без компании и подразделения — для любого проекта): один шаг согласования, фиксированный человек `gorbenko.pavel@tnsu.kz`. Форма всегда шлёт `projectId`, поэтому маршрута с `byProject: false` нет.
|
||||||
|
|
||||||
|
## Особое поведение
|
||||||
|
- **Основание** — согласованная СТ (`status = Согласовано` в списке «Сравнительная таблица» сайта СТ, `CT_SITE_URL`) либо потребность (`GET /Requests/365`, позиции — `/Requests/{oid}/positions`; тот же источник, что шаг 1 СТ). Выбор подставляет проект, компанию (если известна), получателя, раздел, вид работ, условия, общую сумму и позиции (`basisPatch.ts`); всё остаётся редактируемым.
|
||||||
|
- **Получатель** — выигравший поставщик СТ (`winSupplier` → «Поставщики»). Если в счёте указан другой контрагент (БИН, иначе название без формы собственности) — поле подсвечивается (`recipientMatchesInvoice`).
|
||||||
|
- **Раздел / вид работ** у СТ — из позиций СТ (`disciplineCode` / `SMR`), у потребности — `discipline` / `workType` заявки.
|
||||||
|
- **Условия оплаты, доставки и срок** — из «Критериев» СТ по победителю.
|
||||||
|
- **Общая сумма** СТ — сумма предложений победителя в «Оценке поставщиков» (смешанные валюты — в тенге). У потребности её нет, берётся из счёта.
|
||||||
|
- **Сумма оплаты** может быть меньше общей (транши). Сумма всех оплат по основанию (прежние + эта) не может превышать общую — при превышении поле подсвечивается, отправку это **не** блокирует.
|
||||||
|
- **Ранее поданные заявки на оплату** — по `BasisKey` (`ct:<Id СТ>` / `need:<oid>`) через approval-route `GET /api/basis-requests` по всем партициям, без черновиков, отклонённых и возвращённых. Сервер читает от имени приложения, поэтому видны и чужие заявки (права на элементы разорваны поштучно); номер становится ссылкой, только если у пользователя есть права открыть заявку. Снимок пишется в `PreviousPaymentsJson`; черновик и возвращённая при открытии перечитывают список заново.
|
||||||
|
- **Счёт (PDF)**: сначала сохраняется в библиотеку «Счета на оплату», затем `POST /Document/extractPDFText` (OCR записывает текст в `file_content` до ответа, колонка читается сразу), затем ИИ (`/Proxy/Gemini`) достаёт номер счёта, поставщика, БИН, IBAN, валюту, суммы и позиции и подставляет их (назначение платежа — «Оплата по счёту № …»). При сбое OCR/ИИ счёт остаётся приложенным, поля заполняются вручную.
|
||||||
|
- **Таблица позиций**: код, наименование, ед. изм. и количество — из СТ/потребности; позиция поставщика — из его КП; позиция из счёта. ИИ сопоставляет позиции по смыслу, а количество и единицы сверяет код (`positions.ts`). Статусы: совпало / расхождение / нет в счёте / нет в СТ. Результат пишется в `PositionsJson`.
|
||||||
|
- **Код позиции потребности** — `lineNumber` (номер строки табличной части 1С, уникален в пределах заявки).
|
||||||
|
- Справочники 1С (`api-tnsu-portal`, `DictionaryController`) — `DictionaryPicker`; недоступный справочник не блокирует заявку (поле становится текстовым):
|
||||||
|
- **Банковский счёт** — `GET /Dictionary/BankAccounts?search=&companyId=&projectId=` (постранично, поиск на сервере по названию, коду, номеру счёта, банку). Сначала по компании и проекту; если по проекту пусто — повтор только по компании с пояснением под полем (совпадение Id проекта из `/Project` с `bank_accounts.project_id` не гарантировано).
|
||||||
|
- **Договор** — `GET /Dictionary/Contracts?search=` (только действующие; фильтра по компании нет, поиск по названию, коду и контрагенту).
|
||||||
|
- **Группа статьи ДДС** — `GET /Dictionary/CashFlowItemGroups` (целиком); **статья ДДС** — `GET /Dictionary/CashFlowItems?id=<группа>`.
|
||||||
|
- Фильтры-Id передаются, только если это GUID: иначе API отвечает 400.
|
||||||
|
|
||||||
|
## Что не проверено на живом API
|
||||||
|
- Совпадают ли Id компании из `/Dictionary/Companies` и проекта из `/Project` с `company_id` / `project_id` в `bank_accounts` (на это — откат на фильтр по компании).
|
||||||
|
- Доступ пользователей портала на чтение списков сайта СТ.
|
||||||
|
|
||||||
|
## Секции формы
|
||||||
|
HeaderSection, BasisSection (BasisPicker), InvoiceSection, PaymentSection, AmountsSection, PositionsSection
|
||||||
|
|
||||||
|
## Точки входа
|
||||||
|
- rptype: `acc-payment-request`
|
||||||
|
- Режим согласующего (просмотр): есть (`PaymentRequestView`)
|
||||||
|
- «Создать на основе»: —
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { formatAmountInput, overLimitAmount, parseAmount, previousPaymentsTotal, recipientMatchesInvoice } from './amounts';
|
||||||
|
|
||||||
|
describe('parseAmount', () => {
|
||||||
|
it('понимает пробелы и запятую', () => {
|
||||||
|
expect(parseAmount('1 234,50')).toBe(1234.5);
|
||||||
|
});
|
||||||
|
it('мусор и пустая строка — 0', () => {
|
||||||
|
expect(parseAmount('abc')).toBe(0);
|
||||||
|
expect(parseAmount('')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('overLimitAmount', () => {
|
||||||
|
const prev = [
|
||||||
|
{ code: 'A', amount: 600 },
|
||||||
|
{ code: 'B', amount: 100 }
|
||||||
|
];
|
||||||
|
it('не превышает, пока сумма всех оплат не больше общей', () => {
|
||||||
|
expect(overLimitAmount({ TotalAmount: 1000, PayAmount: 300, PreviousPayments: prev })).toBe(0);
|
||||||
|
});
|
||||||
|
it('возвращает величину превышения', () => {
|
||||||
|
expect(overLimitAmount({ TotalAmount: 1000, PayAmount: 400, PreviousPayments: prev })).toBe(100);
|
||||||
|
});
|
||||||
|
it('копейки округления не считаются превышением', () => {
|
||||||
|
expect(overLimitAmount({ TotalAmount: 1000, PayAmount: 300.004, PreviousPayments: prev })).toBe(0);
|
||||||
|
});
|
||||||
|
it('без общей суммы сравнивать не с чем', () => {
|
||||||
|
expect(overLimitAmount({ TotalAmount: 0, PayAmount: 5, PreviousPayments: prev })).toBe(0);
|
||||||
|
});
|
||||||
|
it('считает сумму прежних оплат', () => {
|
||||||
|
expect(previousPaymentsTotal(prev)).toBe(700);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recipientMatchesInvoice', () => {
|
||||||
|
it('БИН решает, когда он есть у обеих сторон', () => {
|
||||||
|
expect(recipientMatchesInvoice({ name: 'А', bin: '1' }, { name: 'Б', bin: '1' })).toBe(true);
|
||||||
|
expect(recipientMatchesInvoice({ name: 'А', bin: '1' }, { name: 'А', bin: '2' })).toBe(false);
|
||||||
|
});
|
||||||
|
it('без БИН сравнивает названия без формы собственности и кавычек', () => {
|
||||||
|
expect(recipientMatchesInvoice({ name: 'ТОО «Альфа Строй»', bin: '' }, { name: 'Альфа Строй, ТОО', bin: '' })).toBe(true);
|
||||||
|
expect(recipientMatchesInvoice({ name: 'ТОО «Альфа»', bin: '' }, { name: 'ТОО «Бета»', bin: '' })).toBe(false);
|
||||||
|
});
|
||||||
|
it('нет данных по счёту — расхождения нет', () => {
|
||||||
|
expect(recipientMatchesInvoice({ name: 'ТОО «Альфа»', bin: '1' }, { name: '', bin: '' })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatAmountInput', () => {
|
||||||
|
it('разбивает на разряды', () => {
|
||||||
|
expect(formatAmountInput('1234567')).toBe('1 234 567');
|
||||||
|
expect(formatAmountInput('1 528 642')).toBe('1 528 642');
|
||||||
|
});
|
||||||
|
it('держит недописанную дробную часть и режет её до двух знаков', () => {
|
||||||
|
expect(formatAmountInput('1234,')).toBe('1 234,');
|
||||||
|
expect(formatAmountInput('1234.567')).toBe('1 234,56');
|
||||||
|
expect(formatAmountInput(',5')).toBe('0,5');
|
||||||
|
});
|
||||||
|
it('выкидывает мусор и лишние разделители', () => {
|
||||||
|
expect(formatAmountInput('12a3,4,5')).toBe('123,45');
|
||||||
|
expect(formatAmountInput('')).toBe('');
|
||||||
|
});
|
||||||
|
it('согласован с parseAmount', () => {
|
||||||
|
expect(parseAmount(formatAmountInput('154100,5'))).toBe(154100.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { IFormData, IPreviousPayment } from './model';
|
||||||
|
|
||||||
|
/** Допуск при сравнении денег: копейки округления не считаем превышением. */
|
||||||
|
const MONEY_EPSILON = 0.005;
|
||||||
|
|
||||||
|
/** Число из текста поля ввода: пробелы и запятая допустимы; мусор → 0. */
|
||||||
|
export function parseAmount(raw: string): number {
|
||||||
|
const value = Number(raw.replace(/\s/g, '').replace(',', '.'));
|
||||||
|
return Number.isFinite(value) ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текст поля суммы с разрядами («1234567,5» → «1 234 567,5»), как его видно при
|
||||||
|
* вводе: остаются цифры и один десятичный разделитель (запятая), в дробной
|
||||||
|
* части не больше двух знаков. Недописанное «12,» сохраняется — иначе запятую
|
||||||
|
* нельзя было бы ввести.
|
||||||
|
*/
|
||||||
|
export function formatAmountInput(raw: string): string {
|
||||||
|
const cleaned = raw.replace(/[^\d.,]/g, '').replace('.', ',');
|
||||||
|
const comma = cleaned.indexOf(',');
|
||||||
|
const intPart = (comma < 0 ? cleaned : cleaned.slice(0, comma)).replace(/,/g, '').replace(/^0+(?=\d)/, '');
|
||||||
|
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||||
|
if (comma < 0) return grouped;
|
||||||
|
const fraction = cleaned.slice(comma + 1).replace(/,/g, '').slice(0, 2);
|
||||||
|
return `${grouped || '0'},${fraction}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сумма ранее поданных оплат по основанию. */
|
||||||
|
export function previousPaymentsTotal(payments: IPreviousPayment[]): number {
|
||||||
|
return payments.reduce((sum, p) => sum + p.amount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* На сколько все оплаты по основанию (прежние + эта) превышают общую сумму СТ.
|
||||||
|
* 0 — не превышают; общая сумма не задана — сравнивать не с чем, тоже 0.
|
||||||
|
*/
|
||||||
|
export function overLimitAmount(data: Pick<IFormData, 'TotalAmount' | 'PayAmount' | 'PreviousPayments'>): number {
|
||||||
|
if (data.TotalAmount <= 0) return 0;
|
||||||
|
const over = previousPaymentsTotal(data.PreviousPayments) + data.PayAmount - data.TotalAmount;
|
||||||
|
return over > MONEY_EPSILON ? over : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сумма с разрядами для показа («1 234 567,5»). */
|
||||||
|
export function formatAmount(value: number): string {
|
||||||
|
return value.toLocaleString('ru-RU', { maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGAL_FORMS = /(^|\s)(тоо|ао|ип|llp|ооо|товарищество с ограниченной ответственностью)(?=\s|$)/gi;
|
||||||
|
|
||||||
|
/** Название контрагента для сравнения: без формы собственности, кавычек и регистра. */
|
||||||
|
export function normalizeCounterparty(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-zа-яё0-9]+/gi, ' ')
|
||||||
|
.replace(LEGAL_FORMS, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Совпадает ли получатель из СТ с поставщиком из счёта. БИН надёжнее названия:
|
||||||
|
* если он есть у обеих сторон — решает он. Нет данных по счёту — расхождения нет
|
||||||
|
* (подсвечивать нечего).
|
||||||
|
*/
|
||||||
|
export function recipientMatchesInvoice(
|
||||||
|
recipient: { name: string; bin: string },
|
||||||
|
invoice: { name: string; bin: string }
|
||||||
|
): boolean {
|
||||||
|
if (!invoice.name && !invoice.bin) return true;
|
||||||
|
if (recipient.bin && invoice.bin) return recipient.bin === invoice.bin;
|
||||||
|
const a = normalizeCounterparty(recipient.name);
|
||||||
|
const b = normalizeCounterparty(invoice.name);
|
||||||
|
if (!a || !b) return true;
|
||||||
|
return a === b || a.indexOf(b) !== -1 || b.indexOf(a) !== -1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"dataListTitle": "Заявка на оплату — согласование",
|
||||||
|
"columns": [
|
||||||
|
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||||
|
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
|
||||||
|
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Заявка на оплату — данные", "lookupShowField": "Title" },
|
||||||
|
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||||
|
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||||
|
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||||
|
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
||||||
|
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
||||||
|
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
|
||||||
|
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
|
||||||
|
{ "internalName": "AutoApprove", "title": "Автосогласование", "type": "Boolean" },
|
||||||
|
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { ICompany, IProject } from '../../shared/dictionary';
|
||||||
|
import { normalizeCounterparty } from './amounts';
|
||||||
|
import { IFormData, IPreviousPayment } from './model';
|
||||||
|
import { rowsFromBasis } from './positions';
|
||||||
|
import { IBasisInfo } from './services/basis.types';
|
||||||
|
|
||||||
|
/** Проект из справочника по названию: в СТ и потребности проект хранится только текстом. */
|
||||||
|
function findProject(projects: IProject[], name: string): IProject | undefined {
|
||||||
|
const wanted = name.trim().toLowerCase();
|
||||||
|
return wanted ? projects.filter((p) => p.name.trim().toLowerCase() === wanted)[0] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Компания из справочника по подсказке (организация потребности). */
|
||||||
|
function findCompany(companies: ICompany[], hint: string): ICompany | undefined {
|
||||||
|
const wanted = normalizeCounterparty(hint);
|
||||||
|
if (!wanted) return undefined;
|
||||||
|
return companies.filter((c) => normalizeCounterparty(c.Name) === wanted)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поля формы, которые подставляет выбранное основание. Всё подставленное остаётся
|
||||||
|
* редактируемым: справочники не всегда точны, а заявитель лучше знает, что платит.
|
||||||
|
* Счёт и сумма оплаты основанием не затрагиваются.
|
||||||
|
*/
|
||||||
|
export function basisPatch(
|
||||||
|
info: IBasisInfo,
|
||||||
|
previous: IPreviousPayment[],
|
||||||
|
projects: IProject[],
|
||||||
|
companies: ICompany[]
|
||||||
|
): Partial<IFormData> {
|
||||||
|
const project = findProject(projects, info.projectName);
|
||||||
|
const company = findCompany(companies, info.companyHint);
|
||||||
|
return {
|
||||||
|
BasisKind: info.kind,
|
||||||
|
BasisKey: info.key,
|
||||||
|
BasisTitle: info.title,
|
||||||
|
ProjectName: info.projectName,
|
||||||
|
ProjectId: project ? project.oid : '',
|
||||||
|
ProjectManager: project ? project.projectManager || '' : '',
|
||||||
|
CompanyId: company ? company.Id : '',
|
||||||
|
CompanyName: company ? company.Name : '',
|
||||||
|
RecipientName: info.recipientName,
|
||||||
|
RecipientBin: info.recipientBin,
|
||||||
|
Section: info.section,
|
||||||
|
WorkKind: info.workKind,
|
||||||
|
TotalAmount: info.total,
|
||||||
|
Currency: info.currency,
|
||||||
|
DeliveryTerms: info.deliveryTerms,
|
||||||
|
PaymentTerms: info.paymentTerms,
|
||||||
|
DeliveryTime: info.deliveryTime,
|
||||||
|
PreviousPayments: previous,
|
||||||
|
Positions: rowsFromBasis(info.positions)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { IFormData, IPositionRow } from './model';
|
||||||
|
import { buildRows, IInvoiceItem, IPositionPair } from './positions';
|
||||||
|
import { IInvoiceData } from './services/invoiceAi.service';
|
||||||
|
import { IBasisPosition } from './services/basis.types';
|
||||||
|
|
||||||
|
/** Назначение платежа по номеру счёта. */
|
||||||
|
export function purposeForInvoice(invoiceNumber: string): string {
|
||||||
|
return invoiceNumber ? `Оплата по счёту № ${invoiceNumber}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Позиции основания из уже собранной таблицы (строки «нет в СТ» — это позиции
|
||||||
|
* счёта, к основанию они не относятся). Нужны, чтобы повторно сверить таблицу с
|
||||||
|
* другим счётом, не перезагружая СТ.
|
||||||
|
*/
|
||||||
|
export function basisPositionsFromRows(rows: IPositionRow[]): IBasisPosition[] {
|
||||||
|
return rows
|
||||||
|
.filter((row) => row.status !== 'extra')
|
||||||
|
.map((row) => ({
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
unit: row.unit,
|
||||||
|
qty: row.qty,
|
||||||
|
supplierName: row.supplierName,
|
||||||
|
supplierQty: row.supplierQty,
|
||||||
|
supplierPrice: row.supplierPrice
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Что счёт подставляет в форму. Пустое значение из счёта поле не затирает.
|
||||||
|
* Общая сумма берётся из счёта только когда основание её не дало (потребность):
|
||||||
|
* у СТ общая сумма — предел для всех оплат, и счёт его менять не должен.
|
||||||
|
* Получатель у потребности — поставщик из счёта; у СТ — победитель, и расхождение
|
||||||
|
* со счётом подсвечивается, а не затирается.
|
||||||
|
*/
|
||||||
|
export function invoicePatch(
|
||||||
|
data: IFormData,
|
||||||
|
invoice: IInvoiceData,
|
||||||
|
items: IInvoiceItem[],
|
||||||
|
pairs: IPositionPair[]
|
||||||
|
): Partial<IFormData> {
|
||||||
|
const patch: Partial<IFormData> = {
|
||||||
|
InvoiceNumber: invoice.invoiceNumber,
|
||||||
|
InvoiceSupplierName: invoice.supplierName,
|
||||||
|
InvoiceSupplierBin: invoice.supplierBin,
|
||||||
|
Positions: buildRows(basisPositionsFromRows(data.Positions), items, pairs)
|
||||||
|
};
|
||||||
|
if (invoice.recipientAccount) patch.RecipientAccount = invoice.recipientAccount;
|
||||||
|
if (invoice.currency) patch.Currency = invoice.currency;
|
||||||
|
if (invoice.invoiceNumber) patch.PaymentPurpose = purposeForInvoice(invoice.invoiceNumber);
|
||||||
|
if (invoice.vatAmount > 0) patch.VatAmount = invoice.vatAmount;
|
||||||
|
if (invoice.totalAmount > 0) {
|
||||||
|
patch.PayAmount = invoice.totalAmount;
|
||||||
|
if (data.TotalAmount <= 0) patch.TotalAmount = invoice.totalAmount;
|
||||||
|
}
|
||||||
|
if (!data.RecipientName && invoice.supplierName) {
|
||||||
|
patch.RecipientName = invoice.supplierName;
|
||||||
|
patch.RecipientBin = invoice.supplierBin;
|
||||||
|
}
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
|
import { dataListTitle } from '../../shared/listTitles';
|
||||||
|
import { spDateOnlyToIso } from '../../shared/dateUtils';
|
||||||
|
import { EMPTY_DATA, FORM_ID, IFormData, IPositionRow, IPreviousPayment, BasisKind } from './model';
|
||||||
|
|
||||||
|
export interface ILoadedRequest {
|
||||||
|
data: IFormData;
|
||||||
|
status: string;
|
||||||
|
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текстовые колонки, которые читаются как есть (имя поля формы = имя колонки). */
|
||||||
|
const TEXT_KEYS = [
|
||||||
|
'RequesterEmail',
|
||||||
|
'RequesterName',
|
||||||
|
'RequesterJobTitle',
|
||||||
|
'Organization',
|
||||||
|
'Department',
|
||||||
|
'DepartmentId',
|
||||||
|
'BasisKey',
|
||||||
|
'BasisTitle',
|
||||||
|
'ProjectName',
|
||||||
|
'ProjectId',
|
||||||
|
'ProjectManager',
|
||||||
|
'CompanyId',
|
||||||
|
'CompanyName',
|
||||||
|
'BankAccountId',
|
||||||
|
'BankAccountName',
|
||||||
|
'RecipientName',
|
||||||
|
'RecipientBin',
|
||||||
|
'RecipientAccount',
|
||||||
|
'Section',
|
||||||
|
'WorkKind',
|
||||||
|
'ContractId',
|
||||||
|
'ContractName',
|
||||||
|
'CashFlowGroupId',
|
||||||
|
'CashFlowGroupName',
|
||||||
|
'CashFlowItemId',
|
||||||
|
'CashFlowItemName',
|
||||||
|
'PaymentPurpose',
|
||||||
|
'DeliveryTerms',
|
||||||
|
'PaymentTerms',
|
||||||
|
'Currency',
|
||||||
|
'DeliveryTime',
|
||||||
|
'Comment',
|
||||||
|
'InvoiceFileName',
|
||||||
|
'InvoiceUrl',
|
||||||
|
'InvoiceNumber',
|
||||||
|
'InvoiceSupplierName',
|
||||||
|
'InvoiceSupplierBin'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const NUMBER_KEYS = ['TotalAmount', 'VatAmount', 'PayAmount'] as const;
|
||||||
|
|
||||||
|
const SELECT_FIELDS = [
|
||||||
|
'Id',
|
||||||
|
'RequestDate',
|
||||||
|
'BasisKind',
|
||||||
|
'PreviousPaymentsJson',
|
||||||
|
'PositionsJson',
|
||||||
|
'RequestStatus',
|
||||||
|
'RequestUniqueCode',
|
||||||
|
...TEXT_KEYS,
|
||||||
|
...NUMBER_KEYS
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Безопасный разбор JSON-колонки табличной части: пустой массив при пустом
|
||||||
|
* значении или битом JSON — заявка должна открываться даже с испорченной
|
||||||
|
* таблицей, иначе её нельзя было бы починить через форму.
|
||||||
|
*/
|
||||||
|
function parseRows<T>(raw: unknown): T[] {
|
||||||
|
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прочитать «Заявку на оплату» по id: модель формы + статус + код.
|
||||||
|
* listId — партиция, где лежит именно эта заявка; не задан — текущая.
|
||||||
|
*/
|
||||||
|
export async function loadPaymentRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
|
||||||
|
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||||
|
const item = (await list.items.getById(itemId).select(...SELECT_FIELDS)()) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const data: IFormData = {
|
||||||
|
...EMPTY_DATA,
|
||||||
|
RequestDate: spDateOnlyToIso(item.RequestDate),
|
||||||
|
BasisKind: item.BasisKind === 'need' ? ('need' as BasisKind) : 'ct',
|
||||||
|
PreviousPayments: parseRows<IPreviousPayment>(item.PreviousPaymentsJson),
|
||||||
|
Positions: parseRows<IPositionRow>(item.PositionsJson)
|
||||||
|
};
|
||||||
|
TEXT_KEYS.forEach((key) => {
|
||||||
|
data[key] = (item[key] as string) || '';
|
||||||
|
});
|
||||||
|
NUMBER_KEYS.forEach((key) => {
|
||||||
|
data[key] = Number(item[key] || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
status: (item.RequestStatus as string) || '',
|
||||||
|
code: (item.RequestUniqueCode as string) || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { RequestTypeId } from '../../models/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
|
||||||
|
* SharePoint выводятся из него, см. shared/listTitles.ts.
|
||||||
|
*/
|
||||||
|
export const FORM_ID: RequestTypeId = 'acc-payment-request';
|
||||||
|
|
||||||
|
/** На чём основана оплата: согласованная СТ либо потребность (когда СТ не было). */
|
||||||
|
export type BasisKind = 'ct' | 'need';
|
||||||
|
|
||||||
|
export const BASIS_OPTIONS: Array<{ value: BasisKind; label: string }> = [
|
||||||
|
{ value: 'ct', label: 'Сравнительная таблица (СТ)' },
|
||||||
|
{ value: 'need', label: 'Потребность' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Ранее поданная заявка на оплату по той же СТ/потребности. */
|
||||||
|
export interface IPreviousPayment {
|
||||||
|
/** Уникальный код заявки на оплату. */
|
||||||
|
code: string;
|
||||||
|
amount: number;
|
||||||
|
/** Id элемента списка данных — для ссылки на заявку. Нет, если у пользователя нет прав её открыть (или в старом снимке). */
|
||||||
|
id?: number;
|
||||||
|
/** Список партиции (серверный относительный URL), где лежит заявка; '' — текущая. */
|
||||||
|
listId?: string;
|
||||||
|
/** Статус заявки на момент чтения. */
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат сверки строки с позициями счёта:
|
||||||
|
* ok — совпало; diff — позиция та же, но отличается количество/ед. изм.;
|
||||||
|
* missing — есть в СТ/потребности, в счёте нет; extra — есть в счёте, в СТ нет.
|
||||||
|
*/
|
||||||
|
export type RowStatus = 'ok' | 'diff' | 'missing' | 'extra';
|
||||||
|
|
||||||
|
/** Строка таблицы позиций: СТ/потребность ↔ поставщик ↔ счёт. */
|
||||||
|
export interface IPositionRow {
|
||||||
|
/** Уникальный код позиции потребности. */
|
||||||
|
code: string;
|
||||||
|
/** Наименование и ед. изм. из потребности (в СТ они есть). */
|
||||||
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
qty: number;
|
||||||
|
/** Позиция поставщика из его КП (только при основании «СТ»). */
|
||||||
|
supplierName: string;
|
||||||
|
supplierQty: number;
|
||||||
|
supplierPrice: number;
|
||||||
|
/** Позиция из счёта. */
|
||||||
|
invoiceName: string;
|
||||||
|
invoiceUnit: string;
|
||||||
|
invoiceQty: number;
|
||||||
|
invoicePrice: number;
|
||||||
|
invoiceSum: number;
|
||||||
|
status: RowStatus;
|
||||||
|
/** Пояснение к расхождению; '' — нет. */
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Данные формы «Заявка на оплату». Ключи = internalName колонок SP-списка
|
||||||
|
* «Заявка на оплату — данные». Табличные части уходят в JSON-колонки
|
||||||
|
* (PreviousPaymentsJson, PositionsJson), суммы — в числовые колонки (для отчётов).
|
||||||
|
*/
|
||||||
|
export interface IFormData {
|
||||||
|
// --- Заявитель ---
|
||||||
|
RequesterEmail: string;
|
||||||
|
RequesterName: string;
|
||||||
|
RequesterJobTitle: string;
|
||||||
|
Organization: string;
|
||||||
|
Department: string;
|
||||||
|
DepartmentId: string;
|
||||||
|
RequestDate: string;
|
||||||
|
|
||||||
|
// --- Основание ---
|
||||||
|
BasisKind: BasisKind;
|
||||||
|
/** Ключ основания для поиска прежних оплат: «ct:<Id СТ>» либо «need:<oid потребности>». */
|
||||||
|
BasisKey: string;
|
||||||
|
/** Подпись основания: «СТ №3 — Проект» либо «Потребность 081/26-04/0017». */
|
||||||
|
BasisTitle: string;
|
||||||
|
ProjectName: string;
|
||||||
|
/** oid проекта: по нему бэкенд резолвит маршрут согласования. */
|
||||||
|
ProjectId: string;
|
||||||
|
ProjectManager: string;
|
||||||
|
CompanyId: string;
|
||||||
|
CompanyName: string;
|
||||||
|
|
||||||
|
// --- Платёж ---
|
||||||
|
BankAccountId: string;
|
||||||
|
BankAccountName: string;
|
||||||
|
RecipientName: string;
|
||||||
|
RecipientBin: string;
|
||||||
|
/** Счёт получателя (IBAN) — из файла счёта. */
|
||||||
|
RecipientAccount: string;
|
||||||
|
Section: string;
|
||||||
|
WorkKind: string;
|
||||||
|
ContractId: string;
|
||||||
|
ContractName: string;
|
||||||
|
CashFlowGroupId: string;
|
||||||
|
CashFlowGroupName: string;
|
||||||
|
CashFlowItemId: string;
|
||||||
|
CashFlowItemName: string;
|
||||||
|
PaymentPurpose: string;
|
||||||
|
DeliveryTerms: string;
|
||||||
|
PaymentTerms: string;
|
||||||
|
|
||||||
|
// --- Суммы ---
|
||||||
|
/** Суммы хранятся числом: 0 — не заполнено. */
|
||||||
|
TotalAmount: number;
|
||||||
|
VatAmount: number;
|
||||||
|
Currency: string;
|
||||||
|
PayAmount: number;
|
||||||
|
DeliveryTime: string;
|
||||||
|
Comment: string;
|
||||||
|
|
||||||
|
// --- Счёт ---
|
||||||
|
InvoiceFileName: string;
|
||||||
|
/** Адрес файла в библиотеке счетов (ServerRelativeUrl). */
|
||||||
|
InvoiceUrl: string;
|
||||||
|
InvoiceNumber: string;
|
||||||
|
/** Поставщик, как он назван в самом счёте, — сверяется с получателем из СТ. */
|
||||||
|
InvoiceSupplierName: string;
|
||||||
|
InvoiceSupplierBin: string;
|
||||||
|
|
||||||
|
// --- Табличные части ---
|
||||||
|
PreviousPayments: IPreviousPayment[];
|
||||||
|
Positions: IPositionRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
||||||
|
|
||||||
|
/** Пропсы каждой секции формы. */
|
||||||
|
export interface ISectionProps {
|
||||||
|
data: IFormData;
|
||||||
|
set: SetField;
|
||||||
|
context: WebPartContext;
|
||||||
|
/** Подставить сразу несколько полей (выбор основания меняет их пачкой). */
|
||||||
|
patch: (changes: Partial<IFormData>) => void;
|
||||||
|
printOrgIndex?: number;
|
||||||
|
onRequesterChange?: (login: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_DATA: IFormData = {
|
||||||
|
RequesterEmail: '',
|
||||||
|
RequesterName: '',
|
||||||
|
RequesterJobTitle: '',
|
||||||
|
Organization: '',
|
||||||
|
Department: '',
|
||||||
|
DepartmentId: '',
|
||||||
|
RequestDate: '',
|
||||||
|
BasisKind: 'ct',
|
||||||
|
BasisKey: '',
|
||||||
|
BasisTitle: '',
|
||||||
|
ProjectName: '',
|
||||||
|
ProjectId: '',
|
||||||
|
ProjectManager: '',
|
||||||
|
CompanyId: '',
|
||||||
|
CompanyName: '',
|
||||||
|
BankAccountId: '',
|
||||||
|
BankAccountName: '',
|
||||||
|
RecipientName: '',
|
||||||
|
RecipientBin: '',
|
||||||
|
RecipientAccount: '',
|
||||||
|
Section: '',
|
||||||
|
WorkKind: '',
|
||||||
|
ContractId: '',
|
||||||
|
ContractName: '',
|
||||||
|
CashFlowGroupId: '',
|
||||||
|
CashFlowGroupName: '',
|
||||||
|
CashFlowItemId: '',
|
||||||
|
CashFlowItemName: '',
|
||||||
|
PaymentPurpose: '',
|
||||||
|
DeliveryTerms: '',
|
||||||
|
PaymentTerms: '',
|
||||||
|
TotalAmount: 0,
|
||||||
|
VatAmount: 0,
|
||||||
|
Currency: '',
|
||||||
|
PayAmount: 0,
|
||||||
|
DeliveryTime: '',
|
||||||
|
Comment: '',
|
||||||
|
InvoiceFileName: '',
|
||||||
|
InvoiceUrl: '',
|
||||||
|
InvoiceNumber: '',
|
||||||
|
InvoiceSupplierName: '',
|
||||||
|
InvoiceSupplierBin: '',
|
||||||
|
PreviousPayments: [],
|
||||||
|
Positions: []
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { buildRows, hasPositionIssues, IInvoiceItem } from './positions';
|
||||||
|
import { IBasisPosition } from './services/basis.types';
|
||||||
|
|
||||||
|
const base = (name: string, qty: number, unit = 'шт'): IBasisPosition => ({
|
||||||
|
code: `K-${name}`,
|
||||||
|
name,
|
||||||
|
unit,
|
||||||
|
qty,
|
||||||
|
supplierName: '',
|
||||||
|
supplierQty: 0,
|
||||||
|
supplierPrice: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = (name: string, quantity: number, unit = 'шт'): IInvoiceItem => ({
|
||||||
|
name,
|
||||||
|
unit,
|
||||||
|
quantity,
|
||||||
|
price: 10,
|
||||||
|
sum: 10 * quantity
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildRows', () => {
|
||||||
|
it('совпавшая пара — ok', () => {
|
||||||
|
const rows = buildRows([base('Цемент', 5)], [item('Цемент М400', 5)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].status).toBe('ok');
|
||||||
|
expect(rows[0].invoiceName).toBe('Цемент М400');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('разное количество — diff с пояснением', () => {
|
||||||
|
const rows = buildRows([base('Цемент', 5)], [item('Цемент', 7)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
|
||||||
|
expect(rows[0].status).toBe('diff');
|
||||||
|
expect(rows[0].note).toContain('количество');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('единицы измерения сравниваются без регистра и точек', () => {
|
||||||
|
const rows = buildRows([base('Труба', 2, 'Шт.')], [item('Труба', 2, 'шт')], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
|
||||||
|
expect(rows[0].status).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('позиция, которую ИИ не упомянул, не пропадает', () => {
|
||||||
|
const rows = buildRows([base('А', 1), base('Б', 1)], [item('А', 1), item('В', 1)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
|
||||||
|
expect(rows.map((r) => r.status)).toEqual(['ok', 'missing', 'extra']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пары с неверными индексами и повторы отбрасываются', () => {
|
||||||
|
const rows = buildRows(
|
||||||
|
[base('А', 1)],
|
||||||
|
[item('А', 1), item('Б', 1)],
|
||||||
|
[
|
||||||
|
{ baseIndex: 0, invoiceIndex: 0, note: '' },
|
||||||
|
{ baseIndex: 0, invoiceIndex: 1, note: '' },
|
||||||
|
{ baseIndex: 9, invoiceIndex: 1, note: '' }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
expect(rows.map((r) => r.status)).toEqual(['ok', 'extra']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasPositionIssues видит любое расхождение', () => {
|
||||||
|
expect(hasPositionIssues(buildRows([base('А', 1)], [], []))).toBe(true);
|
||||||
|
expect(hasPositionIssues(buildRows([base('А', 1)], [item('А', 1)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { IPositionRow } from './model';
|
||||||
|
import { IBasisPosition } from './services/basis.types';
|
||||||
|
|
||||||
|
/** Позиция, распознанная в счёте. */
|
||||||
|
export interface IInvoiceItem {
|
||||||
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
sum: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пара, которую вернула ИИ: какой позиции основания (baseIndex) соответствует
|
||||||
|
* какая позиция счёта (invoiceIndex). −1 с одной из сторон — пары нет.
|
||||||
|
*/
|
||||||
|
export interface IPositionPair {
|
||||||
|
baseIndex: number;
|
||||||
|
invoiceIndex: number;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QTY_EPSILON = 0.0001;
|
||||||
|
|
||||||
|
function emptyInvoicePart(): Pick<IPositionRow, 'invoiceName' | 'invoiceUnit' | 'invoiceQty' | 'invoicePrice' | 'invoiceSum'> {
|
||||||
|
return { invoiceName: '', invoiceUnit: '', invoiceQty: 0, invoicePrice: 0, invoiceSum: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoicePart(item: IInvoiceItem): ReturnType<typeof emptyInvoicePart> {
|
||||||
|
return {
|
||||||
|
invoiceName: item.name,
|
||||||
|
invoiceUnit: item.unit,
|
||||||
|
invoiceQty: item.quantity,
|
||||||
|
invoicePrice: item.price,
|
||||||
|
invoiceSum: item.sum
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ед. изм. для сравнения: «шт.», «Шт» и «шт» — одно и то же. */
|
||||||
|
function normalizeUnit(unit: string): string {
|
||||||
|
return unit.toLowerCase().replace(/[.\s]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function basisPart(position: IBasisPosition): Pick<IPositionRow, 'code' | 'name' | 'unit' | 'qty' | 'supplierName' | 'supplierQty' | 'supplierPrice'> {
|
||||||
|
return {
|
||||||
|
code: position.code,
|
||||||
|
name: position.name,
|
||||||
|
unit: position.unit,
|
||||||
|
qty: position.qty,
|
||||||
|
supplierName: position.supplierName,
|
||||||
|
supplierQty: position.supplierQty,
|
||||||
|
supplierPrice: position.supplierPrice
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строки таблицы до загрузки счёта: только основание, сверять пока не с чем. */
|
||||||
|
export function rowsFromBasis(base: IBasisPosition[]): IPositionRow[] {
|
||||||
|
return base.map((position) => ({
|
||||||
|
...basisPart(position),
|
||||||
|
...emptyInvoicePart(),
|
||||||
|
status: 'ok',
|
||||||
|
note: ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Расхождения парной строки: ед. изм. и количество сравниваем сами, не полагаясь на ИИ. */
|
||||||
|
function diffNote(position: IBasisPosition, item: IInvoiceItem, aiNote: string): string {
|
||||||
|
const notes: string[] = [];
|
||||||
|
if (position.unit && item.unit && normalizeUnit(position.unit) !== normalizeUnit(item.unit)) {
|
||||||
|
notes.push(`ед. изм.: ${position.unit} → ${item.unit}`);
|
||||||
|
}
|
||||||
|
if (Math.abs(position.qty - item.quantity) > QTY_EPSILON) {
|
||||||
|
notes.push(`количество: ${position.qty} → ${item.quantity}`);
|
||||||
|
}
|
||||||
|
// Смысловое отличие названия (другая марка, размер) видит только ИИ — его пометка сама по себе расхождение.
|
||||||
|
if (aiNote) notes.push(aiNote);
|
||||||
|
return notes.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собрать таблицу сверки из ответа ИИ. Пары с неверными индексами и повторы
|
||||||
|
* отбрасываются, а всё, что ИИ не упомянул, попадает в таблицу как «нет в счёте» /
|
||||||
|
* «нет в основании»: ни одна позиция не должна пропасть молча.
|
||||||
|
*/
|
||||||
|
export function buildRows(base: IBasisPosition[], invoice: IInvoiceItem[], pairs: IPositionPair[]): IPositionRow[] {
|
||||||
|
const invoiceByBase = new Map<number, { index: number; note: string }>();
|
||||||
|
const usedInvoice = new Set<number>();
|
||||||
|
|
||||||
|
pairs.forEach((pair) => {
|
||||||
|
const validBase = pair.baseIndex >= 0 && pair.baseIndex < base.length;
|
||||||
|
const validInvoice = pair.invoiceIndex >= 0 && pair.invoiceIndex < invoice.length;
|
||||||
|
if (!validBase || !validInvoice) return;
|
||||||
|
if (invoiceByBase.has(pair.baseIndex) || usedInvoice.has(pair.invoiceIndex)) return;
|
||||||
|
invoiceByBase.set(pair.baseIndex, { index: pair.invoiceIndex, note: pair.note });
|
||||||
|
usedInvoice.add(pair.invoiceIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows: IPositionRow[] = base.map((position, baseIndex) => {
|
||||||
|
const match = invoiceByBase.get(baseIndex);
|
||||||
|
if (!match) {
|
||||||
|
return { ...basisPart(position), ...emptyInvoicePart(), status: 'missing', note: 'Нет в счёте' };
|
||||||
|
}
|
||||||
|
const item = invoice[match.index];
|
||||||
|
const note = diffNote(position, item, match.note);
|
||||||
|
return { ...basisPart(position), ...invoicePart(item), status: note ? 'diff' : 'ok', note };
|
||||||
|
});
|
||||||
|
|
||||||
|
invoice.forEach((item, index) => {
|
||||||
|
if (usedInvoice.has(index)) return;
|
||||||
|
rows.push({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
unit: '',
|
||||||
|
qty: 0,
|
||||||
|
supplierName: '',
|
||||||
|
supplierQty: 0,
|
||||||
|
supplierPrice: 0,
|
||||||
|
...invoicePart(item),
|
||||||
|
status: 'extra',
|
||||||
|
note: 'Нет в СТ / потребности'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Есть ли в таблице хоть одно расхождение со счётом. */
|
||||||
|
export function hasPositionIssues(rows: IPositionRow[]): boolean {
|
||||||
|
return rows.some((row) => row.status !== 'ok');
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"dataListTitle": "Заявка на оплату — данные",
|
||||||
|
"columns": [
|
||||||
|
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
|
||||||
|
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||||
|
|
||||||
|
{ "internalName": "RequesterEmail", "title": "Заявитель (email)", "type": "Text" },
|
||||||
|
{ "internalName": "RequesterName", "title": "Заявитель (Ф.И.О.)", "type": "Text" },
|
||||||
|
{ "internalName": "RequesterJobTitle", "title": "Должность заявителя", "type": "Text" },
|
||||||
|
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
||||||
|
{ "internalName": "Department", "title": "Подразделение", "type": "Text" },
|
||||||
|
{ "internalName": "DepartmentId", "title": "Id подразделения (справочник)", "type": "Text" },
|
||||||
|
{ "internalName": "RequestDate", "title": "Дата заявки", "type": "DateTime", "dateOnly": true },
|
||||||
|
|
||||||
|
{ "internalName": "BasisKind", "title": "Основание (ct / need)", "type": "Text" },
|
||||||
|
{ "internalName": "BasisKey", "title": "Ключ основания (ct:Id / need:oid)", "type": "Text" },
|
||||||
|
{ "internalName": "BasisTitle", "title": "Основание", "type": "Text" },
|
||||||
|
{ "internalName": "ProjectName", "title": "Проект", "type": "Text" },
|
||||||
|
{ "internalName": "ProjectId", "title": "Id проекта", "type": "Text" },
|
||||||
|
{ "internalName": "ProjectManager", "title": "Руководитель проекта (email)", "type": "Text" },
|
||||||
|
{ "internalName": "CompanyId", "title": "Id компании", "type": "Text" },
|
||||||
|
{ "internalName": "CompanyName", "title": "Компания", "type": "Text" },
|
||||||
|
|
||||||
|
{ "internalName": "BankAccountId", "title": "Id банковского счёта (1С)", "type": "Text" },
|
||||||
|
{ "internalName": "BankAccountName", "title": "Банковский счёт", "type": "Text" },
|
||||||
|
{ "internalName": "RecipientName", "title": "Получатель", "type": "Text" },
|
||||||
|
{ "internalName": "RecipientBin", "title": "БИН получателя", "type": "Text" },
|
||||||
|
{ "internalName": "RecipientAccount", "title": "Счёт получателя", "type": "Text" },
|
||||||
|
{ "internalName": "Section", "title": "Раздел", "type": "Text" },
|
||||||
|
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
|
||||||
|
{ "internalName": "ContractId", "title": "Id договора (1С)", "type": "Text" },
|
||||||
|
{ "internalName": "ContractName", "title": "Договор", "type": "Text" },
|
||||||
|
{ "internalName": "CashFlowGroupId", "title": "Id группы статьи ДДС", "type": "Text" },
|
||||||
|
{ "internalName": "CashFlowGroupName", "title": "Группа статьи ДДС", "type": "Text" },
|
||||||
|
{ "internalName": "CashFlowItemId", "title": "Id статьи ДДС", "type": "Text" },
|
||||||
|
{ "internalName": "CashFlowItemName", "title": "Статья ДДС", "type": "Text" },
|
||||||
|
{ "internalName": "PaymentPurpose", "title": "Назначение платежа", "type": "Note" },
|
||||||
|
{ "internalName": "DeliveryTerms", "title": "Условия доставки", "type": "Note" },
|
||||||
|
{ "internalName": "PaymentTerms", "title": "Условия оплаты", "type": "Note" },
|
||||||
|
|
||||||
|
{ "internalName": "TotalAmount", "title": "Общая сумма", "type": "Number" },
|
||||||
|
{ "internalName": "VatAmount", "title": "НДС", "type": "Number" },
|
||||||
|
{ "internalName": "Currency", "title": "Валюта", "type": "Text" },
|
||||||
|
{ "internalName": "PayAmount", "title": "Сумма оплаты", "type": "Number" },
|
||||||
|
{ "internalName": "DeliveryTime", "title": "Общий срок поставки", "type": "Text" },
|
||||||
|
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||||
|
|
||||||
|
{ "internalName": "InvoiceFileName", "title": "Файл счёта", "type": "Text" },
|
||||||
|
{ "internalName": "InvoiceUrl", "title": "Адрес файла счёта", "type": "Text" },
|
||||||
|
{ "internalName": "InvoiceNumber", "title": "Номер счёта", "type": "Text" },
|
||||||
|
{ "internalName": "InvoiceSupplierName", "title": "Поставщик по счёту", "type": "Text" },
|
||||||
|
{ "internalName": "InvoiceSupplierBin", "title": "БИН поставщика по счёту", "type": "Text" },
|
||||||
|
|
||||||
|
{ "internalName": "PreviousPaymentsJson", "title": "Ранее поданные оплаты (JSON)", "type": "Note" },
|
||||||
|
{ "internalName": "PositionsJson", "title": "Позиции: СТ, поставщик, счёт (JSON)", "type": "Note" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { TextInput } from '../../../shared/ui';
|
||||||
|
import { formatAmountInput, parseAmount } from '../amounts';
|
||||||
|
|
||||||
|
interface IAmountInputProps {
|
||||||
|
id?: string;
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toText = (value: number): string => (value ? formatAmountInput(String(value)) : '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поле суммы с десятичной частью и разрядами («1 234 567,50»). Хранит значение числом, но пока пользователь
|
||||||
|
* печатает, держит собственный текст: иначе «12,» превращалось бы в «12» на
|
||||||
|
* каждом нажатии и дробную часть ввести было бы нельзя.
|
||||||
|
*/
|
||||||
|
export const AmountInput: React.FC<IAmountInputProps> = ({ id, value, onChange }) => {
|
||||||
|
const [text, setText] = React.useState<string>(toText(value));
|
||||||
|
|
||||||
|
// Значение изменили снаружи (подставил счёт, загрузилась заявка) — показываем его.
|
||||||
|
React.useEffect(() => {
|
||||||
|
setText((current) => (parseAmount(current) === value ? current : toText(value)));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
id={id}
|
||||||
|
value={text}
|
||||||
|
onChange={(next) => {
|
||||||
|
const formatted = formatAmountInput(next);
|
||||||
|
setText(formatted);
|
||||||
|
onChange(parseAmount(formatted));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './PaymentSections.module.scss';
|
||||||
|
import { FormField, TextInput, TextArea } from '../../../shared/ui';
|
||||||
|
import { formatAmount, overLimitAmount, previousPaymentsTotal } from '../amounts';
|
||||||
|
import { IPreviousPayment, ISectionProps } from '../model';
|
||||||
|
import { AmountInput } from './AmountInput';
|
||||||
|
import { RequestDrawer } from './RequestDrawer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Суммы платежа. Сумма всех оплат по СТ (прежние заявки + эта) не может превышать
|
||||||
|
* общую сумму СТ — при превышении сумма оплаты подсвечивается, отправку это не блокирует.
|
||||||
|
*/
|
||||||
|
export const AmountsSection: React.FC<ISectionProps> = ({ data, set, context }) => {
|
||||||
|
// Прежняя заявка, открытая в панели справа; undefined — панель закрыта.
|
||||||
|
const [opened, setOpened] = React.useState<IPreviousPayment | undefined>(undefined);
|
||||||
|
const close = React.useCallback(() => setOpened(undefined), []);
|
||||||
|
const over = overLimitAmount(data);
|
||||||
|
const previousTotal = previousPaymentsTotal(data.PreviousPayments);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField label="Общая сумма" htmlFor="TotalAmount">
|
||||||
|
<AmountInput id="TotalAmount" value={data.TotalAmount} onChange={(v) => set('TotalAmount', v)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Валюта" htmlFor="Currency">
|
||||||
|
<TextInput id="Currency" value={data.Currency} onChange={(v) => set('Currency', v.toUpperCase())} placeholder="KZT" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="НДС" htmlFor="VatAmount">
|
||||||
|
<AmountInput id="VatAmount" value={data.VatAmount} onChange={(v) => set('VatAmount', v)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Сумма оплаты" htmlFor="PayAmount" required>
|
||||||
|
<div className={over > 0 ? styles.warnBox : undefined}>
|
||||||
|
<AmountInput id="PayAmount" value={data.PayAmount} onChange={(v) => set('PayAmount', v)} />
|
||||||
|
{over > 0 && (
|
||||||
|
<div className={styles.warnText}>
|
||||||
|
Все оплаты по основанию превышают общую сумму на {formatAmount(over)} {data.Currency}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Общий срок поставки" htmlFor="DeliveryTime">
|
||||||
|
<TextInput id="DeliveryTime" value={data.DeliveryTime} onChange={(v) => set('DeliveryTime', v)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Комментарий" htmlFor="Comment" full>
|
||||||
|
<TextArea id="Comment" value={data.Comment} onChange={(v) => set('Comment', v)} rows={2} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<h4 className={styles.fullRow}>Заявки на оплату по этому основанию</h4>
|
||||||
|
{data.PreviousPayments.length === 0 ? (
|
||||||
|
<div className={`${styles.empty} ${styles.fullRow}`}>Ранее заявок на оплату по этому основанию не было.</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.tableWrap}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Номер заявки на оплату</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th className={styles.num}>Сумма оплаты</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.PreviousPayments.map((p) => (
|
||||||
|
<tr key={p.code}>
|
||||||
|
<td>
|
||||||
|
{p.id ? (
|
||||||
|
<button type="button" className={styles.link} onClick={() => setOpened(p)}>
|
||||||
|
{p.code}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
p.code
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{p.status || ''}</td>
|
||||||
|
<td className={styles.num}>{formatAmount(p.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={2}>Итого ранее</td>
|
||||||
|
<td className={styles.num}>{formatAmount(previousTotal)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{opened && opened.id && (
|
||||||
|
<RequestDrawer context={context} itemId={opened.id} listId={opened.listId || ''} code={opened.code} onClose={close} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { IDictionaryItem, ItemPicker } from '../../../shared/dictionary';
|
||||||
|
import { friendlyErrorMessage } from '../../../shared/diagnostics';
|
||||||
|
import { BasisKind } from '../model';
|
||||||
|
import { IBasisOption } from '../services/basis.types';
|
||||||
|
import { listApprovedCts } from '../services/ctBasis.service';
|
||||||
|
import { listNeeds } from '../services/needBasis.service';
|
||||||
|
|
||||||
|
interface IBasisPickerProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
kind: BasisKind;
|
||||||
|
/** Подпись выбранного основания. */
|
||||||
|
value: string;
|
||||||
|
onPick: (id: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPickerItem(option: IBasisOption): IDictionaryItem {
|
||||||
|
return { id: option.id, label: option.title, note: option.note };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поиск основания: согласованные СТ либо потребности (по выбранному виду основания).
|
||||||
|
* Выбор запускает загрузку СТ/потребности целиком (см. useBasisSelection), поэтому
|
||||||
|
* ручной ввод текста основанием не считается — onPick зовётся только с id записи.
|
||||||
|
*/
|
||||||
|
export const BasisPicker: React.FC<IBasisPickerProps> = ({ context, kind, value, onPick, readOnly }) => {
|
||||||
|
const [items, setItems] = React.useState<IDictionaryItem[]>([]);
|
||||||
|
const [error, setError] = React.useState<string>('');
|
||||||
|
// null — показываем выбранное основание; строка — пользователь печатает запрос (в т.ч. пустой).
|
||||||
|
const [query, setQuery] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (readOnly) return undefined;
|
||||||
|
let alive = true;
|
||||||
|
setError('');
|
||||||
|
setItems([]);
|
||||||
|
(kind === 'ct' ? listApprovedCts(context) : listNeeds(context))
|
||||||
|
.then((options) => {
|
||||||
|
if (alive) setItems(options.map(toPickerItem));
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) setError(`Не удалось загрузить список: ${friendlyErrorMessage(e)}`);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [context, kind, readOnly]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemPicker
|
||||||
|
id="BasisTitle"
|
||||||
|
items={items}
|
||||||
|
value={query ?? value}
|
||||||
|
readOnly={readOnly}
|
||||||
|
placeholder={kind === 'ct' ? 'Найдите согласованную СТ по номеру или проекту…' : 'Найдите потребность по номеру или проекту…'}
|
||||||
|
hint={error}
|
||||||
|
onChange={(label, id) => {
|
||||||
|
setQuery(id ? null : label);
|
||||||
|
if (id) onPick(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert, FormField, Select, ISelectOption } from '../../../shared/ui';
|
||||||
|
import styles from './PaymentSections.module.scss';
|
||||||
|
import { ICompany, ProjectPicker, getCompanies } from '../../../shared/dictionary';
|
||||||
|
import { BASIS_OPTIONS, BasisKind, ISectionProps } from '../model';
|
||||||
|
import { IBasisSelection } from '../useBasisSelection';
|
||||||
|
import { BasisPicker } from './BasisPicker';
|
||||||
|
|
||||||
|
interface IBasisSectionProps extends ISectionProps {
|
||||||
|
basis: IBasisSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_OPTIONS: ISelectOption[] = BASIS_OPTIONS.map((o) => ({ value: o.value, label: o.label }));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Основание оплаты: согласованная СТ либо потребность. Выбор основания подставляет
|
||||||
|
* проект, раздел, вид работ, условия, суммы и позиции (см. basisPatch) — всё это
|
||||||
|
* остаётся редактируемым. Проект определяет маршрут согласования.
|
||||||
|
*/
|
||||||
|
export const BasisSection: React.FC<IBasisSectionProps> = ({ data, patch, context, basis }) => {
|
||||||
|
const [companies, setCompanies] = React.useState<ICompany[]>([]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
getCompanies(context)
|
||||||
|
.then((list) => {
|
||||||
|
if (alive) setCompanies(list);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [context]);
|
||||||
|
|
||||||
|
const companyOptions: ISelectOption[] = companies.map((c) => ({ value: c.Id, label: c.Name }));
|
||||||
|
// Компания из заявки может отсутствовать в справочнике (его не удалось загрузить) — показываем её как есть.
|
||||||
|
if (data.CompanyId && !companies.some((c) => c.Id === data.CompanyId)) {
|
||||||
|
companyOptions.push({ value: data.CompanyId, label: data.CompanyName });
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKindChange = (value: string): void => {
|
||||||
|
patch({ BasisKind: value as BasisKind, BasisKey: '', BasisTitle: '', PreviousPayments: [], Positions: [] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField label="Основание" htmlFor="BasisKind" required>
|
||||||
|
<Select id="BasisKind" value={data.BasisKind} onChange={handleKindChange} options={KIND_OPTIONS} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label={data.BasisKind === 'ct' ? 'Согласованная СТ' : 'Потребность'} htmlFor="BasisTitle" required>
|
||||||
|
<BasisPicker
|
||||||
|
context={context}
|
||||||
|
kind={data.BasisKind}
|
||||||
|
value={data.BasisTitle}
|
||||||
|
onPick={(id) => {
|
||||||
|
basis.select(data.BasisKind, id).catch(() => undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Проект" htmlFor="ProjectName" required>
|
||||||
|
<ProjectPicker
|
||||||
|
id="ProjectName"
|
||||||
|
context={context}
|
||||||
|
value={data.ProjectName}
|
||||||
|
placeholder="Начните вводить название проекта…"
|
||||||
|
onChange={(name, id, project) => {
|
||||||
|
patch({ ProjectName: name, ProjectId: id, ProjectManager: project ? project.projectManager || '' : '' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Компания" htmlFor="CompanyId" required>
|
||||||
|
<Select
|
||||||
|
id="CompanyId"
|
||||||
|
value={data.CompanyId}
|
||||||
|
onChange={(id) => {
|
||||||
|
const company = companies.filter((c) => c.Id === id)[0];
|
||||||
|
patch({ CompanyId: id, CompanyName: company ? company.Name : '' });
|
||||||
|
}}
|
||||||
|
options={companyOptions}
|
||||||
|
placeholder="Выберите компанию…"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{basis.loading && <div className={styles.fullRow}><Alert variant="info">Загружаем данные основания…</Alert></div>}
|
||||||
|
{basis.error && <div className={styles.fullRow}><Alert variant="err">{basis.error}</Alert></div>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
|
||||||
|
import { DepartmentPicker } from '../../../shared/dictionary';
|
||||||
|
import { ISectionProps } from '../model';
|
||||||
|
|
||||||
|
/** Шапка заявки: кто подаёт (по умолчанию текущий пользователь), должность, подразделение, дата. */
|
||||||
|
export const HeaderSection: React.FC<ISectionProps> = ({ data, set, context, printOrgIndex, onRequesterChange }) => (
|
||||||
|
<FormGrid>
|
||||||
|
<FormField label="Заявитель" htmlFor="RequesterEmail" required full>
|
||||||
|
<PeopleField
|
||||||
|
context={context}
|
||||||
|
value={data.RequesterEmail}
|
||||||
|
onChange={onRequesterChange ?? ((v) => set('RequesterEmail', v))}
|
||||||
|
placeholder="Начните вводить Ф.И.О. заявителя…"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
|
||||||
|
|
||||||
|
<FormField label="Должность заявителя" htmlFor="RequesterJobTitle">
|
||||||
|
<TextInput id="RequesterJobTitle" value={data.RequesterJobTitle} onChange={(v) => set('RequesterJobTitle', v)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Подразделение" htmlFor="Department">
|
||||||
|
<DepartmentPicker
|
||||||
|
id="Department"
|
||||||
|
context={context}
|
||||||
|
value={data.Department}
|
||||||
|
onChange={(name, id) => {
|
||||||
|
set('Department', name);
|
||||||
|
set('DepartmentId', id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Дата заявки" htmlFor="RequestDate">
|
||||||
|
<TextInput id="RequestDate" type="date" value={data.RequestDate} onChange={(v) => set('RequestDate', v)} />
|
||||||
|
</FormField>
|
||||||
|
</FormGrid>
|
||||||
|
);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './PaymentSections.module.scss';
|
||||||
|
import { Alert, Button, Icon, useFormReadOnly } from '../../../shared/ui';
|
||||||
|
import { DATA_SITE_URL } from '../../../shared/config';
|
||||||
|
import { IFormData } from '../model';
|
||||||
|
import { INVOICE_STAGE_TEXT, IInvoiceFlow } from '../useInvoiceFlow';
|
||||||
|
|
||||||
|
interface IInvoiceSectionProps {
|
||||||
|
data: IFormData;
|
||||||
|
invoice: IInvoiceFlow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ссылка на файл счёта: в данных лежит серверный относительный адрес. */
|
||||||
|
function invoiceHref(serverRelativeUrl: string): string {
|
||||||
|
return new URL(serverRelativeUrl, DATA_SITE_URL).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вложение — счёт на оплату (PDF). Файл сохраняется в библиотеку и уходит на OCR;
|
||||||
|
* ИИ достаёт из текста реквизиты, суммы и позиции и подставляет их в форму.
|
||||||
|
* Если распознать не вышло, счёт всё равно остаётся приложенным — поля заполняются руками.
|
||||||
|
*/
|
||||||
|
export const InvoiceSection: React.FC<IInvoiceSectionProps> = ({ data, invoice }) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const busy = invoice.stage !== 'idle';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={styles.invoiceRow}>
|
||||||
|
{data.InvoiceFileName ? (
|
||||||
|
<span>
|
||||||
|
<Icon name="document" size="sm" />{' '}
|
||||||
|
{data.InvoiceUrl ? (
|
||||||
|
<a href={invoiceHref(data.InvoiceUrl)} target="_blank" rel="noreferrer">
|
||||||
|
{data.InvoiceFileName}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
data.InvoiceFileName
|
||||||
|
)}
|
||||||
|
{data.InvoiceNumber && ` — счёт № ${data.InvoiceNumber}`}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={styles.hint}>Счёт ещё не приложен.</span>
|
||||||
|
)}
|
||||||
|
{!readOnly && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className={styles.fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,.pdf"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = '';
|
||||||
|
if (file) invoice.attach(file).catch(() => undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button variant="secondary" disabled={busy} onClick={() => inputRef.current?.click()}>
|
||||||
|
<Icon name="upload" size="sm" />
|
||||||
|
{data.InvoiceFileName ? 'Заменить счёт' : 'Приложить счёт (PDF)'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{busy && <span className={styles.stage}>{INVOICE_STAGE_TEXT[invoice.stage]}</span>}
|
||||||
|
</div>
|
||||||
|
{invoice.error && <Alert variant="warn">{invoice.error}</Alert>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './PaymentSections.module.scss';
|
||||||
|
import { FormField, TextInput, TextArea } from '../../../shared/ui';
|
||||||
|
import {
|
||||||
|
DictionaryPicker,
|
||||||
|
getCashFlowGroups,
|
||||||
|
getCashFlowItems,
|
||||||
|
searchBankAccounts,
|
||||||
|
searchContracts
|
||||||
|
} from '../../../shared/dictionary';
|
||||||
|
import { recipientMatchesInvoice } from '../amounts';
|
||||||
|
import { ISectionProps } from '../model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реквизиты платежа. Банковский счёт, договор и статьи ДДС — из справочников 1С
|
||||||
|
* (счета фильтруются по компании и проекту, договоры ищутся по названию, коду и
|
||||||
|
* контрагенту — фильтра по компании у API договоров нет). Получатель — победитель
|
||||||
|
* СТ; если в самом счёте указан другой контрагент, поле подсвечивается.
|
||||||
|
*/
|
||||||
|
export const PaymentSection: React.FC<ISectionProps> = ({ data, set, patch, context }) => {
|
||||||
|
const recipientMatches = recipientMatchesInvoice(
|
||||||
|
{ name: data.RecipientName, bin: data.RecipientBin },
|
||||||
|
{ name: data.InvoiceSupplierName, bin: data.InvoiceSupplierBin }
|
||||||
|
);
|
||||||
|
const accountFilter = { companyId: data.CompanyId, projectId: data.ProjectId };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField label="Банковский счёт" htmlFor="BankAccountName" required>
|
||||||
|
<DictionaryPicker
|
||||||
|
id="BankAccountName"
|
||||||
|
load={(search) => searchBankAccounts(context, accountFilter, search)}
|
||||||
|
loadKey={`${data.CompanyId}|${data.ProjectId}`}
|
||||||
|
serverSearch
|
||||||
|
value={data.BankAccountName}
|
||||||
|
placeholder="Счёт компании в 1С…"
|
||||||
|
onChange={(label, id) => patch({ BankAccountName: label, BankAccountId: id })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Договор" htmlFor="ContractName">
|
||||||
|
<DictionaryPicker
|
||||||
|
id="ContractName"
|
||||||
|
load={(search) => searchContracts(context, search)}
|
||||||
|
loadKey="contracts"
|
||||||
|
serverSearch
|
||||||
|
value={data.ContractName}
|
||||||
|
placeholder="Название, код договора или контрагент…"
|
||||||
|
onChange={(label, id) => patch({ ContractName: label, ContractId: id })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Получатель" htmlFor="RecipientName" required>
|
||||||
|
<div className={recipientMatches ? undefined : styles.warnBox}>
|
||||||
|
<TextInput id="RecipientName" value={data.RecipientName} onChange={(v) => set('RecipientName', v)} />
|
||||||
|
{!recipientMatches && (
|
||||||
|
<div className={styles.warnText}>
|
||||||
|
В счёте указан другой контрагент: {data.InvoiceSupplierName || '—'}
|
||||||
|
{data.InvoiceSupplierBin ? ` (БИН ${data.InvoiceSupplierBin})` : ''}. Проверьте получателя.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Счёт получателя (IBAN)" htmlFor="RecipientAccount" required>
|
||||||
|
<TextInput id="RecipientAccount" value={data.RecipientAccount} onChange={(v) => set('RecipientAccount', v)} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Раздел" htmlFor="Section">
|
||||||
|
<TextInput id="Section" value={data.Section} onChange={(v) => set('Section', v)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Вид работ" htmlFor="WorkKind">
|
||||||
|
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Группа статьи ДДС" htmlFor="CashFlowGroupName" required>
|
||||||
|
<DictionaryPicker
|
||||||
|
id="CashFlowGroupName"
|
||||||
|
load={() => getCashFlowGroups(context)}
|
||||||
|
loadKey="cash-flow-groups"
|
||||||
|
value={data.CashFlowGroupName}
|
||||||
|
onChange={(label, id) => patch({ CashFlowGroupName: label, CashFlowGroupId: id, CashFlowItemName: '', CashFlowItemId: '' })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Статья ДДС" htmlFor="CashFlowItemName" required>
|
||||||
|
<DictionaryPicker
|
||||||
|
id="CashFlowItemName"
|
||||||
|
load={() => getCashFlowItems(context, data.CashFlowGroupId)}
|
||||||
|
loadKey={data.CashFlowGroupId}
|
||||||
|
value={data.CashFlowItemName}
|
||||||
|
onChange={(label, id) => patch({ CashFlowItemName: label, CashFlowItemId: id })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Назначение платежа" htmlFor="PaymentPurpose" required full>
|
||||||
|
<TextArea id="PaymentPurpose" value={data.PaymentPurpose} onChange={(v) => set('PaymentPurpose', v)} rows={2} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Условия доставки" htmlFor="DeliveryTerms">
|
||||||
|
<TextArea id="DeliveryTerms" value={data.DeliveryTerms} onChange={(v) => set('DeliveryTerms', v)} rows={2} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Условия оплаты" htmlFor="PaymentTerms">
|
||||||
|
<TextArea id="PaymentTerms" value={data.PaymentTerms} onChange={(v) => set('PaymentTerms', v)} rows={2} />
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Подсветка расхождений: поле, значение которого стоит перепроверить, обводится
|
||||||
|
// красным, а под ним — пояснение. Подсветка не блокирует отправку: решает человек.
|
||||||
|
.warnBox {
|
||||||
|
padding: $sp-1;
|
||||||
|
border: 1px solid $red-500;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $red-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnText {
|
||||||
|
margin-top: $sp-1;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: $sp-1;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoiceRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileInput {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $navy-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrap {
|
||||||
|
// Тело секции — flex/grid из ui-kit: без явной ширины блок ужимается по содержимому.
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid $gray-200;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: $gray-50;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $gray-700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
tfoot td {
|
||||||
|
font-weight: 600;
|
||||||
|
background: $gray-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowDiff {
|
||||||
|
background: $gold-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowBad {
|
||||||
|
background: $red-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowOk {
|
||||||
|
color: $green-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBad {
|
||||||
|
color: $red-500;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: $sp-4;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тело CollapseSection из ui-kit — сетка в две колонки; всё не-поле растягиваем на обе.
|
||||||
|
.fullRow {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
color: $navy-700;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: underline;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './PaymentSections.module.scss';
|
||||||
|
import { Alert } from '../../../shared/ui';
|
||||||
|
import { formatAmount } from '../amounts';
|
||||||
|
import { IPositionRow, RowStatus } from '../model';
|
||||||
|
import { hasPositionIssues } from '../positions';
|
||||||
|
|
||||||
|
interface IPositionsSectionProps {
|
||||||
|
rows: IPositionRow[];
|
||||||
|
/** Счёт приложен и сверка выполнялась — только тогда статусы строк что-то значат. */
|
||||||
|
compared: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<RowStatus, string> = {
|
||||||
|
ok: 'Совпадает',
|
||||||
|
diff: 'Расхождение',
|
||||||
|
missing: 'Нет в счёте',
|
||||||
|
extra: 'Нет в СТ'
|
||||||
|
};
|
||||||
|
|
||||||
|
function rowClass(row: IPositionRow, compared: boolean): string | undefined {
|
||||||
|
if (!compared) return undefined;
|
||||||
|
if (row.status === 'diff') return styles.rowDiff;
|
||||||
|
if (row.status === 'missing' || row.status === 'extra') return styles.rowBad;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cell = (value: number): string => (value ? formatAmount(value) : '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Позиции: из СТ/потребности (код, наименование, ед. изм.), позиции поставщика и
|
||||||
|
* позиции из счёта. Строки с расхождениями подсвечены; сверку выполняет ИИ при
|
||||||
|
* загрузке счёта, количество и единицы сверяются кодом.
|
||||||
|
*/
|
||||||
|
export const PositionsSection: React.FC<IPositionsSectionProps> = ({ rows, compared }) => {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return <div className={styles.empty}>Позиции появятся после выбора основания.</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{compared && hasPositionIssues(rows) && (
|
||||||
|
<Alert variant="warn">Позиции счёта отличаются от СТ/потребности — расхождения подсвечены.</Alert>
|
||||||
|
)}
|
||||||
|
<div className={styles.tableWrap}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Код</th>
|
||||||
|
<th>Наименование (потребность)</th>
|
||||||
|
<th>Ед. изм.</th>
|
||||||
|
<th className={styles.num}>Кол-во</th>
|
||||||
|
<th>Позиция поставщика</th>
|
||||||
|
<th>Позиция из счёта</th>
|
||||||
|
<th className={styles.num}>Кол-во в счёте</th>
|
||||||
|
<th className={styles.num}>Цена</th>
|
||||||
|
<th className={styles.num}>Сумма</th>
|
||||||
|
{compared && <th>Сверка</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<tr key={`${row.code}-${index}`} className={rowClass(row, compared)}>
|
||||||
|
<td>{row.code}</td>
|
||||||
|
<td>{row.name}</td>
|
||||||
|
<td>{row.unit}</td>
|
||||||
|
<td className={styles.num}>{cell(row.qty)}</td>
|
||||||
|
<td>{row.supplierName}</td>
|
||||||
|
<td>{row.invoiceName}</td>
|
||||||
|
<td className={styles.num}>{cell(row.invoiceQty)}</td>
|
||||||
|
<td className={styles.num}>{cell(row.invoicePrice)}</td>
|
||||||
|
<td className={styles.num}>{cell(row.invoiceSum)}</td>
|
||||||
|
{compared && (
|
||||||
|
<td className={row.status === 'ok' ? styles.rowOk : styles.statusBad}>
|
||||||
|
{STATUS_LABEL[row.status]}
|
||||||
|
{row.note && row.status !== 'ok' && <div>{row.note}</div>}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as ReactDOM from 'react-dom';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { Drawer } from '../../../components/Drawer/Drawer';
|
||||||
|
import { Spinner } from '@tnsu/ui-kit';
|
||||||
|
import portalStyles from '../../../components/RequestPortal.module.scss';
|
||||||
|
import { FORM_ID } from '../model';
|
||||||
|
|
||||||
|
// Лениво: ApproveFeature через реестр просмотров тянет PaymentRequestView, а тот —
|
||||||
|
// эту же секцию. Статический импорт замкнул бы цикл модулей.
|
||||||
|
const ApproveFeature = React.lazy(() => import(/* webpackChunkName: 'approve-feature' */ '../../../features/approve/ApproveFeature'));
|
||||||
|
|
||||||
|
interface IRequestDrawerProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
itemId: number;
|
||||||
|
/** Партиция, где лежит заявка; '' — текущая. */
|
||||||
|
listId: string;
|
||||||
|
code: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прежняя заявка на оплату в выезжающей панели — той же, что у таблиц портала.
|
||||||
|
* Открываем её здесь, а не переходом на rpmode=approve: смена маршрута
|
||||||
|
* пересоздала бы открытую форму, и всё введённое пропало бы.
|
||||||
|
*
|
||||||
|
* Через портал в body: форма сама бывает открыта в панели (черновик), а
|
||||||
|
* fixed-слой внутри неё ограничивался бы её рамками. Корневой класс портала
|
||||||
|
* возвращает шрифт и цвет, которые в body не наследуются.
|
||||||
|
*/
|
||||||
|
export const RequestDrawer: React.FC<IRequestDrawerProps> = ({ context, itemId, listId, code, onClose }) =>
|
||||||
|
ReactDOM.createPortal(
|
||||||
|
<div className={portalStyles.requestPortal}>
|
||||||
|
<Drawer title={`Заявка №${code}`} onClose={onClose}>
|
||||||
|
<React.Suspense fallback={<Spinner block label="Загружаем заявку…" />}>
|
||||||
|
<ApproveFeature context={context} formKey={FORM_ID} itemId={itemId} listId={listId || undefined} />
|
||||||
|
</React.Suspense>
|
||||||
|
</Drawer>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { BasisKind } from '../model';
|
||||||
|
|
||||||
|
/** Позиция основания: из СТ (с предложением победителя) либо из потребности. */
|
||||||
|
export interface IBasisPosition {
|
||||||
|
/** Уникальный код позиции потребности; '' — у потребности его нет. */
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
qty: number;
|
||||||
|
/** Позиция поставщика из его КП; '' / 0 — у потребности КП нет. */
|
||||||
|
supplierName: string;
|
||||||
|
supplierQty: number;
|
||||||
|
supplierPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Всё, что форма берёт из выбранной СТ или потребности. */
|
||||||
|
export interface IBasisInfo {
|
||||||
|
kind: BasisKind;
|
||||||
|
/** «ct:<Id СТ>» / «need:<oid потребности>» — по нему ищем прежние оплаты. */
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
projectName: string;
|
||||||
|
/** Раздел и вид работ. */
|
||||||
|
section: string;
|
||||||
|
workKind: string;
|
||||||
|
/** Компания-заказчик, если известна (подбирается по справочнику компаний). */
|
||||||
|
companyHint: string;
|
||||||
|
/** Выигравший поставщик СТ; у потребности пустой. */
|
||||||
|
recipientName: string;
|
||||||
|
recipientBin: string;
|
||||||
|
/** Общая сумма основания и её валюта; 0 — не определена. */
|
||||||
|
total: number;
|
||||||
|
currency: string;
|
||||||
|
deliveryTerms: string;
|
||||||
|
paymentTerms: string;
|
||||||
|
deliveryTime: string;
|
||||||
|
positions: IBasisPosition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка выпадающего списка выбора основания. */
|
||||||
|
export interface IBasisOption {
|
||||||
|
/** Id СТ либо oid потребности. */
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
/** Пояснение: проект, дата, число позиций. */
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { CT_SITE_URL } from '../../../shared/config';
|
||||||
|
import { spForSite } from '../../../shared/pnp/pnpjs';
|
||||||
|
import { IBasisInfo, IBasisOption, IBasisPosition } from './basis.types';
|
||||||
|
|
||||||
|
// Списки сайта СТ (см. ct-forms-portal, CtService.LISTS) — имена те же.
|
||||||
|
const CT_LISTS = {
|
||||||
|
ct: 'Сравнительная таблица',
|
||||||
|
positions: 'Позиции СТ',
|
||||||
|
offers: 'Оценка поставщиков',
|
||||||
|
suppliers: 'Поставщики',
|
||||||
|
criteria: 'Критерии',
|
||||||
|
rates: 'Курс Валют'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Значение Choice «Статус» у согласованной СТ. */
|
||||||
|
const CT_STATUS_APPROVED = 'Согласовано';
|
||||||
|
/** Валюта предложений без подстановки «Валюта» — в СТ это тенге. */
|
||||||
|
const DEFAULT_CURRENCY = 'KZT';
|
||||||
|
const MAX_CTS = 500;
|
||||||
|
const MAX_POSITIONS = 500;
|
||||||
|
/** Сколько позиций кладём в один OR-фильтр: длинная строка запроса SharePoint не принимает. */
|
||||||
|
const FILTER_CHUNK = 20;
|
||||||
|
|
||||||
|
interface ICtRow {
|
||||||
|
Id: number;
|
||||||
|
number?: number | string;
|
||||||
|
project?: string;
|
||||||
|
winSupplierId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPositionRow {
|
||||||
|
Id: number;
|
||||||
|
Title?: string;
|
||||||
|
unit?: string;
|
||||||
|
quantity?: number;
|
||||||
|
disciplineCode?: string;
|
||||||
|
SMR?: string;
|
||||||
|
uniqueCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IOfferRow {
|
||||||
|
positionId: number;
|
||||||
|
supplierPosition?: string;
|
||||||
|
quantity?: number;
|
||||||
|
price?: number;
|
||||||
|
summa?: number;
|
||||||
|
summaTenge?: number;
|
||||||
|
valutaId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ICriteriaRow {
|
||||||
|
paymentType?: string;
|
||||||
|
deliveryType?: string;
|
||||||
|
deadline?: string;
|
||||||
|
criteriaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctWeb(context: WebPartContext): ReturnType<typeof spForSite>['web'] {
|
||||||
|
return spForSite(CT_SITE_URL, context).web;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk<T>(items: T[], size: number): T[][] {
|
||||||
|
const result: T[][] = [];
|
||||||
|
for (let i = 0; i < items.length; i += size) result.push(items.slice(i, i + size));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unique(values: Array<string | undefined>): string[] {
|
||||||
|
const seen: string[] = [];
|
||||||
|
values.forEach((v) => {
|
||||||
|
const value = (v || '').trim();
|
||||||
|
if (value && seen.indexOf(value) === -1) seen.push(value);
|
||||||
|
});
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctTitle(row: ICtRow): string {
|
||||||
|
return `СТ №${row.number ?? row.Id}${row.project ? ` — ${row.project}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Согласованные СТ для выбора основания, новые сверху. */
|
||||||
|
export async function listApprovedCts(context: WebPartContext): Promise<IBasisOption[]> {
|
||||||
|
const rows = (await ctWeb(context)
|
||||||
|
.lists.getByTitle(CT_LISTS.ct)
|
||||||
|
.items.select('Id', 'number', 'project', 'winSupplierId')
|
||||||
|
.filter(`status eq '${CT_STATUS_APPROVED}'`)
|
||||||
|
.orderBy('Id', false)
|
||||||
|
.top(MAX_CTS)()) as ICtRow[];
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: String(row.Id),
|
||||||
|
title: ctTitle(row),
|
||||||
|
note: row.winSupplierId ? '' : 'победитель не выбран'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Предложения победителя по позициям СТ (по чанкам — см. FILTER_CHUNK). */
|
||||||
|
async function loadWinnerOffers(
|
||||||
|
context: WebPartContext,
|
||||||
|
supplierId: number,
|
||||||
|
positionIds: number[]
|
||||||
|
): Promise<IOfferRow[]> {
|
||||||
|
const lists = ctWeb(context).lists;
|
||||||
|
const parts = await Promise.all(
|
||||||
|
chunk(positionIds, FILTER_CHUNK).map((ids) =>
|
||||||
|
lists
|
||||||
|
.getByTitle(CT_LISTS.offers)
|
||||||
|
.items.select('Id', 'positionId', 'supplierPosition', 'quantity', 'price', 'summa', 'summaTenge', 'valutaId')
|
||||||
|
.filter(`supplierId eq ${supplierId} and (${ids.map((id) => `positionId eq ${id}`).join(' or ')})`)
|
||||||
|
.top(MAX_POSITIONS)()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return ([] as IOfferRow[]).concat(...(parts as IOfferRow[][]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Код валюты по Id записи «Курс Валют»; пустая подстановка — тенге. */
|
||||||
|
async function loadCurrencyCodes(context: WebPartContext, ids: number[]): Promise<Map<number, string>> {
|
||||||
|
const codes = new Map<number, string>();
|
||||||
|
await Promise.all(
|
||||||
|
ids.map(async (id) => {
|
||||||
|
const rate = (await ctWeb(context).lists.getByTitle(CT_LISTS.rates).items.getById(id).select('code')()) as { code?: string };
|
||||||
|
codes.set(id, rate.code || DEFAULT_CURRENCY);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Условия победителя из «Критериев» СТ: оплата, доставка, срок. */
|
||||||
|
async function loadWinnerTerms(context: WebPartContext, ctId: number, supplierId: number): Promise<ICriteriaRow> {
|
||||||
|
const rows = (await ctWeb(context)
|
||||||
|
.lists.getByTitle(CT_LISTS.criteria)
|
||||||
|
.items.select('Id', 'paymentType', 'deliveryType', 'deadline', 'criteriaLabel')
|
||||||
|
.filter(`CTId eq ${ctId} and supplierId eq ${supplierId}`)
|
||||||
|
.top(MAX_POSITIONS)()) as ICriteriaRow[];
|
||||||
|
// Строки с criteriaLabel — произвольные критерии, а не условия поставки.
|
||||||
|
return rows.filter((r) => !r.criteriaLabel && (r.paymentType || r.deliveryType || r.deadline))[0] || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBasisPosition(position: IPositionRow, offers: IOfferRow[]): IBasisPosition {
|
||||||
|
return {
|
||||||
|
code: position.uniqueCode || '',
|
||||||
|
name: position.Title || '',
|
||||||
|
unit: position.unit || '',
|
||||||
|
qty: Number(position.quantity || 0),
|
||||||
|
supplierName: unique(offers.map((o) => o.supplierPosition)).join(' / '),
|
||||||
|
supplierQty: offers.reduce((sum, o) => sum + Number(o.quantity || 0), 0),
|
||||||
|
supplierPrice: Number(offers[0]?.price || 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общая сумма предложений победителя. Все в одной валюте — суммируем «Сумма» в
|
||||||
|
* этой валюте; смешанные — берём «Сумма в тенге», иначе числа несопоставимы.
|
||||||
|
*/
|
||||||
|
function offersTotal(offers: IOfferRow[], codes: Map<number, string>): { total: number; currency: string } {
|
||||||
|
const currencies = unique(offers.map((o) => (o.valutaId ? codes.get(o.valutaId) : DEFAULT_CURRENCY)));
|
||||||
|
if (currencies.length > 1) {
|
||||||
|
return { total: offers.reduce((s, o) => s + Number(o.summaTenge || 0), 0), currency: DEFAULT_CURRENCY };
|
||||||
|
}
|
||||||
|
return { total: offers.reduce((s, o) => s + Number(o.summa || 0), 0), currency: currencies[0] || DEFAULT_CURRENCY };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Всё, что форма берёт из согласованной СТ: победитель, позиции, суммы, условия. */
|
||||||
|
export async function loadCtBasis(context: WebPartContext, ctId: string): Promise<IBasisInfo> {
|
||||||
|
const web = ctWeb(context);
|
||||||
|
const id = parseInt(ctId, 10);
|
||||||
|
const [ct, positions] = await Promise.all([
|
||||||
|
web.lists.getByTitle(CT_LISTS.ct).items.getById(id).select('Id', 'number', 'project', 'winSupplierId')() as Promise<ICtRow>,
|
||||||
|
web.lists
|
||||||
|
.getByTitle(CT_LISTS.positions)
|
||||||
|
.items.select('Id', 'Title', 'unit', 'quantity', 'disciplineCode', 'SMR', 'uniqueCode')
|
||||||
|
.filter(`CTId eq ${id}`)
|
||||||
|
.top(MAX_POSITIONS)() as Promise<IPositionRow[]>
|
||||||
|
]);
|
||||||
|
|
||||||
|
const winnerId = ct.winSupplierId || 0;
|
||||||
|
const [winner, offers, terms] = await Promise.all([
|
||||||
|
winnerId
|
||||||
|
? (web.lists.getByTitle(CT_LISTS.suppliers).items.getById(winnerId).select('name', 'bin')() as Promise<{ name?: string; bin?: string }>)
|
||||||
|
: Promise.resolve({} as { name?: string; bin?: string }),
|
||||||
|
winnerId && positions.length > 0 ? loadWinnerOffers(context, winnerId, positions.map((p) => p.Id)) : Promise.resolve([] as IOfferRow[]),
|
||||||
|
winnerId ? loadWinnerTerms(context, id, winnerId) : Promise.resolve({} as ICriteriaRow)
|
||||||
|
]);
|
||||||
|
const currencyIds = unique(offers.map((o) => (o.valutaId ? String(o.valutaId) : ''))).map(Number);
|
||||||
|
const codes = await loadCurrencyCodes(context, currencyIds);
|
||||||
|
const { total, currency } = offersTotal(offers, codes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'ct',
|
||||||
|
key: `ct:${ct.Id}`,
|
||||||
|
title: ctTitle(ct),
|
||||||
|
projectName: ct.project || '',
|
||||||
|
section: unique(positions.map((p) => p.disciplineCode)).join(', '),
|
||||||
|
workKind: unique(positions.map((p) => p.SMR)).join(', '),
|
||||||
|
companyHint: '',
|
||||||
|
recipientName: winner.name || '',
|
||||||
|
recipientBin: winner.bin || '',
|
||||||
|
total,
|
||||||
|
currency,
|
||||||
|
deliveryTerms: terms.deliveryType || '',
|
||||||
|
paymentTerms: terms.paymentType || '',
|
||||||
|
deliveryTime: terms.deadline || '',
|
||||||
|
positions: positions.map((p) =>
|
||||||
|
toBasisPosition(
|
||||||
|
p,
|
||||||
|
offers.filter((o) => o.positionId === p.Id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import '@pnp/sp/webs';
|
||||||
|
import '@pnp/sp/lists';
|
||||||
|
import '@pnp/sp/items';
|
||||||
|
import '@pnp/sp/folders';
|
||||||
|
import '@pnp/sp/files';
|
||||||
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { APPROVAL_API_CLIENT_ID, DATA_SITE_URL, DICTIONARY_API_BASE_URL } from '../../../shared/config';
|
||||||
|
import { PAYMENT_INVOICES_LIBRARY_TITLE } from '../../../shared/listTitles';
|
||||||
|
import { getSP } from '../../../shared/pnp/pnpjs';
|
||||||
|
import { fileStamp } from '../../../shared/chat/chatFileNames';
|
||||||
|
|
||||||
|
/** Колонка библиотеки, куда OCR кладёт распознанный текст счёта. */
|
||||||
|
const FILE_CONTENT_FIELD = 'file_content';
|
||||||
|
|
||||||
|
export const MAX_INVOICE_SIZE_BYTES = 15 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface IStoredInvoice {
|
||||||
|
/** Id элемента библиотеки — по нему сервис OCR находит файл. */
|
||||||
|
itemId: number;
|
||||||
|
/** Имя файла, как он назван в библиотеке (с отметкой времени). */
|
||||||
|
storedName: string;
|
||||||
|
serverRelativeUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проверка файла счёта до загрузки. Возвращает текст ошибки или undefined. */
|
||||||
|
export function validateInvoiceFile(file: File): string | undefined {
|
||||||
|
if (!/\.pdf$/i.test(file.name)) {
|
||||||
|
return 'Счёт должен быть в формате PDF — другие форматы сервис распознавания не читает.';
|
||||||
|
}
|
||||||
|
if (file.size > MAX_INVOICE_SIZE_BYTES) {
|
||||||
|
return `Файл больше ${Math.round(MAX_INVOICE_SIZE_BYTES / (1024 * 1024))} МБ.`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить файл счёта в библиотеку. Делаем это сразу при выборе файла, до
|
||||||
|
* сохранения самой заявки: OCR работает с элементом библиотеки, а не с вложением.
|
||||||
|
* Имя в библиотеке — только латиница и цифры: OCR-сервис получает имя в multipart и
|
||||||
|
* на кириллице теряет расширение («unsupported file type ''»). Исходное имя
|
||||||
|
* хранится в данных заявки (InvoiceFileName), поэтому в библиотеке оно не нужно.
|
||||||
|
* Метка времени и случайный хвост исключают перезапись счёта другой заявки.
|
||||||
|
*/
|
||||||
|
export async function storeInvoice(file: File): Promise<IStoredInvoice> {
|
||||||
|
const storedName = `invoice-${fileStamp(new Date())}-${Math.random().toString(36).slice(2, 8)}.pdf`;
|
||||||
|
const root = getSP().web.lists.getByTitle(PAYMENT_INVOICES_LIBRARY_TITLE).rootFolder;
|
||||||
|
const added = await root.files.addUsingPath(storedName, file);
|
||||||
|
// Id элемента библиотеки нужен OCR, а addUsingPath отдаёт только сведения о файле.
|
||||||
|
const item = (await getSP()
|
||||||
|
.web.getFileByServerRelativePath(added.ServerRelativeUrl)
|
||||||
|
.listItemAllFields.select('Id')()) as { Id: number };
|
||||||
|
return { itemId: item.Id, storedName, serverRelativeUrl: added.ServerRelativeUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFileContent(itemId: number): Promise<string> {
|
||||||
|
const item = (await getSP()
|
||||||
|
.web.lists.getByTitle(PAYMENT_INVOICES_LIBRARY_TITLE)
|
||||||
|
.items.getById(itemId)
|
||||||
|
.select(FILE_CONTENT_FIELD)()) as Record<string, unknown>;
|
||||||
|
return typeof item[FILE_CONTENT_FIELD] === 'string' ? (item[FILE_CONTENT_FIELD] as string).trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправить сохранённый счёт на OCR (POST /Document/extractPDFText) и вернуть
|
||||||
|
* распознанный текст из колонки file_content. Сервис отвечает только после того,
|
||||||
|
* как записал текст в колонку, поэтому читаем её сразу, без опроса.
|
||||||
|
*/
|
||||||
|
export async function recognizeInvoice(context: WebPartContext, itemId: number): Promise<string> {
|
||||||
|
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||||
|
const response: HttpClientResponse = await client.post(
|
||||||
|
`${DICTIONARY_API_BASE_URL}/Document/extractPDFText`,
|
||||||
|
AadHttpClient.configurations.v1,
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
siteUrl: DATA_SITE_URL,
|
||||||
|
libraryName: PAYMENT_INVOICES_LIBRARY_TITLE,
|
||||||
|
itemId: String(itemId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Не удалось распознать счёт (Document/extractPDFText): ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await readFileContent(itemId);
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('Сервис распознавания не нашёл в счёте текста.');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { askGemini, IGeminiSchema } from '../../../shared/ai/gemini.service';
|
||||||
|
import { IInvoiceItem, IPositionPair } from '../positions';
|
||||||
|
import { IBasisPosition } from './basis.types';
|
||||||
|
|
||||||
|
/** Текст счёта режем: прокси ИИ ограничен по размеру запроса, а хвост счёта — реквизиты и подписи. */
|
||||||
|
const MAX_INVOICE_TEXT_CHARS = 30000;
|
||||||
|
|
||||||
|
/** Что ИИ достаёт из текста счёта. */
|
||||||
|
export interface IInvoiceData {
|
||||||
|
invoiceNumber: string;
|
||||||
|
supplierName: string;
|
||||||
|
supplierBin: string;
|
||||||
|
/** Счёт получателя (IBAN). */
|
||||||
|
recipientAccount: string;
|
||||||
|
currency: string;
|
||||||
|
totalAmount: number;
|
||||||
|
vatAmount: number;
|
||||||
|
items: IInvoiceItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRING: IGeminiSchema = { type: 'string' };
|
||||||
|
const NUMBER: IGeminiSchema = { type: 'number' };
|
||||||
|
|
||||||
|
const INVOICE_SCHEMA: IGeminiSchema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
invoiceNumber: STRING,
|
||||||
|
supplierName: STRING,
|
||||||
|
supplierBin: STRING,
|
||||||
|
recipientAccount: STRING,
|
||||||
|
currency: STRING,
|
||||||
|
totalAmount: NUMBER,
|
||||||
|
vatAmount: NUMBER,
|
||||||
|
items: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { name: STRING, unit: STRING, quantity: NUMBER, price: NUMBER, sum: NUMBER },
|
||||||
|
required: ['name', 'quantity']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['invoiceNumber', 'totalAmount', 'items']
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAIRS_SCHEMA: IGeminiSchema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
pairs: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { baseIndex: { type: 'integer' }, invoiceIndex: { type: 'integer' }, note: STRING },
|
||||||
|
required: ['baseIndex', 'invoiceIndex']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['pairs']
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Разобрать текст счёта: номер, поставщик, реквизиты, суммы, позиции. */
|
||||||
|
export async function parseInvoiceText(context: WebPartContext, text: string): Promise<IInvoiceData> {
|
||||||
|
const prompt =
|
||||||
|
'Ниже распознанный текст счёта на оплату. Извлеки: номер счёта, наименование поставщика (продавца), ' +
|
||||||
|
'его БИН/ИИН (12 цифр, пустая строка если нет), счёт получателя платежа (IBAN, например KZ…), ' +
|
||||||
|
'валюту (код: KZT, USD, EUR, RUB, CNY), итоговую сумму к оплате, сумму НДС (0 если нет) и все позиции ' +
|
||||||
|
'таблицы (наименование, единица измерения, количество, цена за единицу, сумма по строке). ' +
|
||||||
|
'Числа — без пробелов и валютных знаков. Ничего не выдумывай: чего нет в тексте — пустая строка или 0.\n\n' +
|
||||||
|
`Текст счёта:\n${text.slice(0, MAX_INVOICE_TEXT_CHARS)}`;
|
||||||
|
const data = await askGemini<Partial<IInvoiceData>>(context, prompt, INVOICE_SCHEMA);
|
||||||
|
return {
|
||||||
|
invoiceNumber: data.invoiceNumber || '',
|
||||||
|
supplierName: data.supplierName || '',
|
||||||
|
supplierBin: (data.supplierBin || '').replace(/\D/g, ''),
|
||||||
|
recipientAccount: (data.recipientAccount || '').replace(/\s/g, ''),
|
||||||
|
currency: (data.currency || '').toUpperCase(),
|
||||||
|
totalAmount: Number(data.totalAmount || 0),
|
||||||
|
vatAmount: Number(data.vatAmount || 0),
|
||||||
|
items: (data.items || []).map((item) => ({
|
||||||
|
name: item.name || '',
|
||||||
|
unit: item.unit || '',
|
||||||
|
quantity: Number(item.quantity || 0),
|
||||||
|
price: Number(item.price || 0),
|
||||||
|
sum: Number(item.sum || 0)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Попросить ИИ сопоставить позиции основания (СТ/потребности) с позициями счёта.
|
||||||
|
* Названия в счёте поставщик пишет по-своему, поэтому сопоставляем по смыслу;
|
||||||
|
* количества и единицы потом сверяет код (см. buildRows), а не ИИ.
|
||||||
|
*/
|
||||||
|
export async function matchPositions(
|
||||||
|
context: WebPartContext,
|
||||||
|
base: IBasisPosition[],
|
||||||
|
invoice: IInvoiceItem[]
|
||||||
|
): Promise<IPositionPair[]> {
|
||||||
|
if (base.length === 0 || invoice.length === 0) return [];
|
||||||
|
const baseLines = base.map((p, i) => `${i}. ${p.name} (${p.unit}, ${p.qty})${p.supplierName ? ` — у поставщика: ${p.supplierName}` : ''}`);
|
||||||
|
const invoiceLines = invoice.map((p, i) => `${i}. ${p.name} (${p.unit}, ${p.quantity})`);
|
||||||
|
const prompt =
|
||||||
|
'Сопоставь позиции заявки с позициями счёта поставщика. Одна позиция заявки соответствует не более чем ' +
|
||||||
|
'одной позиции счёта и наоборот. Для каждой найденной пары верни baseIndex и invoiceIndex. ' +
|
||||||
|
'Позиции без пары верни с индексом −1 с отсутствующей стороны. В note коротко (до 10 слов) опиши отличие ' +
|
||||||
|
'в названии, если оно смысловое (другая марка, размер, материал); если названия по смыслу совпадают — пустая строка.\n\n' +
|
||||||
|
`Позиции заявки:\n${baseLines.join('\n')}\n\nПозиции счёта:\n${invoiceLines.join('\n')}`;
|
||||||
|
const result = await askGemini<{ pairs?: IPositionPair[] }>(context, prompt, PAIRS_SCHEMA);
|
||||||
|
return (result.pairs || []).map((pair) => ({
|
||||||
|
baseIndex: Number(pair.baseIndex),
|
||||||
|
invoiceIndex: Number(pair.invoiceIndex),
|
||||||
|
note: pair.note || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../../../shared/config';
|
||||||
|
import { IBasisInfo, IBasisOption, IBasisPosition } from './basis.types';
|
||||||
|
|
||||||
|
/** За сколько дней показываем потребности — как шаг выбора позиций в СТ. */
|
||||||
|
const NEEDS_LOOKBACK_DAYS = 365;
|
||||||
|
|
||||||
|
type RawRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
interface INeed {
|
||||||
|
id: string;
|
||||||
|
requestNumber: string;
|
||||||
|
project: string;
|
||||||
|
section: string;
|
||||||
|
workKind: string;
|
||||||
|
organization: string;
|
||||||
|
createdAt: string;
|
||||||
|
positionsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiGet(context: WebPartContext, path: string): Promise<unknown> {
|
||||||
|
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||||
|
const response: HttpClientResponse = await client.get(`${DICTIONARY_API_BASE_URL}${path}`, AadHttpClient.configurations.v1);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${path}: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapNeed(item: RawRecord): INeed {
|
||||||
|
return {
|
||||||
|
id: String(item.id ?? ''),
|
||||||
|
requestNumber: String(item.requestNumber ?? ''),
|
||||||
|
// В API нет отдельной сущности «проект» — description и есть название проекта (как в шаге 1 СТ).
|
||||||
|
project: String(item.description ?? ''),
|
||||||
|
section: String(item.discipline ?? ''),
|
||||||
|
workKind: String(item.workType ?? ''),
|
||||||
|
organization: String(item.organization ?? ''),
|
||||||
|
createdAt: String(item.requestDate ?? ''),
|
||||||
|
positionsCount: Number(item.positionsCount ?? 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Список потребностей большой и в рамках сессии не меняется — грузим один раз.
|
||||||
|
let needsCache: Promise<INeed[]> | undefined;
|
||||||
|
|
||||||
|
function getNeeds(context: WebPartContext): Promise<INeed[]> {
|
||||||
|
if (!needsCache) {
|
||||||
|
needsCache = (apiGet(context, `/Requests/${NEEDS_LOOKBACK_DAYS}`) as Promise<RawRecord[]>)
|
||||||
|
.then((rows) => rows.map(mapNeed))
|
||||||
|
.catch((e) => {
|
||||||
|
needsCache = undefined;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return needsCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Потребности для выбора основания. */
|
||||||
|
export async function listNeeds(context: WebPartContext): Promise<IBasisOption[]> {
|
||||||
|
const needs = await getNeeds(context);
|
||||||
|
return needs.map((need) => ({
|
||||||
|
id: need.id,
|
||||||
|
title: `Потребность ${need.requestNumber}`,
|
||||||
|
note: [need.project, need.organization].filter(Boolean).join(' · ')
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Основание «Потребность»: проект, раздел, вид работ и позиции без КП и победителя. */
|
||||||
|
export async function loadNeedBasis(context: WebPartContext, needId: string): Promise<IBasisInfo> {
|
||||||
|
const needs = await getNeeds(context);
|
||||||
|
const need = needs.filter((n) => n.id === needId)[0];
|
||||||
|
if (!need) {
|
||||||
|
throw new Error('Потребность не найдена в списке — обновите страницу.');
|
||||||
|
}
|
||||||
|
const rows = (await apiGet(context, `/Requests/${encodeURIComponent(needId)}/positions`)) as RawRecord[];
|
||||||
|
const positions: IBasisPosition[] = rows.map((row) => ({
|
||||||
|
// Отдельного кода у позиции потребности нет; номер строки табличной части 1С
|
||||||
|
// (lineNumber) уникален в пределах заявки — он и служит кодом позиции.
|
||||||
|
code: row.lineNumber !== undefined && row.lineNumber !== null ? String(row.lineNumber) : '',
|
||||||
|
name: String(row.materialName ?? ''),
|
||||||
|
unit: String(row.unit ?? ''),
|
||||||
|
qty: Number(row.quantity ?? 0),
|
||||||
|
supplierName: '',
|
||||||
|
supplierQty: 0,
|
||||||
|
supplierPrice: 0
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
kind: 'need',
|
||||||
|
key: `need:${need.id}`,
|
||||||
|
title: `Потребность ${need.requestNumber}`,
|
||||||
|
projectName: need.project,
|
||||||
|
section: need.section,
|
||||||
|
workKind: need.workKind,
|
||||||
|
companyHint: need.organization,
|
||||||
|
recipientName: '',
|
||||||
|
recipientBin: '',
|
||||||
|
total: 0,
|
||||||
|
currency: '',
|
||||||
|
deliveryTerms: '',
|
||||||
|
paymentTerms: '',
|
||||||
|
deliveryTime: '',
|
||||||
|
positions
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { REQUEST_STATUS, getBasisRequests, getRequestTypeIdByPageUrl } from '../../../shared/approval';
|
||||||
|
import { FORM_ID, IPreviousPayment } from '../model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Статусы, которые в «уже оплачено/заявлено» не входят: черновик ещё не подан,
|
||||||
|
* отклонённая и возвращённая заявки платёж не создают.
|
||||||
|
*/
|
||||||
|
const NOT_COUNTED_STATUSES: string[] = [REQUEST_STATUS.draft, REQUEST_STATUS.rejected, REQUEST_STATUS.returned];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прежние заявки на оплату по тому же основанию (СТ или потребности) — по всем
|
||||||
|
* партициям списка.
|
||||||
|
*
|
||||||
|
* Читаем не список напрямую, а через approval-route (GET /api/basis-requests):
|
||||||
|
* права на элементы заявок разорваны поштучно, и под своей учёткой заявитель
|
||||||
|
* видел бы только свои оплаты — сумма по СТ занижалась бы и проверка превышения
|
||||||
|
* пропускала переплату. Сервер отдаёт все заявки, а canOpen говорит, можно ли
|
||||||
|
* текущему пользователю открыть каждую (без прав ссылки в таблице нет).
|
||||||
|
*
|
||||||
|
* excludeId — сама редактируемая заявка: себя в «ранее поданных» показывать нельзя.
|
||||||
|
*/
|
||||||
|
export async function loadPreviousPayments(
|
||||||
|
context: WebPartContext,
|
||||||
|
basisKey: string,
|
||||||
|
excludeId?: number
|
||||||
|
): Promise<IPreviousPayment[]> {
|
||||||
|
if (!basisKey) return [];
|
||||||
|
const requestTypeId = await getRequestTypeIdByPageUrl(context, FORM_ID);
|
||||||
|
const rows = await getBasisRequests(context, requestTypeId, basisKey);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.filter((row) => row.id !== excludeId && NOT_COUNTED_STATUSES.indexOf(row.status) === -1)
|
||||||
|
.map((row) => ({
|
||||||
|
code: row.uniqueCode || `№${row.id}`,
|
||||||
|
amount: Number(row.amount || 0),
|
||||||
|
id: row.canOpen ? row.id : undefined,
|
||||||
|
listId: row.listId,
|
||||||
|
status: row.status
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
|
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
||||||
|
import { FORM_ID, IFormData } from './model';
|
||||||
|
|
||||||
|
/** Экземпляр сервиса списка согласования этой формы (см. approvalServiceFactory). */
|
||||||
|
export function paymentRequestApprovalService(listId?: string): ApprovalListService {
|
||||||
|
return approvalServiceFor(FORM_ID, listId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Осмысленный заголовок элемента (системная колонка Title). */
|
||||||
|
function buildTitle(data: IFormData): string {
|
||||||
|
const subject = data.RecipientName.trim() || data.BasisTitle;
|
||||||
|
return ['Заявка на оплату', [subject, data.InvoiceNumber ? `счёт ${data.InvoiceNumber}` : ''].filter(Boolean).join(', ')]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(': ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Колонки, которые не копируются как есть: табличные части идут в JSON. */
|
||||||
|
const JSON_KEYS: Array<keyof IFormData> = ['PreviousPayments', 'Positions'];
|
||||||
|
|
||||||
|
/** Поля элемента списка данных из модели формы. Пустая строка → null, чтобы не хранить пустые значения. */
|
||||||
|
function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
||||||
|
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
|
||||||
|
|
||||||
|
(Object.keys(data) as Array<keyof IFormData>).forEach((key) => {
|
||||||
|
if (JSON_KEYS.indexOf(key) !== -1) return;
|
||||||
|
const value = data[key];
|
||||||
|
item[key] = value === '' ? null : value;
|
||||||
|
});
|
||||||
|
|
||||||
|
item.PreviousPaymentsJson = data.PreviousPayments.length > 0 ? JSON.stringify(data.PreviousPayments) : null;
|
||||||
|
item.PositionsJson = data.Positions.length > 0 ? JSON.stringify(data.Positions) : null;
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ISaveResult = ISaveItemResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить «Заявку на оплату»: контент + маршрут согласования. existingId задан →
|
||||||
|
* обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
|
* listId — партиция, в которую пишем (см. resolveWriteListId).
|
||||||
|
*/
|
||||||
|
export async function savePaymentRequest(
|
||||||
|
existingId: number | undefined,
|
||||||
|
requestTypeId: number,
|
||||||
|
data: IFormData,
|
||||||
|
approvers: IApproverToWrite[],
|
||||||
|
executors: IApproverToWrite[],
|
||||||
|
status: string,
|
||||||
|
listId?: string
|
||||||
|
): Promise<ISaveResult> {
|
||||||
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, buildItem(data, status));
|
||||||
|
await writeRoute(paymentRequestApprovalService(listId), result.id, approvers, executors, status);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { getCompanies, getProjects } from '../../shared/dictionary';
|
||||||
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
|
import { basisPatch } from './basisPatch';
|
||||||
|
import { BasisKind, IFormData } from './model';
|
||||||
|
import { loadCtBasis } from './services/ctBasis.service';
|
||||||
|
import { loadNeedBasis } from './services/needBasis.service';
|
||||||
|
import { loadPreviousPayments } from './services/previousPayments.service';
|
||||||
|
|
||||||
|
export interface IBasisSelection {
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
/** Загрузить выбранную СТ/потребность и подставить её данные в форму. */
|
||||||
|
select: (kind: BasisKind, id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор основания оплаты. Справочники проектов и компаний нужны только для
|
||||||
|
* подбора oid/Id по тексту (в СТ проект — строка), поэтому их сбой не мешает:
|
||||||
|
* без них проект/компания просто выбираются руками.
|
||||||
|
*/
|
||||||
|
export function useBasisSelection(
|
||||||
|
context: WebPartContext,
|
||||||
|
patch: (changes: Partial<IFormData>) => void,
|
||||||
|
editId: number | undefined
|
||||||
|
): IBasisSelection {
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
const select = useCallback(
|
||||||
|
async (kind: BasisKind, id: string): Promise<void> => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const info = kind === 'ct' ? await loadCtBasis(context, id) : await loadNeedBasis(context, id);
|
||||||
|
const [previous, projects, companies] = await Promise.all([
|
||||||
|
loadPreviousPayments(context, info.key, editId),
|
||||||
|
getProjects(context).catch(() => []),
|
||||||
|
getCompanies(context).catch(() => [])
|
||||||
|
]);
|
||||||
|
patch(basisPatch(info, previous, projects, companies));
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Не удалось загрузить основание: ${friendlyErrorMessage(e)}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[context, patch, editId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { loading, error, select };
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
|
import { IFormData } from './model';
|
||||||
|
import { basisPositionsFromRows, invoicePatch } from './invoicePatch';
|
||||||
|
import { recognizeInvoice, storeInvoice, validateInvoiceFile } from './services/invoice.service';
|
||||||
|
import { matchPositions, parseInvoiceText } from './services/invoiceAi.service';
|
||||||
|
|
||||||
|
export type InvoiceStage = 'idle' | 'upload' | 'ocr' | 'analyze';
|
||||||
|
|
||||||
|
export const INVOICE_STAGE_TEXT: Record<InvoiceStage, string> = {
|
||||||
|
idle: '',
|
||||||
|
upload: 'Сохраняем счёт в библиотеку…',
|
||||||
|
ocr: 'Распознаём текст счёта (OCR)…',
|
||||||
|
analyze: 'ИИ разбирает счёт и сверяет позиции…'
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IInvoiceFlow {
|
||||||
|
stage: InvoiceStage;
|
||||||
|
/** Ошибка последнего шага; файл при этом уже сохранён, форму можно заполнить руками. */
|
||||||
|
error: string;
|
||||||
|
attach: (file: File) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвейер счёта: библиотека → OCR → ИИ (реквизиты и позиции) → сверка с основанием.
|
||||||
|
* Каждый шаг после сохранения файла необязателен: при сбое OCR или ИИ счёт остаётся
|
||||||
|
* приложенным, а поля заполняются вручную — заявку из-за внешнего сервиса не блокируем.
|
||||||
|
*/
|
||||||
|
export function useInvoiceFlow(
|
||||||
|
context: WebPartContext,
|
||||||
|
data: IFormData,
|
||||||
|
patch: (changes: Partial<IFormData>) => void
|
||||||
|
): IInvoiceFlow {
|
||||||
|
const [stage, setStage] = useState<InvoiceStage>('idle');
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
const attach = useCallback(
|
||||||
|
async (file: File): Promise<void> => {
|
||||||
|
const invalid = validateInvoiceFile(file);
|
||||||
|
if (invalid) {
|
||||||
|
setError(invalid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
setStage('upload');
|
||||||
|
const stored = await storeInvoice(file);
|
||||||
|
patch({ InvoiceFileName: file.name, InvoiceUrl: stored.serverRelativeUrl });
|
||||||
|
|
||||||
|
setStage('ocr');
|
||||||
|
const text = await recognizeInvoice(context, stored.itemId);
|
||||||
|
|
||||||
|
setStage('analyze');
|
||||||
|
const invoice = await parseInvoiceText(context, text);
|
||||||
|
const pairs = await matchPositions(context, basisPositionsFromRows(data.Positions), invoice.items);
|
||||||
|
patch(invoicePatch(data, invoice, invoice.items, pairs));
|
||||||
|
} catch (e) {
|
||||||
|
setError(`${friendlyErrorMessage(e)} Заполните поля по счёту вручную.`);
|
||||||
|
} finally {
|
||||||
|
setStage('idle');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[context, data, patch]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { stage, error, attach };
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { IFormData } from './model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обязательные поля заявки на оплату. Возвращает текст ошибки или undefined.
|
||||||
|
* Превышение общей суммы и расхождения со счётом сюда НЕ входят: они только
|
||||||
|
* подсвечиваются, решение — за согласующими.
|
||||||
|
*/
|
||||||
|
export function validatePayment(data: IFormData): string | undefined {
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!data.RequesterEmail) missing.push('заявитель');
|
||||||
|
if (!data.BasisKey) missing.push('основание (СТ или потребность)');
|
||||||
|
if (!data.ProjectName.trim()) missing.push('проект');
|
||||||
|
if (!data.CompanyName.trim()) missing.push('компания');
|
||||||
|
if (!data.BankAccountName.trim()) missing.push('банковский счёт');
|
||||||
|
if (!data.RecipientName.trim()) missing.push('получатель');
|
||||||
|
if (!data.RecipientAccount.trim()) missing.push('счёт получателя');
|
||||||
|
if (!data.CashFlowGroupName.trim()) missing.push('группа статьи ДДС');
|
||||||
|
if (!data.CashFlowItemName.trim()) missing.push('статья ДДС');
|
||||||
|
if (!data.PaymentPurpose.trim()) missing.push('назначение платежа');
|
||||||
|
if (data.PayAmount <= 0) missing.push('сумма оплаты');
|
||||||
|
if (!data.InvoiceFileName) missing.push('счёт на оплату');
|
||||||
|
return missing.length > 0 ? `Заполните обязательные поля: ${missing.join(', ')}.` : undefined;
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
(`CompanyPickerMulti`), хранится JSON-массивом `{Id,Name}` в Note-колонке `CompaniesJson`.
|
(`CompanyPickerMulti`), хранится JSON-массивом `{Id,Name}` в Note-колонке `CompaniesJson`.
|
||||||
- **График работы (WorkSchedule)** — выпадающий список (набор совпадает с «Заявкой
|
- **График работы (WorkSchedule)** — выпадающий список (набор совпадает с «Заявкой
|
||||||
на подбор»); при выборе «Другое» появляется ручной ввод `WorkScheduleOther`.
|
на подбор»); при выборе «Другое» появляется ручной ввод `WorkScheduleOther`.
|
||||||
|
- **Время работы (WorkHours)** — так же, как график: набор и колонка сайта общие
|
||||||
|
с «Заявкой на подбор», оттуда подставляется; «Другое» → `WorkHoursOther`.
|
||||||
- **Испытательный срок (ProbationPeriod)** — выпадающий список частых сроков.
|
- **Испытательный срок (ProbationPeriod)** — выпадающий список частых сроков.
|
||||||
- **Рекрутинговое агентство (условный блок, последняя секция)** — `Toggle`
|
- **Рекрутинговое агентство (условный блок, последняя секция)** — `Toggle`
|
||||||
`RecruitingAgency`; при включении показываются «Название компании» (`AgencyName`)
|
`RecruitingAgency`; при включении показываются «Название компании» (`AgencyName`)
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
|||||||
import { draftIssues, submitIssues } from './validation';
|
import { draftIssues, submitIssues } from './validation';
|
||||||
import { saveHireRequest, hireApprovalService } from './submit';
|
import { saveHireRequest, hireApprovalService } from './submit';
|
||||||
import { loadHireRequest } from './load';
|
import { loadHireRequest } from './load';
|
||||||
|
import { isApprovedStatus } from '../../shared/approval/models/statuses';
|
||||||
|
import { loadSelectionRequest } from '../RecruitingSelection/load';
|
||||||
|
import { selectionPrefill } from './selectionPrefill';
|
||||||
import {
|
import {
|
||||||
ATT_CATEGORY_ID,
|
ATT_CATEGORY_ID,
|
||||||
ATT_CATEGORY_RESUME,
|
ATT_CATEGORY_RESUME,
|
||||||
@@ -73,6 +76,7 @@ import { PpeSection } from './sections/PpeSection';
|
|||||||
import { ConditionsSection } from './sections/ConditionsSection';
|
import { ConditionsSection } from './sections/ConditionsSection';
|
||||||
import { AgencySection } from './sections/AgencySection';
|
import { AgencySection } from './sections/AgencySection';
|
||||||
import { NoteSection } from './sections/NoteSection';
|
import { NoteSection } from './sections/NoteSection';
|
||||||
|
import { SecuritySection } from './sections/SecuritySection';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||||
@@ -82,6 +86,8 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
onSaved,
|
onSaved,
|
||||||
editId,
|
editId,
|
||||||
editListId,
|
editListId,
|
||||||
|
sourceId,
|
||||||
|
sourceListId,
|
||||||
onCodeLoaded
|
onCodeLoaded
|
||||||
}) => {
|
}) => {
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
@@ -140,6 +146,31 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
};
|
};
|
||||||
}, [editId, editListId, notifyCode, setSubmitError]);
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
|
|
||||||
|
// Новая заявка «на основе» заявки на подбор (кнопка «Оформить приём по этой
|
||||||
|
// заявке» в её просмотре): связь 1.1 + те же поля, что при выборе подбора
|
||||||
|
// вручную (selectionPrefill). Приём оформляют только по утверждённому подбору —
|
||||||
|
// как и в списке выбора поля 1.1.
|
||||||
|
useEffect(() => {
|
||||||
|
if (editId || !sourceId) return;
|
||||||
|
let alive = true;
|
||||||
|
loadSelectionRequest(sourceId, sourceListId)
|
||||||
|
.then((loaded) => {
|
||||||
|
if (!alive) return;
|
||||||
|
if (!isApprovedStatus(loaded.status)) {
|
||||||
|
setSubmitError(`Заявка на подбор №${loaded.code || sourceId} ещё не согласована — приём по ней оформить нельзя.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const link = { id: sourceId, listId: sourceListId || '', code: loaded.code };
|
||||||
|
setData((prev) => ({ ...prev, ...selectionPrefill(link, loaded.data) }));
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) setSubmitError(`Не удалось предзаполнить из заявки на подбор №${sourceId}: ${friendlyErrorMessage(e)}`);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [editId, sourceId, sourceListId, setSubmitError]);
|
||||||
|
|
||||||
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего
|
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего
|
||||||
// пользователя (кандидата ещё нет в системе). У заявки, открытой на
|
// пользователя (кандидата ещё нет в системе). У заявки, открытой на
|
||||||
// редактирование, маршрут читается из уже записанных строк — планирование
|
// редактирование, маршрут читается из уже записанных строк — планирование
|
||||||
@@ -409,6 +440,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
<PpeSection {...sectionProps} />
|
<PpeSection {...sectionProps} />
|
||||||
<ConditionsSection {...sectionProps} />
|
<ConditionsSection {...sectionProps} />
|
||||||
<AgencySection {...sectionProps} />
|
<AgencySection {...sectionProps} />
|
||||||
|
<SecuritySection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
</FieldChangesProvider>
|
</FieldChangesProvider>
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import { PpeSection } from './sections/PpeSection';
|
|||||||
import { ConditionsSection } from './sections/ConditionsSection';
|
import { ConditionsSection } from './sections/ConditionsSection';
|
||||||
import { AgencySection } from './sections/AgencySection';
|
import { AgencySection } from './sections/AgencySection';
|
||||||
import { NoteSection } from './sections/NoteSection';
|
import { NoteSection } from './sections/NoteSection';
|
||||||
|
import { SecuritySection } from './sections/SecuritySection';
|
||||||
import { ApproverNoteBanner } from '../../shared/approverNote';
|
import { ApproverNoteBanner } from '../../shared/approverNote';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
@@ -416,6 +417,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
<PpeSection {...sectionProps} />
|
<PpeSection {...sectionProps} />
|
||||||
<ConditionsSection {...sectionProps} />
|
<ConditionsSection {...sectionProps} />
|
||||||
<AgencySection {...sectionProps} />
|
<AgencySection {...sectionProps} />
|
||||||
|
<SecuritySection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
</FieldChangesProvider>
|
</FieldChangesProvider>
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [
|
|||||||
'HireCandidateName', 'HireHireDate', 'Position',
|
'HireCandidateName', 'HireHireDate', 'Position',
|
||||||
'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther',
|
'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther',
|
||||||
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
|
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
|
||||||
'AgencyName', 'AgencyTerms', 'HireApproverNote',
|
'WorkHours', 'WorkHoursOther',
|
||||||
|
'AgencyName', 'AgencyTerms', 'HireSecurityNote', 'HireApproverNote',
|
||||||
'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode'
|
'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode'
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -62,6 +63,8 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
|
|||||||
'ProjectCode',
|
'ProjectCode',
|
||||||
'RecruitingAgency',
|
'RecruitingAgency',
|
||||||
'HireOnArrival',
|
'HireOnArrival',
|
||||||
|
'HireSecurityApproved',
|
||||||
|
'PieceworkPay',
|
||||||
'HireApproverNoteImportant',
|
'HireApproverNoteImportant',
|
||||||
APPROVER_CHANGES_FIELD,
|
APPROVER_CHANGES_FIELD,
|
||||||
'RequestStatus',
|
'RequestStatus',
|
||||||
@@ -79,6 +82,8 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
|
|||||||
// Флаг агентства — булево поле (Yes/No), не строка.
|
// Флаг агентства — булево поле (Yes/No), не строка.
|
||||||
(data as unknown as Record<string, unknown>).RecruitingAgency = !!item.RecruitingAgency;
|
(data as unknown as Record<string, unknown>).RecruitingAgency = !!item.RecruitingAgency;
|
||||||
(data as unknown as Record<string, unknown>).HireOnArrival = !!item.HireOnArrival;
|
(data as unknown as Record<string, unknown>).HireOnArrival = !!item.HireOnArrival;
|
||||||
|
data.HireSecurityApproved = !!item.HireSecurityApproved;
|
||||||
|
data.PieceworkPay = !!item.PieceworkPay;
|
||||||
data.HireApproverNoteImportant = !!item.HireApproverNoteImportant;
|
data.HireApproverNoteImportant = !!item.HireApproverNoteImportant;
|
||||||
|
|
||||||
SCALAR_FIELDS.forEach((key) => {
|
SCALAR_FIELDS.forEach((key) => {
|
||||||
|
|||||||
@@ -75,10 +75,27 @@ export interface IFormData {
|
|||||||
SalaryAfterProbation: string;
|
SalaryAfterProbation: string;
|
||||||
WorkSchedule: string;
|
WorkSchedule: string;
|
||||||
WorkScheduleOther: string;
|
WorkScheduleOther: string;
|
||||||
|
/**
|
||||||
|
* Время работы — та же колонка сайта WorkHours (Choice), что в заявке на подбор:
|
||||||
|
* оттуда и подставляется (см. selectionPrefill).
|
||||||
|
*/
|
||||||
|
WorkHours: string;
|
||||||
|
WorkHoursOther: string;
|
||||||
|
/**
|
||||||
|
* Сдельная оплата труда — информационная отметка, на обязательность окладов не
|
||||||
|
* влияет. Та же колонка сайта PieceworkPay (Boolean), что в заявке на подбор:
|
||||||
|
* оттуда и подставляется (см. selectionPrefill).
|
||||||
|
*/
|
||||||
|
PieceworkPay: boolean;
|
||||||
// Рекрутинговое агентство (условный блок): при включённом флаге — название + условия.
|
// Рекрутинговое агентство (условный блок): при включённом флаге — название + условия.
|
||||||
RecruitingAgency: boolean;
|
RecruitingAgency: boolean;
|
||||||
AgencyName: string;
|
AgencyName: string;
|
||||||
AgencyTerms: string;
|
AgencyTerms: string;
|
||||||
|
// Служба безопасности: инициатор при подаче отмечает, что кандидат согласован
|
||||||
|
// с СБ; примечание при отметке — по желанию. Информационное —
|
||||||
|
// на маршрут и решения не влияет.
|
||||||
|
HireSecurityApproved: boolean;
|
||||||
|
HireSecurityNote: string;
|
||||||
// Примечание для согласующих (shared/approverNote): текст + «Важное».
|
// Примечание для согласующих (shared/approverNote): текст + «Важное».
|
||||||
// Префикс Hire — чтобы internalName не совпал с одноимёнными колонками других форм.
|
// Префикс Hire — чтобы internalName не совпал с одноимёнными колонками других форм.
|
||||||
HireApproverNote: string;
|
HireApproverNote: string;
|
||||||
@@ -121,9 +138,14 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
SalaryAfterProbation: '',
|
SalaryAfterProbation: '',
|
||||||
WorkSchedule: '',
|
WorkSchedule: '',
|
||||||
WorkScheduleOther: '',
|
WorkScheduleOther: '',
|
||||||
|
WorkHours: '',
|
||||||
|
WorkHoursOther: '',
|
||||||
|
PieceworkPay: false,
|
||||||
RecruitingAgency: false,
|
RecruitingAgency: false,
|
||||||
AgencyName: '',
|
AgencyName: '',
|
||||||
AgencyTerms: '',
|
AgencyTerms: '',
|
||||||
|
HireSecurityApproved: false,
|
||||||
|
HireSecurityNote: '',
|
||||||
HireApproverNote: '',
|
HireApproverNote: '',
|
||||||
HireApproverNoteImportant: false
|
HireApproverNoteImportant: false
|
||||||
};
|
};
|
||||||
@@ -187,5 +209,19 @@ export const WORK_SCHEDULE_OPTIONS: ISelectOption[] = [
|
|||||||
{ value: OTHER_VALUE, label: OTHER_VALUE }
|
{ value: OTHER_VALUE, label: OTHER_VALUE }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Варианты «Время работы» (24-часовой формат) — совпадают с набором в форме
|
||||||
|
* «Заявка на подбор» (WORK_HOURS_OPTIONS) и с choices колонки WorkHours:
|
||||||
|
* формы независимы, значения продублированы намеренно.
|
||||||
|
*/
|
||||||
|
export const WORK_HOURS_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: '08:00-17:00', label: '08:00-17:00' },
|
||||||
|
{ value: '09:00-18:00', label: '09:00-18:00' },
|
||||||
|
{ value: '07:00-19:00', label: '07:00-19:00' },
|
||||||
|
{ value: '08:00-20:00', label: '08:00-20:00' },
|
||||||
|
{ value: '09:00-21:00', label: '09:00-21:00' },
|
||||||
|
{ value: OTHER_VALUE, label: OTHER_VALUE }
|
||||||
|
];
|
||||||
|
|
||||||
/** Плейсхолдер для полей-людей. */
|
/** Плейсхолдер для полей-людей. */
|
||||||
export const PERSON_PLACEHOLDER = 'Начните вводить имя сотрудника…';
|
export const PERSON_PLACEHOLDER = 'Начните вводить имя сотрудника…';
|
||||||
|
|||||||
@@ -41,11 +41,17 @@
|
|||||||
{ "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 },
|
{ "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 },
|
||||||
{ "internalName": "WorkSchedule", "title": "График работы", "type": "Text" },
|
{ "internalName": "WorkSchedule", "title": "График работы", "type": "Text" },
|
||||||
{ "internalName": "WorkScheduleOther", "title": "График работы (другое)", "type": "Text" },
|
{ "internalName": "WorkScheduleOther", "title": "График работы (другое)", "type": "Text" },
|
||||||
|
{ "internalName": "WorkHours", "title": "Время работы", "type": "Choice", "choices": ["08:00-17:00", "09:00-18:00", "07:00-19:00", "08:00-20:00", "09:00-21:00", "Другое"] },
|
||||||
|
{ "internalName": "WorkHoursOther", "title": "Время работы (другое)", "type": "Text" },
|
||||||
|
{ "internalName": "PieceworkPay", "title": "Сдельная оплата труда", "type": "Boolean" },
|
||||||
|
|
||||||
{ "internalName": "RecruitingAgency", "title": "Рекрутинговое агентство", "type": "Boolean" },
|
{ "internalName": "RecruitingAgency", "title": "Рекрутинговое агентство", "type": "Boolean" },
|
||||||
{ "internalName": "AgencyName", "title": "Название компании (агентство)", "type": "Text" },
|
{ "internalName": "AgencyName", "title": "Название компании (агентство)", "type": "Text" },
|
||||||
{ "internalName": "AgencyTerms", "title": "Условия (агентство)", "type": "Note" },
|
{ "internalName": "AgencyTerms", "title": "Условия (агентство)", "type": "Note" },
|
||||||
|
|
||||||
|
{ "internalName": "HireSecurityApproved", "title": "Согласован СБ", "type": "Boolean" },
|
||||||
|
{ "internalName": "HireSecurityNote", "title": "Примечание СБ", "type": "Note" },
|
||||||
|
|
||||||
{ "internalName": "HireApproverNote", "title": "Примечание для согласующих", "type": "Note" },
|
{ "internalName": "HireApproverNote", "title": "Примечание для согласующих", "type": "Note" },
|
||||||
{ "internalName": "HireApproverNoteImportant", "title": "Примечание — важное", "type": "Boolean" }
|
{ "internalName": "HireApproverNoteImportant", "title": "Примечание — важное", "type": "Boolean" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, MoneyInput, Select } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, MoneyInput, Select, Toggle } from '../../../shared/ui';
|
||||||
import { ISectionProps, OTHER_VALUE, WORK_SCHEDULE_OPTIONS, PROBATION_PERIOD_OPTIONS } from '../model';
|
import { ISectionProps, OTHER_VALUE, WORK_SCHEDULE_OPTIONS, WORK_HOURS_OPTIONS, PROBATION_PERIOD_OPTIONS } from '../model';
|
||||||
|
|
||||||
interface IConditionsSectionProps extends ISectionProps {
|
interface IConditionsSectionProps extends ISectionProps {
|
||||||
/** Начальник расчётной группы правит оклады даже в режиме просмотра (см. approverRights.ts). */
|
/** Начальник расчётной группы правит оклады даже в режиме просмотра (см. approverRights.ts). */
|
||||||
@@ -67,5 +67,30 @@ export const ConditionsSection: React.FC<IConditionsSectionProps> = ({ data, set
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
<FormField label="4.5 Время работы" htmlFor="WorkHours" required>
|
||||||
|
<Select
|
||||||
|
id="WorkHours"
|
||||||
|
value={data.WorkHours}
|
||||||
|
onChange={(v) => {
|
||||||
|
set('WorkHours', v);
|
||||||
|
if (v !== OTHER_VALUE) set('WorkHoursOther', '');
|
||||||
|
}}
|
||||||
|
options={WORK_HOURS_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{data.WorkHours === OTHER_VALUE && (
|
||||||
|
<FormField label="Время работы (другое)" htmlFor="WorkHoursOther">
|
||||||
|
<TextInput
|
||||||
|
id="WorkHoursOther"
|
||||||
|
value={data.WorkHoursOther}
|
||||||
|
onChange={(v) => set('WorkHoursOther', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
{/* Просто отметка: обязательность окладов (4.2, 4.3) от неё не зависит. */}
|
||||||
|
<FormField label="4.6 Сдельная оплата труда" fieldKey="PieceworkPay">
|
||||||
|
<Toggle checked={data.PieceworkPay} onChange={(v) => set('PieceworkPay', v)} onLabel="Да" />
|
||||||
|
</FormField>
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ const NOTE_FIELD_KEYS: IApproverNoteFieldKeys = {
|
|||||||
important: 'HireApproverNoteImportant'
|
important: 'HireApproverNoteImportant'
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 6. Примечание для согласующих — общий раздел shared/approverNote на полях приёма. */
|
/** 7. Примечание для согласующих — общий раздел shared/approverNote на полях приёма. */
|
||||||
export const NoteSection: React.FC<ISectionProps> = ({ data, set }) => (
|
export const NoteSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||||
<ApproverNoteSection
|
<ApproverNoteSection
|
||||||
title="6. Примечание для согласующих"
|
title="7. Примечание для согласующих"
|
||||||
value={{ text: data.HireApproverNote, important: data.HireApproverNoteImportant }}
|
value={{ text: data.HireApproverNote, important: data.HireApproverNoteImportant }}
|
||||||
onChange={(note) => {
|
onChange={(note) => {
|
||||||
set('HireApproverNote', note.text);
|
set('HireApproverNote', note.text);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { CollapseSection, FormField, TextArea, Toggle } from '../../../shared/ui';
|
||||||
|
import { ISectionProps } from '../model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 6. Служба безопасности: инициатор отмечает, что кандидат согласован с СБ, и
|
||||||
|
* при желании пишет примечание (необязательно). Поле информационное —
|
||||||
|
* на маршрут и решения согласующих не влияет. При снятии отметки примечание
|
||||||
|
* очищается, чтобы в заявку не ушёл текст скрытого поля (как в AgencySection).
|
||||||
|
*/
|
||||||
|
export const SecuritySection: React.FC<ISectionProps> = ({ data, set }) => {
|
||||||
|
const handleToggle = (value: boolean): void => {
|
||||||
|
set('HireSecurityApproved', value);
|
||||||
|
if (!value) set('HireSecurityNote', '');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapseSection title="6. Служба безопасности" icon="user-check">
|
||||||
|
<FormField label="6.1 Согласован СБ" fieldKey="HireSecurityApproved">
|
||||||
|
<Toggle checked={data.HireSecurityApproved} onChange={handleToggle} onLabel="Да" />
|
||||||
|
</FormField>
|
||||||
|
{data.HireSecurityApproved && (
|
||||||
|
<FormField label="6.2 Примечание СБ" htmlFor="HireSecurityNote" full>
|
||||||
|
<TextArea
|
||||||
|
id="HireSecurityNote"
|
||||||
|
value={data.HireSecurityNote}
|
||||||
|
onChange={(v) => set('HireSecurityNote', v)}
|
||||||
|
placeholder="Кто и когда согласовал, замечания СБ"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
+11
-33
@@ -5,7 +5,14 @@ import { routeUrl } from '../../../shared/routing';
|
|||||||
import { getApprovedSelectionRequests, ISelectionRequestOption } from '../selectionLookup';
|
import { getApprovedSelectionRequests, ISelectionRequestOption } from '../selectionLookup';
|
||||||
import { loadSelectionRequest } from '../../RecruitingSelection/load';
|
import { loadSelectionRequest } from '../../RecruitingSelection/load';
|
||||||
import { IFormData, SetField } from '../model';
|
import { IFormData, SetField } from '../model';
|
||||||
import { hireTypeFromSelection } from '../hireTypeFromSelection';
|
import { selectionPrefill } from '../selectionPrefill';
|
||||||
|
|
||||||
|
/** Записать набор полей через SetField формы — по одному ключу. */
|
||||||
|
function applyPatch(set: SetField, patch: Partial<IFormData>): void {
|
||||||
|
(Object.keys(patch) as (keyof IFormData)[]).forEach((key) => {
|
||||||
|
set(key, patch[key] as IFormData[typeof key]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Слаг формы подбора в реестре форм — цель ссылки (rptype). */
|
/** Слаг формы подбора в реестре форм — цель ссылки (rptype). */
|
||||||
const SELECTION_FORM_KEY = 'recruiting-selection';
|
const SELECTION_FORM_KEY = 'recruiting-selection';
|
||||||
@@ -91,40 +98,11 @@ export const SelectionRequestField: React.FC<ISelectionRequestFieldProps> = ({ d
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
setQuery('');
|
setQuery('');
|
||||||
|
|
||||||
// Максимум полей, общих с заявкой на подбор, подтягиваем из неё — перезаписываем
|
// Максимум полей, общих с заявкой на подбор, подтягиваем из неё (см.
|
||||||
// даже уже заполненные вручную (пользователь осознанно выбрал/сменил связь, см.
|
// selectionPrefill — та же логика у «Оформить приём» из просмотра подбора).
|
||||||
// обсуждение с Тимуром: перезапись — ожидаемое поведение при смене связанной заявки).
|
|
||||||
loadSelectionRequest(o.id, o.listId || undefined)
|
loadSelectionRequest(o.id, o.listId || undefined)
|
||||||
.then(({ data: selection }) => {
|
.then(({ data: selection }) => {
|
||||||
set('Companies', selection.Companies);
|
applyPatch(set, selectionPrefill({ id: o.id, listId: o.listId, code: o.code }, selection));
|
||||||
set('MainCompanyId', ''); // компании заменены целиком — зависимые поля пересчитывать не от чего
|
|
||||||
set('CompanyShares', []);
|
|
||||||
// В подборе проект один — он становится единственным выбранным проектом приёма.
|
|
||||||
set(
|
|
||||||
'Projects',
|
|
||||||
selection.ObjectProjectId
|
|
||||||
? [{ Id: selection.ObjectProjectId, Name: selection.ObjectProject, Code: selection.ObjectProjectCode }]
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
set('Position', selection.Position);
|
|
||||||
set('Department', selection.Department);
|
|
||||||
set('WorkSchedule', selection.WorkSchedule);
|
|
||||||
set('WorkScheduleOther', selection.WorkScheduleOther);
|
|
||||||
set('ProbationPeriod', selection.SelectionProbationPeriod);
|
|
||||||
set('ProbationPeriodOther', selection.SelectionProbationPeriodOther);
|
|
||||||
set('SalaryProbation', selection.RateProbation);
|
|
||||||
set('SalaryAfterProbation', selection.RateAfterProbation);
|
|
||||||
// Тип приёма однозначен, только если в подборе ОДНА причина; ФИО «взамен
|
|
||||||
// кого» — только если под ней ОДИН сотрудник. Иначе инициатор выбирает сам.
|
|
||||||
const reasons = selection.VacancyReasons;
|
|
||||||
const single = reasons.length === 1 ? reasons[0] : undefined;
|
|
||||||
const hireTypePrefill = single
|
|
||||||
? hireTypeFromSelection(single.reason, single.employees.length === 1 ? single.employees[0] : '')
|
|
||||||
: undefined;
|
|
||||||
if (hireTypePrefill) {
|
|
||||||
set('HireType', hireTypePrefill.hireType);
|
|
||||||
set('HireReplacedEmployee', hireTypePrefill.replacedEmployee);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(() => undefined); // связь уже сохранена по id/коду — сбой обогащения не критичен
|
.catch(() => undefined); // связь уже сохранена по id/коду — сбой обогащения не критичен
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { IFormData as ISelectionData } from '../RecruitingSelection/model';
|
||||||
|
import { hireTypeFromSelection } from './hireTypeFromSelection';
|
||||||
|
import { IFormData } from './model';
|
||||||
|
|
||||||
|
/** Какая заявка на подбор связывается с приёмом (см. SelectionRequestField). */
|
||||||
|
export interface ISelectionLink {
|
||||||
|
id: number;
|
||||||
|
/** Url партиции подбора; '' — неизвестна. */
|
||||||
|
listId: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поля приёма, которые берутся из связанной заявки на подбор, + сама связь.
|
||||||
|
* Одна функция на оба пути: выбор подбора в поле 1.1 (SelectionRequestField) и
|
||||||
|
* «+ Оформить приём по этой заявке» из просмотра подбора — чтобы они не
|
||||||
|
* разошлись. Уже заполненное вручную перезаписывается: пользователь осознанно
|
||||||
|
* выбрал/сменил связь.
|
||||||
|
*/
|
||||||
|
export function selectionPrefill(link: ISelectionLink, selection: ISelectionData): Partial<IFormData> {
|
||||||
|
const patch: Partial<IFormData> = {
|
||||||
|
SelectionRequestId: String(link.id),
|
||||||
|
SelectionRequestListId: link.listId,
|
||||||
|
SelectionRequestCode: link.code,
|
||||||
|
Companies: selection.Companies,
|
||||||
|
// Компании заменены целиком — зависимые поля пересчитывать не от чего.
|
||||||
|
MainCompanyId: '',
|
||||||
|
CompanyShares: [],
|
||||||
|
// В подборе проект один — он становится единственным выбранным проектом приёма.
|
||||||
|
Projects: selection.ObjectProjectId
|
||||||
|
? [{ Id: selection.ObjectProjectId, Name: selection.ObjectProject, Code: selection.ObjectProjectCode }]
|
||||||
|
: [],
|
||||||
|
Position: selection.Position,
|
||||||
|
Department: selection.Department,
|
||||||
|
WorkSchedule: selection.WorkSchedule,
|
||||||
|
WorkScheduleOther: selection.WorkScheduleOther,
|
||||||
|
WorkHours: selection.WorkHours,
|
||||||
|
WorkHoursOther: selection.WorkHoursOther,
|
||||||
|
ProbationPeriod: selection.SelectionProbationPeriod,
|
||||||
|
ProbationPeriodOther: selection.SelectionProbationPeriodOther,
|
||||||
|
SalaryProbation: selection.RateProbation,
|
||||||
|
SalaryAfterProbation: selection.RateAfterProbation,
|
||||||
|
PieceworkPay: selection.PieceworkPay,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Тип приёма однозначен, только если в подборе ОДНА причина; ФИО «взамен
|
||||||
|
// кого» — только если под ней ОДИН сотрудник. Иначе инициатор выбирает сам.
|
||||||
|
const reasons = selection.VacancyReasons;
|
||||||
|
const single = reasons.length === 1 ? reasons[0] : undefined;
|
||||||
|
const hireType = single
|
||||||
|
? hireTypeFromSelection(single.reason, single.employees.length === 1 ? single.employees[0] : '')
|
||||||
|
: undefined;
|
||||||
|
if (hireType) {
|
||||||
|
patch.HireType = hireType.hireType;
|
||||||
|
patch.HireReplacedEmployee = hireType.replacedEmployee;
|
||||||
|
}
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
@@ -25,7 +25,8 @@ describe('RecruitingHire validation', () => {
|
|||||||
'4.1 Испытательный срок',
|
'4.1 Испытательный срок',
|
||||||
'4.2 Оклад при испытательном сроке',
|
'4.2 Оклад при испытательном сроке',
|
||||||
'4.3 Оклад по завершении ИС',
|
'4.3 Оклад по завершении ИС',
|
||||||
'4.4 График работы'
|
'4.4 График работы',
|
||||||
|
'4.5 Время работы'
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,6 +39,11 @@ describe('RecruitingHire validation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('примечание СБ необязательно, даже при отметке «Согласован СБ»', () => {
|
||||||
|
expect(labels(submitIssues({ ...DRAFT_READY, HireSecurityApproved: true }))).not.toContain('6.2 Примечание СБ');
|
||||||
|
expect(draftIssues({ ...DRAFT_READY, HireSecurityApproved: true })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('«По приезду на объект» снимает обязательность даты приёма', () => {
|
it('«По приезду на объект» снимает обязательность даты приёма', () => {
|
||||||
expect(labels(submitIssues({ ...DRAFT_READY, HireOnArrival: true }))).not.toContain('2.2 Дата приёма');
|
expect(labels(submitIssues({ ...DRAFT_READY, HireOnArrival: true }))).not.toContain('2.2 Дата приёма');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface IRequiredRule extends IFieldIssue {
|
|||||||
* агентство» не проверяется — у булевых нет пустого состояния.
|
* агентство» не проверяется — у булевых нет пустого состояния.
|
||||||
* 2.2 «Дата приёма» не нужна, если отмечено «По приезду на объект»
|
* 2.2 «Дата приёма» не нужна, если отмечено «По приезду на объект»
|
||||||
* (HireOnArrival) — тогда дату проставляет исполнитель на своём шаге.
|
* (HireOnArrival) — тогда дату проставляет исполнитель на своём шаге.
|
||||||
|
* 6.2 «Примечание СБ» необязательно (по просьбе заказчика) — даже при отметке 6.1.
|
||||||
*
|
*
|
||||||
* 1.3 «Проект» — хотя бы один проект из справочника: первый уходит в resolve
|
* 1.3 «Проект» — хотя бы один проект из справочника: первый уходит в resolve
|
||||||
* маршрута (projectId), а без него approval-route отвечал 404 и заявка ушла бы
|
* маршрута (projectId), а без него approval-route отвечал 404 и заявка ушла бы
|
||||||
@@ -48,7 +49,8 @@ const REQUIRED_RULES: ReadonlyArray<IRequiredRule> = [
|
|||||||
{ field: 'ProbationPeriod', label: '4.1 Испытательный срок', ok: (d) => !!d.ProbationPeriod },
|
{ field: 'ProbationPeriod', label: '4.1 Испытательный срок', ok: (d) => !!d.ProbationPeriod },
|
||||||
{ field: 'SalaryProbation', label: '4.2 Оклад при испытательном сроке', ok: (d) => !!d.SalaryProbation.trim() },
|
{ field: 'SalaryProbation', label: '4.2 Оклад при испытательном сроке', ok: (d) => !!d.SalaryProbation.trim() },
|
||||||
{ field: 'SalaryAfterProbation', label: '4.3 Оклад по завершении ИС', ok: (d) => !!d.SalaryAfterProbation.trim() },
|
{ field: 'SalaryAfterProbation', label: '4.3 Оклад по завершении ИС', ok: (d) => !!d.SalaryAfterProbation.trim() },
|
||||||
{ field: 'WorkSchedule', label: '4.4 График работы', ok: (d) => !!d.WorkSchedule }
|
{ field: 'WorkSchedule', label: '4.4 График работы', ok: (d) => !!d.WorkSchedule },
|
||||||
|
{ field: 'WorkHours', label: '4.5 Время работы', ok: (d) => !!d.WorkHours }
|
||||||
];
|
];
|
||||||
|
|
||||||
function toIssue({ field, label, message }: IRequiredRule): IFieldIssue {
|
function toIssue({ field, label, message }: IRequiredRule): IFieldIssue {
|
||||||
|
|||||||
@@ -27,4 +27,7 @@ ApplicationSection · DepartmentSection · VacancySection · HealthTripsSection
|
|||||||
## Точки входа
|
## Точки входа
|
||||||
- rptype: `recruiting-selection`
|
- rptype: `recruiting-selection`
|
||||||
- Режим согласующего (просмотр): есть (`RecruitingSelectionView`)
|
- Режим согласующего (просмотр): есть (`RecruitingSelectionView`)
|
||||||
- «Создать на основе»: —
|
- «Создать на основе»: из согласованного подбора — «+ Оформить приём по этой заявке»
|
||||||
|
(`sections/StartHireBar`, под вложениями) → `recruiting-hire` с `rpsource`=Id подбора и
|
||||||
|
`rplist`=его партиция; форма приёма заполняет поля через `RecruitingHire/selectionPrefill.ts`
|
||||||
|
(та же логика, что при выборе подбора в поле 1.1)
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ import { VacancySection } from './sections/VacancySection';
|
|||||||
import { HealthTripsSection } from './sections/HealthTripsSection';
|
import { HealthTripsSection } from './sections/HealthTripsSection';
|
||||||
import { ConditionsSection } from './sections/ConditionsSection';
|
import { ConditionsSection } from './sections/ConditionsSection';
|
||||||
import { HiresProgressSection } from './sections/HiresProgressSection';
|
import { HiresProgressSection } from './sections/HiresProgressSection';
|
||||||
|
import { StartHireBar } from './sections/StartHireBar';
|
||||||
|
import { useLinkedHires } from './useLinkedHires';
|
||||||
|
import { FORM_ID as HIRE_FORM_ID } from '../RecruitingHire/model';
|
||||||
|
import { navigate } from '../../shared/routing';
|
||||||
|
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
|
||||||
import { parseVacanciesCount } from './vacancyReasons';
|
import { parseVacanciesCount } from './vacancyReasons';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
@@ -289,6 +294,22 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
handleStepAdded,
|
handleStepAdded,
|
||||||
setRouteError
|
setRouteError
|
||||||
);
|
);
|
||||||
|
// Приёмы по этому подбору — один запрос на оба блока: «Приём по заявке» и
|
||||||
|
// «Оформить приём» под вложениями. Только для согласованного подбора.
|
||||||
|
const linkedHires = useLinkedHires(itemId, listId || '', isApprovedStatus(status));
|
||||||
|
|
||||||
|
// Новая заявка на приём, связанная с этим подбором и заполненная из него (см.
|
||||||
|
// RecruitingHireForm/selectionPrefill). Несохранённые правки в другой новой
|
||||||
|
// заявке потерялись бы — спрашиваем, как при «рабочем месте на основе приёма».
|
||||||
|
const handleStartHire = (): void => {
|
||||||
|
confirmDiscardFormChanges()
|
||||||
|
.then((ok) => {
|
||||||
|
if (!ok) return;
|
||||||
|
navigate({ mode: 'create', nav: 'new', formKey: HIRE_FORM_ID, sourceId: itemId, listId });
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
// По статусу заявки: согласующих — пока идёт согласование, исполнителей —
|
// По статусу заявки: согласующих — пока идёт согласование, исполнителей —
|
||||||
// пока не завершено исполнение. Сервер перепроверит по строкам (routeStepAdd.ts).
|
// пока не завершено исполнение. Сервер перепроверит по строкам (routeStepAdd.ts).
|
||||||
const canAddApprover = canRemoveApprover && status === REQUEST_STATUS.onApproval;
|
const canAddApprover = canRemoveApprover && status === REQUEST_STATUS.onApproval;
|
||||||
@@ -374,11 +395,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
<DepartmentSection {...sectionProps} />
|
<DepartmentSection {...sectionProps} />
|
||||||
{/* Приём оформляют только по утверждённому подбору — до этого блоку показывать нечего. */}
|
{/* Приём оформляют только по утверждённому подбору — до этого блоку показывать нечего. */}
|
||||||
{isApprovedStatus(status) && (
|
{isApprovedStatus(status) && (
|
||||||
<HiresProgressSection
|
<HiresProgressSection linked={linkedHires} vacancies={parseVacanciesCount(data.OpenVacanciesCount)} />
|
||||||
selectionId={itemId}
|
|
||||||
selectionListId={listId || ''}
|
|
||||||
vacancies={parseVacanciesCount(data.OpenVacanciesCount)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<VacancySection {...sectionProps} />
|
<VacancySection {...sectionProps} />
|
||||||
<HealthTripsSection {...sectionProps} />
|
<HealthTripsSection {...sectionProps} />
|
||||||
@@ -389,6 +406,15 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
<EditableAttachments editor={attachments} editable={fullEditable} />
|
<EditableAttachments editor={attachments} editable={fullEditable} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* После вложений, перед маршрутом: приём оформляют, уже просмотрев подбор целиком. */}
|
||||||
|
{isApprovedStatus(status) && (
|
||||||
|
<StartHireBar
|
||||||
|
linked={linkedHires}
|
||||||
|
vacancies={parseVacanciesCount(data.OpenVacanciesCount)}
|
||||||
|
onStart={handleStartHire}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</FormCard>
|
</FormCard>
|
||||||
|
|
||||||
{routeError && <Alert variant="err">{routeError}</Alert>}
|
{routeError && <Alert variant="err">{routeError}</Alert>}
|
||||||
|
|||||||
+7
-41
@@ -3,14 +3,13 @@ import styles from './HiresProgressSection.module.scss';
|
|||||||
import { Alert, CollapseSection, FormField } from '../../../shared/ui';
|
import { Alert, CollapseSection, FormField } from '../../../shared/ui';
|
||||||
import { StatusBadge } from '../../../shared/approval';
|
import { StatusBadge } from '../../../shared/approval';
|
||||||
import { routeUrl } from '../../../shared/routing';
|
import { routeUrl } from '../../../shared/routing';
|
||||||
import { friendlyErrorMessage } from '../../../shared/diagnostics';
|
|
||||||
import { FORM_ID as HIRE_FORM_ID } from '../../RecruitingHire/model';
|
import { FORM_ID as HIRE_FORM_ID } from '../../RecruitingHire/model';
|
||||||
import { getLinkedHires, ILinkedHire, isOccupyingHireStatus } from '../../RecruitingHire/linkedHires';
|
import { ILinkedHire } from '../../RecruitingHire/linkedHires';
|
||||||
|
import { ILinkedHiresState, occupiedLabel } from '../useLinkedHires';
|
||||||
|
|
||||||
interface IHiresProgressSectionProps {
|
interface IHiresProgressSectionProps {
|
||||||
selectionId: number;
|
/** Заявки на приём по этому подбору (см. useLinkedHires — грузятся один раз на просмотр). */
|
||||||
/** Партиция, где лежит заявка на подбор ('' — неизвестна). */
|
linked: ILinkedHiresState;
|
||||||
selectionListId: string;
|
|
||||||
/** Количество открытых вакансий из 2.2. 0 — не указано. */
|
/** Количество открытых вакансий из 2.2. 0 — не указано. */
|
||||||
vacancies: number;
|
vacancies: number;
|
||||||
}
|
}
|
||||||
@@ -20,14 +19,9 @@ const TEXT = {
|
|||||||
occupied: 'Занято вакансий',
|
occupied: 'Занято вакансий',
|
||||||
hires: 'Заявки на приём',
|
hires: 'Заявки на приём',
|
||||||
loading: 'Загрузка…',
|
loading: 'Загрузка…',
|
||||||
empty: 'Заявок на приём по этому подбору пока нет.',
|
empty: 'Заявок на приём по этому подбору пока нет.'
|
||||||
loadError: 'Не удалось загрузить заявки на приём'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function occupiedLabel(occupied: number, vacancies: number): string {
|
|
||||||
return vacancies > 0 ? `${occupied} из ${vacancies}` : String(occupied);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hireHref(hire: ILinkedHire): string {
|
function hireHref(hire: ILinkedHire): string {
|
||||||
return routeUrl({
|
return routeUrl({
|
||||||
mode: 'approve',
|
mode: 'approve',
|
||||||
@@ -44,36 +38,8 @@ function hireHref(hire: ILinkedHire): string {
|
|||||||
* Правило «приём занимает вакансию» — то же, что и в выборе подбора в форме
|
* Правило «приём занимает вакансию» — то же, что и в выборе подбора в форме
|
||||||
* приёма (см. RecruitingHire/linkedHires.ts).
|
* приёма (см. RecruitingHire/linkedHires.ts).
|
||||||
*/
|
*/
|
||||||
export const HiresProgressSection: React.FC<IHiresProgressSectionProps> = ({
|
export const HiresProgressSection: React.FC<IHiresProgressSectionProps> = ({ linked, vacancies }) => {
|
||||||
selectionId,
|
const { hires, occupied, isLoading, error } = linked;
|
||||||
selectionListId,
|
|
||||||
vacancies
|
|
||||||
}) => {
|
|
||||||
const [hires, setHires] = React.useState<ILinkedHire[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
|
||||||
const [error, setError] = React.useState<string>('');
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
let alive = true;
|
|
||||||
setIsLoading(true);
|
|
||||||
getLinkedHires(selectionId, selectionListId)
|
|
||||||
.then((rows) => {
|
|
||||||
if (!alive) return;
|
|
||||||
setHires(rows);
|
|
||||||
setError('');
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
if (alive) setError(`${TEXT.loadError}: ${friendlyErrorMessage(e)}`);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (alive) setIsLoading(false);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, [selectionId, selectionListId]);
|
|
||||||
|
|
||||||
const occupied = hires.filter((h) => isOccupyingHireStatus(h.status)).length;
|
|
||||||
|
|
||||||
const renderHires = (): React.ReactNode => {
|
const renderHires = (): React.ReactNode => {
|
||||||
if (isLoading) return <span className={styles.note}>{TEXT.loading}</span>;
|
if (isLoading) return <span className={styles.note}>{TEXT.loading}</span>;
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-2;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-top: $sp-3;
|
||||||
|
padding: $sp-3 $sp-4;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $navy-50;
|
||||||
|
font-family: $font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $sp-2;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $amber-700;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './StartHireBar.module.scss';
|
||||||
|
import { Button, Icon } from '../../../shared/ui';
|
||||||
|
import { ILinkedHiresState, allVacanciesOccupied, occupiedLabel } from '../useLinkedHires';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
linked: ILinkedHiresState;
|
||||||
|
/** Количество открытых вакансий из 2.2. 0 — не указано. */
|
||||||
|
vacancies: number;
|
||||||
|
onStart: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT = {
|
||||||
|
title: 'Приём по этой заявке',
|
||||||
|
occupied: 'занято',
|
||||||
|
button: 'Оформить приём по этой заявке',
|
||||||
|
allOccupied: 'Все вакансии по этой заявке уже заняты — оформляйте, только если это замена или дополнительный приём.'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «+ Оформить приём по этой заявке» под вложениями согласованного подбора:
|
||||||
|
* открывает новую заявку на приём, уже связанную с этим подбором и заполненную
|
||||||
|
* из него. Когда вакансии заняты, кнопка не прячется (бывают замены), но рядом
|
||||||
|
* предупреждение. На печать не выводится.
|
||||||
|
*/
|
||||||
|
export const StartHireBar: React.FC<Props> = ({ linked, vacancies, onStart }) => {
|
||||||
|
const { occupied, isLoading, error } = linked;
|
||||||
|
const known = !isLoading && !error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.bar} rp-no-print`}>
|
||||||
|
<div className={styles.head}>
|
||||||
|
<span className={styles.title}>{TEXT.title}</span>
|
||||||
|
{known && (
|
||||||
|
<span className={styles.count}>
|
||||||
|
{TEXT.occupied} {occupiedLabel(occupied, vacancies)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button variant="secondary" onClick={onStart}>
|
||||||
|
<Icon name="plus" size="sm" />
|
||||||
|
{TEXT.button}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{known && allVacanciesOccupied(occupied, vacancies) && (
|
||||||
|
<div className={styles.warning} role="note">
|
||||||
|
<Icon name="warning" size="sm" />
|
||||||
|
<span>{TEXT.allOccupied}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
|
import { getLinkedHires, ILinkedHire, isOccupyingHireStatus } from '../RecruitingHire/linkedHires';
|
||||||
|
|
||||||
|
export interface ILinkedHiresState {
|
||||||
|
hires: ILinkedHire[];
|
||||||
|
/** Сколько приёмов занимают вакансию (то же правило, что в выборе подбора в форме приёма). */
|
||||||
|
occupied: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Текст ошибки загрузки; '' — без ошибки. */
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOAD_ERROR = 'Не удалось загрузить заявки на приём';
|
||||||
|
|
||||||
|
/** «2 из 6»; без числа вакансий в подборе — просто «2». */
|
||||||
|
export function occupiedLabel(occupied: number, vacancies: number): string {
|
||||||
|
return vacancies > 0 ? `${occupied} из ${vacancies}` : String(occupied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Все вакансии заняты — новый приём нужен разве что как замена или дополнительный. */
|
||||||
|
export function allVacanciesOccupied(occupied: number, vacancies: number): boolean {
|
||||||
|
return vacancies > 0 && occupied >= vacancies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заявки на приём по этому подбору — ОДИН запрос на просмотр подбора: их
|
||||||
|
* показывают и блок «Приём по заявке», и строка «Оформить приём» под вложениями.
|
||||||
|
* enabled=false (подбор ещё не согласован) — ничего не грузим.
|
||||||
|
*/
|
||||||
|
export function useLinkedHires(selectionId: number, selectionListId: string, enabled: boolean): ILinkedHiresState {
|
||||||
|
const [hires, setHires] = React.useState<ILinkedHire[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = React.useState<boolean>(enabled);
|
||||||
|
const [error, setError] = React.useState<string>('');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
let alive = true;
|
||||||
|
setIsLoading(true);
|
||||||
|
getLinkedHires(selectionId, selectionListId)
|
||||||
|
.then((rows) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setHires(rows);
|
||||||
|
setError('');
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) setError(`${LOAD_ERROR}: ${friendlyErrorMessage(e)}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (alive) setIsLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [selectionId, selectionListId, enabled]);
|
||||||
|
|
||||||
|
return { hires, occupied: hires.filter((h) => isOccupyingHireStatus(h.status)).length, isLoading, error };
|
||||||
|
}
|
||||||
@@ -12,11 +12,13 @@
|
|||||||
Если для вида работ согласование не требуется — заявка сразу «Выполнение».
|
Если для вида работ согласование не требуется — заявка сразу «Выполнение».
|
||||||
Работу отдел 1С ведёт в карточке Microsoft Planner (см. «Особое поведение»).
|
Работу отдел 1С ведёт в карточке Microsoft Planner (см. «Особое поведение»).
|
||||||
- Особенности: маршрут условный — набор шагов зависит от полей **«Виды работ»**
|
- Особенности: маршрут условный — набор шагов зависит от полей **«Виды работ»**
|
||||||
(`WorkType`) и **«Отдел»** (`Department`, свободный текст из профиля AAD, см.
|
(`WorkType`), **«База 1С»** (`DB1C`) и **«Отдел»** (`Department`, свободный текст
|
||||||
«Особое поведение»), которые передаются в `resolveRoute`/`getPlannedMatrix` через
|
из профиля AAD, см. «Особое поведение»), которые передаются в
|
||||||
параметр `fieldValues: [{ fieldName: 'WorkType', value: data.WorkType },
|
`resolveRoute`/`getPlannedMatrix` через параметр `fieldValues` (см.
|
||||||
{ fieldName: 'Department', value: data.Department }]` (см. `IResolveFieldValue` в
|
`IResolveFieldValue` в `shared/approval/models/IApprovalRouteApi.ts`). «База 1С» —
|
||||||
`shared/approval/models/IApprovalRouteApi.ts`). В админке маршрутов условие по
|
мультивыбор, а `value` у API только строка, поэтому все выбранные базы уходят
|
||||||
|
одной записью через пробел; условия по ней в админке — только «содержит»
|
||||||
|
(напр. «База 1С содержит ЗУП»). В админке маршрутов условие по
|
||||||
отделу настроено оператором «содержит» (напр. «Department содержит Бухгалтерия»),
|
отделу настроено оператором «содержит» (напр. «Department содержит Бухгалтерия»),
|
||||||
поэтому важно фактическое значение поля, а не Id из справочника — отдельного
|
поэтому важно фактическое значение поля, а не Id из справочника — отдельного
|
||||||
DepartmentId у этой формы нет. Для части видов работ маршрут пуст — согласование
|
DepartmentId у этой формы нет. Для части видов работ маршрут пуст — согласование
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
|
|||||||
import styles from './Ticket1CForm.module.scss';
|
import styles from './Ticket1CForm.module.scss';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
FieldIssuesProvider,
|
||||||
|
issueLabels,
|
||||||
FormCard,
|
FormCard,
|
||||||
InitiatorLine,
|
InitiatorLine,
|
||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
@@ -21,7 +23,6 @@ import {
|
|||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
|
||||||
loadRoutePhase,
|
loadRoutePhase,
|
||||||
isOpenedRouteReady,
|
isOpenedRouteReady,
|
||||||
resolveWriteListId,
|
resolveWriteListId,
|
||||||
@@ -31,7 +32,8 @@ import {
|
|||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
unassignedStepMessage,
|
unassignedStepMessage,
|
||||||
executorsToWrite
|
executorsToWrite,
|
||||||
|
composeRemoveRowRule
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import {
|
import {
|
||||||
uploadAttachments,
|
uploadAttachments,
|
||||||
@@ -49,7 +51,8 @@ import {
|
|||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, hasOtherDatabase } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
|
import { formIssues } from './validation';
|
||||||
import { saveTicketRequest, ticket1cApprovalService } from './submit';
|
import { saveTicketRequest, ticket1cApprovalService } from './submit';
|
||||||
import { loadTicketRequest } from './load';
|
import { loadTicketRequest } from './load';
|
||||||
import { getApplicantProfile } from './services/profile.service';
|
import { getApplicantProfile } from './services/profile.service';
|
||||||
@@ -57,6 +60,7 @@ import { RequestSection } from './sections/RequestSection';
|
|||||||
import { ContactSection } from './sections/ContactSection';
|
import { ContactSection } from './sections/ContactSection';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
import { applyManagerOverride, managerEmail } from './managerOverride';
|
import { applyManagerOverride, managerEmail } from './managerOverride';
|
||||||
|
import { planTicket1CPhase, ticket1cRouteFieldValues } from './planRoute';
|
||||||
import { handoffToPlanner } from './plannerHandoff';
|
import { handoffToPlanner } from './plannerHandoff';
|
||||||
import { submitStatus } from './submitStatus';
|
import { submitStatus } from './submitStatus';
|
||||||
|
|
||||||
@@ -168,26 +172,24 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||||
const requestTypeId = requestTypeIdState.id;
|
const requestTypeId = requestTypeIdState.id;
|
||||||
|
|
||||||
// Маршрут полностью и однозначно определяется видом работ и отделом — настроен
|
// Маршрут полностью и однозначно определяется видом работ, базой 1С и отделом —
|
||||||
// в админке маршрутов (условные шаги по WorkType и Department, см. fieldValues),
|
// настроен в админке маршрутов (условные шаги по WorkType, DB1C и Department,
|
||||||
// а не выбирается заявителем. Поэтому матрица здесь ВСЕГДА нередактируема
|
// см. fieldValues), а не выбирается заявителем. Поэтому матрица здесь ВСЕГДА
|
||||||
// (editable=false) — заявитель только видит, кто согласует, без возможности
|
// нередактируема (editable=false) — заявитель только видит, кто согласует, без
|
||||||
// добавить/убрать/переставить согласующего.
|
// возможности добавить/убрать/переставить согласующего.
|
||||||
// Условия шагов (и согласования, и исполнения) — по виду работ и отделу.
|
// Расчёт маршрута вынесен в planRoute.ts — им же пользуется страница
|
||||||
|
// «Проверка маршрутов», чтобы проверять ровно то, что видит заявитель.
|
||||||
const fieldValues = useMemo(
|
const fieldValues = useMemo(
|
||||||
() => [
|
() => ticket1cRouteFieldValues({ WorkType: data.WorkType, DB1C: data.DB1C, Department: data.Department }),
|
||||||
{ fieldName: 'WorkType', value: data.WorkType },
|
[data.WorkType, data.DB1C, data.Department]
|
||||||
{ fieldName: 'Department', value: data.Department }
|
|
||||||
],
|
|
||||||
[data.WorkType, data.Department]
|
|
||||||
);
|
);
|
||||||
const matrixKey = `${userEmail}-${editId ?? 'new'}-${data.WorkType}-${data.Department}`;
|
const matrixKey = [userEmail, editId ?? 'new', ...fieldValues.map((f) => f.value)].join('-');
|
||||||
const matrixReady =
|
const matrixReady =
|
||||||
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.WorkType && !!requestTypeId));
|
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.WorkType && !!requestTypeId));
|
||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
||||||
getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, fieldValues)
|
planTicket1CPhase(context, requestTypeId as number, userEmail, approval, 'Approval', fieldValues)
|
||||||
),
|
),
|
||||||
[context, userEmail, approval, editId, requestStatus, fieldValues, requestTypeId]
|
[context, userEmail, approval, editId, requestStatus, fieldValues, requestTypeId]
|
||||||
);
|
);
|
||||||
@@ -198,7 +200,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
const loadExecutors = useCallback(
|
const loadExecutors = useCallback(
|
||||||
() =>
|
() =>
|
||||||
loadRoutePhase(approval, editId, requestStatus, 'Execution', () =>
|
loadRoutePhase(approval, editId, requestStatus, 'Execution', () =>
|
||||||
getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution', null, fieldValues)
|
planTicket1CPhase(context, requestTypeId as number, userEmail, approval, 'Execution', fieldValues)
|
||||||
),
|
),
|
||||||
[context, userEmail, approval, editId, requestStatus, fieldValues, requestTypeId]
|
[context, userEmail, approval, editId, requestStatus, fieldValues, requestTypeId]
|
||||||
);
|
);
|
||||||
@@ -241,20 +243,11 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
);
|
);
|
||||||
const matrixEditor = useMemo(() => ({ ...matrix, rows: matrixRows }), [matrix, matrixRows]);
|
const matrixEditor = useMemo(() => ({ ...matrix, rows: matrixRows }), [matrix, matrixRows]);
|
||||||
|
|
||||||
const isOtherDatabaseMissing = hasOtherDatabase(data) && !data.DB1COther.trim();
|
// Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider.
|
||||||
const areRequiredFilled = !!(
|
const issues = formIssues(data);
|
||||||
data.TaskName.trim() &&
|
const isValid = issues.length === 0;
|
||||||
data.WorkType &&
|
|
||||||
data.Organization &&
|
|
||||||
data.DB1C.length > 0 &&
|
|
||||||
data.Question.trim()
|
|
||||||
);
|
|
||||||
const isValid = areRequiredFilled && !isOtherDatabaseMissing;
|
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
if (!areRequiredFilled) {
|
if (!isValid) return `Заполните или исправьте поля: ${issueLabels(issues)}.`;
|
||||||
return 'Заполните обязательные поля: наименование задачи, вид работ, организация, база 1С, вопрос.';
|
|
||||||
}
|
|
||||||
if (isOtherDatabaseMissing) return 'Выбрана база 1С «Другое» — укажите её название.';
|
|
||||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -392,7 +385,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<FieldIssuesProvider draftIssues={issues} submitIssues={issues}>
|
||||||
<button type="button" className={styles.back} onClick={onCancel}>
|
<button type="button" className={styles.back} onClick={onCancel}>
|
||||||
<Icon name="arrow-left" size="sm" />
|
<Icon name="arrow-left" size="sm" />
|
||||||
<span>К списку заявок</span>
|
<span>К списку заявок</span>
|
||||||
@@ -444,6 +437,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={matrixEditor}
|
editor={matrixEditor}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
// Пустой маршрут — штатная ситуация: для части видов работ согласование
|
// Пустой маршрут — штатная ситуация: для части видов работ согласование
|
||||||
// не нужно, заявка сразу уходит в Planner. Сбой загрузки маршрута
|
// не нужно, заявка сразу уходит в Planner. Сбой загрузки маршрута
|
||||||
// показывается отдельно (matrix.error) и блокирует отправку.
|
// показывается отдельно (matrix.error) и блокирует отправку.
|
||||||
@@ -457,6 +451,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
title="Исполнение"
|
title="Исполнение"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
emptyText="Исполнители не назначены."
|
emptyText="Исполнители не назначены."
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -485,7 +480,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
submitLabel="Отправить"
|
submitLabel="Отправить"
|
||||||
/>
|
/>
|
||||||
</FormCard>
|
</FormCard>
|
||||||
</div>
|
</FieldIssuesProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ export const CHOICES_LOADING_PLACEHOLDER = 'Загрузка вариантов
|
|||||||
export const CHOICES_EMPTY_PLACEHOLDER = 'Нет вариантов — обратитесь к администратору';
|
export const CHOICES_EMPTY_PLACEHOLDER = 'Нет вариантов — обратитесь к администратору';
|
||||||
export const CHOICES_ERROR_PREFIX = 'Не удалось загрузить варианты полей';
|
export const CHOICES_ERROR_PREFIX = 'Не удалось загрузить варианты полей';
|
||||||
|
|
||||||
|
// Подсказка в поле «База 1С», когда сработало правило «дата запрета по АТП».
|
||||||
|
export const DB1C_ATP_ONLY_HINT = 'Для даты запрета по АТП другие базы не выбираются';
|
||||||
|
// Предупреждение над полем «База 1С» при виде работ «Дата запрета».
|
||||||
|
export const DB1C_ATP_ONLY_WARNING =
|
||||||
|
'Для даты запрета (открытие/закрытие) по базе АТП можно выбрать только АТП — ' +
|
||||||
|
'дополнительные базы недоступны.';
|
||||||
|
|
||||||
// Значения из property pane старой web part (см. живую страницу SharePoint).
|
// Значения из property pane старой web part (см. живую страницу SharePoint).
|
||||||
export const PLANNER_PLAN_ID = '168WqDV8CEKkW3cL6olCRZgACwYa';
|
export const PLANNER_PLAN_ID = '168WqDV8CEKkW3cL6olCRZgACwYa';
|
||||||
export const PLANNER_BUCKET_NAME = 'Входящие неотсортированные';
|
export const PLANNER_BUCKET_NAME = 'Входящие неотсортированные';
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { IMatrixRow } from '../../shared/approval';
|
||||||
|
import { dedupeRouteRows } from './dedupeRoute';
|
||||||
|
|
||||||
|
function row(order: number, email: string): IMatrixRow {
|
||||||
|
return { order, who: email, email, position: '', status: '—', date: '', comment: '', allowReassign: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dedupeRouteRows', () => {
|
||||||
|
it('схлопывает одного человека на одном шаге (ЗУП + АТП от финдепа)', () => {
|
||||||
|
const rows = [row(1, 'boss@tnsu.kz'), row(2, 'gulzhan@tnsu.kz'), row(2, 'Gulzhan@tnsu.kz')];
|
||||||
|
expect(dedupeRouteRows(rows).map((r) => r.email)).toEqual(['boss@tnsu.kz', 'gulzhan@tnsu.kz']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('оставляет разных людей на одном шаге', () => {
|
||||||
|
const rows = [row(2, 'gulzhan@tnsu.kz'), row(2, 'zhanat@tnsu.kz')];
|
||||||
|
expect(dedupeRouteRows(rows)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('оставляет одного человека на разных шагах', () => {
|
||||||
|
const rows = [row(1, 'gulzhan@tnsu.kz'), row(2, 'gulzhan@tnsu.kz')];
|
||||||
|
expect(dedupeRouteRows(rows)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не трогает неназначенные строки', () => {
|
||||||
|
const rows = [row(2, ''), row(2, '')];
|
||||||
|
expect(dedupeRouteRows(rows)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { IMatrixRow } from '../../shared/approval';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Убирает повторы одного и того же человека внутри одного шага маршрута.
|
||||||
|
*
|
||||||
|
* Зачем: «База 1С» — мультивыбор, и при нескольких базах на одном шаге могут
|
||||||
|
* сработать несколько строк матрицы с одним исполнителем (напр. Гульжан и за
|
||||||
|
* ЗУП, и за АТП от финдепа). Без схлопывания он получил бы две строки
|
||||||
|
* параллельного шага — два уведомления и два решения.
|
||||||
|
*
|
||||||
|
* Один человек на РАЗНЫХ шагах не схлопывается — это разные согласования.
|
||||||
|
* Строки без email (неназначенный шаг) не трогаем.
|
||||||
|
*/
|
||||||
|
export function dedupeRouteRows(rows: IMatrixRow[]): IMatrixRow[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return rows.filter((row) => {
|
||||||
|
if (!row.email) return true;
|
||||||
|
const key = `${row.order}|${row.email.toLowerCase()}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { DB1C_ATP_OPTION, WORK_TYPE_BAN_DATE, restrictDatabases } from './model';
|
||||||
|
|
||||||
|
const ZUP = '1С: Зарплата и управление персоналом (ЗУП)';
|
||||||
|
|
||||||
|
describe('Ticket1C restrictDatabases', () => {
|
||||||
|
it('дата запрета + АТП — остаётся только АТП', () => {
|
||||||
|
expect(restrictDatabases(WORK_TYPE_BAN_DATE, [ZUP, DB1C_ATP_OPTION])).toEqual([DB1C_ATP_OPTION]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('дата запрета без АТП — базы не трогаем', () => {
|
||||||
|
expect(restrictDatabases(WORK_TYPE_BAN_DATE, [ZUP])).toEqual([ZUP]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('другой вид работ — АТП можно сочетать с другими базами', () => {
|
||||||
|
expect(restrictDatabases('Прочее', [ZUP, DB1C_ATP_OPTION])).toEqual([ZUP, DB1C_ATP_OPTION]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,6 +44,27 @@ export function hasOtherDatabase(data: IFormData): boolean {
|
|||||||
return data.DB1C.indexOf(DB1C_OTHER_OPTION) !== -1;
|
return data.DB1C.indexOf(DB1C_OTHER_OPTION) !== -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Варианты колонок SP, для которых действует правило «дата запрета по АТП —
|
||||||
|
* только одна база». Должны дословно совпадать с вариантами колонок
|
||||||
|
* WorkType и DB1C в SharePoint (варианты читаются из SP, см. useFieldChoices).
|
||||||
|
*/
|
||||||
|
export const WORK_TYPE_BAN_DATE = 'Дата запрета (открытие/закрытие)';
|
||||||
|
export const DB1C_ATP_OPTION = '1С: Автотранспортное предприятие (АТП)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дата запрета в АТП открывается/закрывается отдельно от остальных баз,
|
||||||
|
* поэтому в такой заявке база АТП может быть только одна.
|
||||||
|
*/
|
||||||
|
export function isAtpOnlyBanDate(workType: string, databases: string[]): boolean {
|
||||||
|
return workType === WORK_TYPE_BAN_DATE && databases.indexOf(DB1C_ATP_OPTION) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Применяет правило isAtpOnlyBanDate: при его срабатывании оставляет только АТП. */
|
||||||
|
export function restrictDatabases(workType: string, databases: string[]): string[] {
|
||||||
|
return isAtpOnlyBanDate(workType, databases) ? [DB1C_ATP_OPTION] : databases;
|
||||||
|
}
|
||||||
|
|
||||||
/** Обновление одного поля формы с сохранением типа значения. */
|
/** Обновление одного поля формы с сохранением типа значения. */
|
||||||
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import {
|
||||||
|
ApprovalListService,
|
||||||
|
ApprovalStatus,
|
||||||
|
getPlannedMatrix,
|
||||||
|
IMatrixRow,
|
||||||
|
IResolveFieldValue
|
||||||
|
} from '../../shared/approval';
|
||||||
|
import { IFormData } from './model';
|
||||||
|
import { dedupeRouteRows } from './dedupeRoute';
|
||||||
|
|
||||||
|
/** Поля заявки, от которых зависит маршрут 1С (условия шагов в админке маршрута #8). */
|
||||||
|
export type Ticket1CRouteInput = Pick<IFormData, 'WorkType' | 'DB1C' | 'Department'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Значения полей для условных шагов маршрута (fieldValues при resolve).
|
||||||
|
*
|
||||||
|
* Базы 1С — мультивыбор, а approval-route принимает value только строкой:
|
||||||
|
* все выбранные базы уходят одной записью через пробел (как CompaniesJson в
|
||||||
|
* RecruitingHire), условия в админке — «DB1C содержит ЗУП/АТП/ATS».
|
||||||
|
*/
|
||||||
|
export function ticket1cRouteFieldValues(input: Ticket1CRouteInput): IResolveFieldValue[] {
|
||||||
|
return [
|
||||||
|
{ fieldName: 'WorkType', value: input.WorkType },
|
||||||
|
{ fieldName: 'DB1C', value: input.DB1C.slice().sort().join(' ') },
|
||||||
|
{ fieldName: 'Department', value: input.Department }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запланированный маршрут одной фазы заявки в 1С. Один источник для формы и для
|
||||||
|
* страницы «Проверка маршрутов» — проверка должна видеть ровно то, что увидит
|
||||||
|
* пользователь, включая схлопывание повторов на шаге.
|
||||||
|
*/
|
||||||
|
export async function planTicket1CPhase(
|
||||||
|
context: WebPartContext,
|
||||||
|
requestTypeId: number,
|
||||||
|
userEmail: string,
|
||||||
|
approval: ApprovalListService,
|
||||||
|
phase: ApprovalStatus,
|
||||||
|
fieldValues: IResolveFieldValue[]
|
||||||
|
): Promise<IMatrixRow[]> {
|
||||||
|
const rows = await getPlannedMatrix(context, requestTypeId, userEmail, approval, phase, null, fieldValues);
|
||||||
|
return dedupeRouteRows(rows);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Строка на обе колонки сетки секции — как FormField с full.
|
||||||
|
.fullRow {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import styles from './RequestSection.module.scss';
|
||||||
import { Alert, CollapseSection, FormField, MultiChoiceField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui';
|
import { Alert, CollapseSection, FormField, MultiChoiceField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui';
|
||||||
import { useFieldChoices, IFieldChoicesState } from '../../../shared/fieldChoices';
|
import { useFieldChoices, IFieldChoicesState } from '../../../shared/fieldChoices';
|
||||||
import { dataListTitle } from '../../../shared/listTitles';
|
import { dataListTitle } from '../../../shared/listTitles';
|
||||||
import { DB1C_OTHER_OPTION, FORM_ID, ISectionProps, hasOtherDatabase } from '../model';
|
import {
|
||||||
|
DB1C_OTHER_OPTION,
|
||||||
|
FORM_ID,
|
||||||
|
WORK_TYPE_BAN_DATE,
|
||||||
|
ISectionProps,
|
||||||
|
hasOtherDatabase,
|
||||||
|
isAtpOnlyBanDate,
|
||||||
|
restrictDatabases
|
||||||
|
} from '../model';
|
||||||
import {
|
import {
|
||||||
QUESTION_MAX,
|
QUESTION_MAX,
|
||||||
|
DB1C_ATP_ONLY_HINT,
|
||||||
|
DB1C_ATP_ONLY_WARNING,
|
||||||
CHOICES_LOADING_PLACEHOLDER,
|
CHOICES_LOADING_PLACEHOLDER,
|
||||||
CHOICES_EMPTY_PLACEHOLDER,
|
CHOICES_EMPTY_PLACEHOLDER,
|
||||||
CHOICES_ERROR_PREFIX
|
CHOICES_ERROR_PREFIX
|
||||||
@@ -30,6 +41,16 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => {
|
|||||||
const organizations = useFieldChoices(listTitle, 'Organization');
|
const organizations = useFieldChoices(listTitle, 'Organization');
|
||||||
const databases = useFieldChoices(listTitle, 'DB1C');
|
const databases = useFieldChoices(listTitle, 'DB1C');
|
||||||
const choicesError = workTypes.error || organizations.error || databases.error;
|
const choicesError = workTypes.error || organizations.error || databases.error;
|
||||||
|
const isAtpOnly = isAtpOnlyBanDate(data.WorkType, data.DB1C);
|
||||||
|
|
||||||
|
// Правило «дата запрета по АТП» зависит и от вида работ, и от баз — поэтому
|
||||||
|
// применяется при смене любого из двух полей.
|
||||||
|
const setDatabases = (workType: string, values: string[]): void => {
|
||||||
|
const restricted = restrictDatabases(workType, values);
|
||||||
|
set('DB1C', restricted);
|
||||||
|
// Сняли «Другое» — вписанное название больше не относится к заявке.
|
||||||
|
if (restricted.indexOf(DB1C_OTHER_OPTION) === -1) set('DB1COther', '');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapseSection title="Заявка" icon="clipboard">
|
<CollapseSection title="Заявка" icon="clipboard">
|
||||||
@@ -41,7 +62,10 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => {
|
|||||||
<Select
|
<Select
|
||||||
id="WorkType"
|
id="WorkType"
|
||||||
value={data.WorkType}
|
value={data.WorkType}
|
||||||
onChange={(v) => set('WorkType', v)}
|
onChange={(v) => {
|
||||||
|
set('WorkType', v);
|
||||||
|
setDatabases(v, data.DB1C);
|
||||||
|
}}
|
||||||
options={withCurrentValue(workTypes.options, data.WorkType)}
|
options={withCurrentValue(workTypes.options, data.WorkType)}
|
||||||
placeholder={choicesPlaceholder(workTypes, '— выберите —')}
|
placeholder={choicesPlaceholder(workTypes, '— выберите —')}
|
||||||
/>
|
/>
|
||||||
@@ -55,18 +79,22 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => {
|
|||||||
placeholder={choicesPlaceholder(organizations, '— выберите —')}
|
placeholder={choicesPlaceholder(organizations, '— выберите —')}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
{/* Предупреждаем заранее, ещё до выбора баз, чтобы правило не стало сюрпризом. */}
|
||||||
|
{data.WorkType === WORK_TYPE_BAN_DATE && (
|
||||||
|
<div className={styles.fullRow}>
|
||||||
|
<Alert variant="warn">{DB1C_ATP_ONLY_WARNING}</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* На всю ширину: чипы выбранных баз растят поле по высоте, и соседнее
|
{/* На всю ширину: чипы выбранных баз растят поле по высоте, и соседнее
|
||||||
поле в той же строке сетки съезжало бы относительно него. */}
|
поле в той же строке сетки съезжало бы относительно него. */}
|
||||||
<FormField label="База 1С" fieldKey="DB1C" required full>
|
<FormField label="База 1С" fieldKey="DB1C" required full>
|
||||||
<MultiChoiceField
|
<MultiChoiceField
|
||||||
value={data.DB1C}
|
value={data.DB1C}
|
||||||
onChange={(v) => {
|
onChange={(v) => setDatabases(data.WorkType, v)}
|
||||||
set('DB1C', v);
|
// Пустой список вариантов прячет выпадашку — добавить другую базу нельзя,
|
||||||
// Сняли «Другое» — вписанное название больше не относится к заявке.
|
// пока не уберут АТП или не сменят вид работ.
|
||||||
if (v.indexOf(DB1C_OTHER_OPTION) === -1) set('DB1COther', '');
|
options={isAtpOnly ? [] : databases.options}
|
||||||
}}
|
placeholder={isAtpOnly ? DB1C_ATP_ONLY_HINT : choicesPlaceholder(databases, 'Выберите одну или несколько баз')}
|
||||||
options={databases.options}
|
|
||||||
placeholder={choicesPlaceholder(databases, 'Выберите одну или несколько баз')}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
{hasOtherDatabase(data) && (
|
{hasOtherDatabase(data) && (
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { IFieldIssue } from '../../shared/ui';
|
||||||
|
import { IFormData, hasOtherDatabase } from './model';
|
||||||
|
|
||||||
|
/** Название базы вручную обязательно, только если в «База 1С» отмечено «Другое». */
|
||||||
|
function otherDatabaseIssue(data: IFormData): IFieldIssue | undefined {
|
||||||
|
if (!hasOtherDatabase(data) || data.DB1COther.trim()) return undefined;
|
||||||
|
return { field: 'DB1COther', label: 'Другая база 1С', message: 'выбрано «Другое» — укажите название базы' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Всё, что мешает сохранить или отправить заявку, в порядке формы (пусто — можно).
|
||||||
|
* field — ключ FormField (htmlFor/fieldKey): по нему поле подсвечивается и к нему
|
||||||
|
* прокручивается форма.
|
||||||
|
*/
|
||||||
|
export function formIssues(data: IFormData): IFieldIssue[] {
|
||||||
|
const issues: Array<IFieldIssue | undefined> = [
|
||||||
|
data.TaskName.trim() ? undefined : { field: 'TaskName', label: 'Наименование задачи' },
|
||||||
|
data.WorkType ? undefined : { field: 'WorkType', label: 'Виды работ' },
|
||||||
|
data.Organization ? undefined : { field: 'Organization', label: 'Организация' },
|
||||||
|
data.DB1C.length > 0 ? undefined : { field: 'DB1C', label: 'База 1С' },
|
||||||
|
otherDatabaseIssue(data),
|
||||||
|
data.Question.trim() ? undefined : { field: 'Question', label: 'Вопрос' }
|
||||||
|
];
|
||||||
|
return issues.filter((issue): issue is IFieldIssue => issue !== undefined);
|
||||||
|
}
|
||||||
@@ -22,10 +22,11 @@
|
|||||||
- **Период вводится двумя датами** (с / по), обе обязательны, конец ≥ начала.
|
- **Период вводится двумя датами** (с / по), обе обязательны, конец ≥ начала.
|
||||||
`DaysCount` считается при сохранении (включительно) для отчётности — в UI не показывается.
|
`DaysCount` считается при сохранении (включительно) для отчётности — в UI не показывается.
|
||||||
- **Причина** (`UnpaidLeaveReasonType`, Choice) — обязательна: брак / рождение ребёнка /
|
- **Причина** (`UnpaidLeaveReasonType`, Choice) — обязательна: брак / рождение ребёнка /
|
||||||
смерть близких / «В иных случаях». Только при «иных случаях» появляется обязательный
|
смерть близких / «В иных случаях» (в форме подписано «Иная причина», значение в SP прежнее). Только при «иных случаях» появляется обязательный
|
||||||
текст причины (`Reason`). У заявок до появления колонки причины нет — их прежний
|
текст причины (`Reason`). У заявок до появления колонки причины нет — их прежний
|
||||||
текст из `Reason` по-прежнему показывается (`isReasonTextShown`).
|
текст из `Reason` по-прежнему показывается (`isReasonTextShown`).
|
||||||
- **Не более 5 календарных дней** (`MAX_DAYS`) — при любой причине.
|
- **Количество дней без верхнего предела** — 5 дней работодатель обязан дать при браке /
|
||||||
|
рождении / смерти, остальное — по договорённости, поэтому форма лимит не ставит.
|
||||||
- **Правило «не позднее чем за 14 дней»** (`MIN_NOTICE_DAYS`) — только для «иных случаев»
|
- **Правило «не позднее чем за 14 дней»** (`MIN_NOTICE_DAYS`) — только для «иных случаев»
|
||||||
(`requiresAdvanceNotice`): брак, рождение и смерть заранее не планируются.
|
(`requiresAdvanceNotice`): брак, рождение и смерть заранее не планируются.
|
||||||
- **Подсветка проблемных полей** — `FieldIssuesProvider` + правила в `validation.ts`
|
- **Подсветка проблемных полей** — `FieldIssuesProvider` + правила в `validation.ts`
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
RequestNumber,
|
RequestNumber,
|
||||||
SubmitActions,
|
SubmitActions,
|
||||||
getDraftDeleteHandler,
|
getDraftDeleteHandler,
|
||||||
useRequestSubmit
|
useRequestSubmit,
|
||||||
|
LeaveNotice
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
composeRemoveRowRule,
|
composeRemoveRowRule,
|
||||||
@@ -45,7 +46,8 @@ import {
|
|||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
|
||||||
|
import { organizationRouteFieldValues } from '../../shared/dictionary/organizationRouteFields';
|
||||||
import { formIssues } from './validation';
|
import { formIssues } from './validation';
|
||||||
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
|
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
|
||||||
import { loadUnpaidLeaveRequest } from './load';
|
import { loadUnpaidLeaveRequest } from './load';
|
||||||
@@ -161,16 +163,22 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
// Планируемый маршрут резолвится по e-mail ВЫБРАННОГО сотрудника (не текущего
|
// Планируемый маршрут резолвится по e-mail ВЫБРАННОГО сотрудника (не текущего
|
||||||
// пользователя) — так работает подача за другого. У заявки на редактировании
|
// пользователя) — так работает подача за другого. У заявки на редактировании
|
||||||
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
|
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
|
||||||
|
//
|
||||||
|
// Кадровый сотрудник и исполнитель зависят от организации сотрудника (условия
|
||||||
|
// «Organization содержит …» в админке) — см. shared/dictionary/organizationRouteFields.ts.
|
||||||
|
// Организация приходит позже выбора сотрудника, поэтому она в ключе перезагрузки.
|
||||||
|
const routeFieldValues = useMemo(() => organizationRouteFieldValues(data.Organization), [data.Organization]);
|
||||||
|
const routeOrgKey = routeFieldValues.map((f) => f.value).join('|');
|
||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
||||||
getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval')
|
getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, routeFieldValues)
|
||||||
),
|
),
|
||||||
[context, approval, editId, requestStatus, data.Employee, requestTypeId]
|
[context, approval, editId, requestStatus, data.Employee, requestTypeId, routeFieldValues]
|
||||||
);
|
);
|
||||||
const matrix = useMatrixEditor(
|
const matrix = useMatrixEditor(
|
||||||
loadMatrix,
|
loadMatrix,
|
||||||
`${data.Employee}-${editId ?? 'new'}`,
|
`${data.Employee}-${editId ?? 'new'}-${routeOrgKey}`,
|
||||||
!readOnly,
|
!readOnly,
|
||||||
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.Employee && !!requestTypeId)),
|
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.Employee && !!requestTypeId)),
|
||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
@@ -184,7 +192,8 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
requestTypeId,
|
requestTypeId,
|
||||||
userEmail: data.Employee,
|
userEmail: data.Employee,
|
||||||
editId,
|
editId,
|
||||||
requestStatus
|
requestStatus,
|
||||||
|
fieldValues: routeFieldValues
|
||||||
});
|
});
|
||||||
|
|
||||||
// Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider.
|
// Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider.
|
||||||
@@ -326,6 +335,8 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
)}
|
)}
|
||||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||||
|
|
||||||
|
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||||
|
|
||||||
<FormModeProvider readOnly={readOnly}>
|
<FormModeProvider readOnly={readOnly}>
|
||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
@@ -355,6 +366,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
submit={submit}
|
submit={submit}
|
||||||
isValid={isValid}
|
isValid={isValid}
|
||||||
|
formId={FORM_ID}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onSaveDraft={handleSaveDraft}
|
onSaveDraft={handleSaveDraft}
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './UnpaidLeaveView.module.scss';
|
import styles from './UnpaidLeaveView.module.scss';
|
||||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
import {
|
||||||
|
Alert,
|
||||||
|
FormCard,
|
||||||
|
InitiatorLine,
|
||||||
|
FormModeProvider,
|
||||||
|
PrintSplitButton,
|
||||||
|
OrgLetterhead,
|
||||||
|
LeaveNotice
|
||||||
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -23,7 +31,7 @@ import {
|
|||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
|
||||||
import { loadUnpaidLeaveRequest } from './load';
|
import { loadUnpaidLeaveRequest } from './load';
|
||||||
import { unpaidLeaveApprovalService } from './submit';
|
import { unpaidLeaveApprovalService } from './submit';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
@@ -148,6 +156,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
}
|
}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
>
|
>
|
||||||
|
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||||
<FormModeProvider readOnly>
|
<FormModeProvider readOnly>
|
||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
|
|||||||
@@ -94,8 +94,13 @@ export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О.
|
|||||||
* Действует только для «иных случаев» — см. requiresAdvanceNotice.
|
* Действует только для «иных случаев» — см. requiresAdvanceNotice.
|
||||||
*/
|
*/
|
||||||
export const MIN_NOTICE_DAYS = 14;
|
export const MIN_NOTICE_DAYS = 14;
|
||||||
/** Отпуск без сохранения заработной платы — не более стольких календарных дней (по любой причине). */
|
|
||||||
export const MAX_DAYS = 5;
|
/** Памятка вверху формы и просмотра (см. shared/ui/LeaveNotice). Текст — по просьбе HR. */
|
||||||
|
export const NOTICE_TITLE = 'Памятка';
|
||||||
|
export const NOTICE_ITEMS: string[] = [
|
||||||
|
'После согласования заявления его необходимо распечатать с портала (кнопка «Печать» на странице заявки), ' +
|
||||||
|
'собственноручно подписать и передать специалисту отдела кадров.'
|
||||||
|
];
|
||||||
|
|
||||||
/** Причины отпуска. Значения совпадают с choices колонки UnpaidLeaveReasonType. */
|
/** Причины отпуска. Значения совпадают с choices колонки UnpaidLeaveReasonType. */
|
||||||
export const REASON_MARRIAGE = 'Регистрация брака';
|
export const REASON_MARRIAGE = 'Регистрация брака';
|
||||||
@@ -108,7 +113,8 @@ export const UNPAID_LEAVE_REASONS: ISelectOption[] = [
|
|||||||
{ value: REASON_MARRIAGE, label: REASON_MARRIAGE },
|
{ value: REASON_MARRIAGE, label: REASON_MARRIAGE },
|
||||||
{ value: REASON_CHILD_BIRTH, label: REASON_CHILD_BIRTH },
|
{ value: REASON_CHILD_BIRTH, label: REASON_CHILD_BIRTH },
|
||||||
{ value: REASON_RELATIVE_DEATH, label: REASON_RELATIVE_DEATH },
|
{ value: REASON_RELATIVE_DEATH, label: REASON_RELATIVE_DEATH },
|
||||||
{ value: REASON_OTHER, label: REASON_OTHER }
|
// Значение в SP остаётся прежним (choices колонки и старые заявки), меняется только подпись.
|
||||||
|
{ value: REASON_OTHER, label: 'Иная причина' }
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
EMPLOYEE_PLACEHOLDER,
|
EMPLOYEE_PLACEHOLDER,
|
||||||
SUBSTITUTE_PLACEHOLDER,
|
SUBSTITUTE_PLACEHOLDER,
|
||||||
MIN_NOTICE_DAYS,
|
MIN_NOTICE_DAYS,
|
||||||
MAX_DAYS,
|
|
||||||
REASON_OTHER,
|
REASON_OTHER,
|
||||||
UNPAID_LEAVE_REASONS,
|
UNPAID_LEAVE_REASONS,
|
||||||
requiresAdvanceNotice,
|
requiresAdvanceNotice,
|
||||||
@@ -61,14 +60,13 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
|
|||||||
min={requiresAdvanceNotice(data.ReasonType) ? isoDatePlusDays(MIN_NOTICE_DAYS) : undefined}
|
min={requiresAdvanceNotice(data.ReasonType) ? isoDatePlusDays(MIN_NOTICE_DAYS) : undefined}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={`Количество дней (не более ${MAX_DAYS})`} htmlFor="DaysCount" required>
|
<FormField label="Количество дней" htmlFor="DaysCount" required>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="DaysCount"
|
id="DaysCount"
|
||||||
type="number"
|
type="number"
|
||||||
value={data.DaysCount}
|
value={data.DaysCount}
|
||||||
onChange={(v) => set('DaysCount', v)}
|
onChange={(v) => set('DaysCount', v)}
|
||||||
min={1}
|
min={1}
|
||||||
max={MAX_DAYS}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Причина" htmlFor="UnpaidLeaveReasonType" required full>
|
<FormField label="Причина" htmlFor="UnpaidLeaveReasonType" required full>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { IFieldIssue } from '../../shared/ui';
|
import type { IFieldIssue } from '../../shared/ui';
|
||||||
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
|
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
|
||||||
import { IFormData, MAX_DAYS, MIN_NOTICE_DAYS, REASON_OTHER, requiresAdvanceNotice } from './model';
|
import { IFormData, MIN_NOTICE_DAYS, REASON_OTHER, requiresAdvanceNotice } from './model';
|
||||||
|
|
||||||
/** Дата начала: обязательна, а в «иных случаях» — не раньше чем через MIN_NOTICE_DAYS дней. */
|
/** Дата начала: обязательна, а в «иных случаях» — не раньше чем через MIN_NOTICE_DAYS дней. */
|
||||||
function startDateIssue(data: IFormData): IFieldIssue | undefined {
|
function startDateIssue(data: IFormData): IFieldIssue | undefined {
|
||||||
@@ -12,17 +12,20 @@ function startDateIssue(data: IFormData): IFieldIssue | undefined {
|
|||||||
return {
|
return {
|
||||||
field: 'StartDate',
|
field: 'StartDate',
|
||||||
label: 'Дата начала',
|
label: 'Дата начала',
|
||||||
message: `в иных случаях заявление подают не позднее чем за ${MIN_NOTICE_DAYS} дней — не раньше ${formatIsoDate(minStartDate)}`
|
message: `при иной причине заявление подают не позднее чем за ${MIN_NOTICE_DAYS} дней — не раньше ${formatIsoDate(minStartDate)}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Количество дней: обязательно, от 1 до MAX_DAYS. */
|
/**
|
||||||
|
* Количество дней: обязательно, целое и больше нуля. Верхнего предела нет:
|
||||||
|
* сверх 5 дней (обязательных при браке/рождении/смерти) — по договорённости с работодателем.
|
||||||
|
*/
|
||||||
function daysCountIssue(data: IFormData): IFieldIssue | undefined {
|
function daysCountIssue(data: IFormData): IFieldIssue | undefined {
|
||||||
if (!data.DaysCount.trim()) return { field: 'DaysCount', label: 'Количество дней' };
|
if (!data.DaysCount.trim()) return { field: 'DaysCount', label: 'Количество дней' };
|
||||||
|
|
||||||
const days = Number(data.DaysCount);
|
const days = Number(data.DaysCount);
|
||||||
if (Number.isFinite(days) && days > 0 && days <= MAX_DAYS) return undefined;
|
if (Number.isInteger(days) && days > 0) return undefined;
|
||||||
return { field: 'DaysCount', label: 'Количество дней', message: `от 1 до ${MAX_DAYS} календарных дней` };
|
return { field: 'DaysCount', label: 'Количество дней', message: 'целое число больше нуля' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,6 +30,9 @@
|
|||||||
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
|
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
|
||||||
|
|
||||||
## Подписание в ЭДО
|
## Подписание в ЭДО
|
||||||
|
> **Сейчас выключено** (`VACATION_EDO_SIGNING_ENABLED = false` в `shared/config.ts`): решение последнего
|
||||||
|
> согласующего применяется сразу, в ЭДО ничего не уходит. Описанное ниже работает, когда флаг `true`.
|
||||||
|
|
||||||
Когда **последний согласующий** нажимает «Согласовать», заявление сначала уходит на подпись в TANSU ЭДО
|
Когда **последний согласующий** нажимает «Согласовать», заявление сначала уходит на подпись в TANSU ЭДО
|
||||||
(`VacationView` → `onBeforeDecide`), и только потом применяется решение. Последний — значит заявка
|
(`VacationView` → `onBeforeDecide`), и только потом применяется решение. Последний — значит заявка
|
||||||
«На согласовании» и несогласованной осталась одна строка фазы согласования (`completesApprovalPhase`;
|
«На согласовании» и несогласованной осталась одна строка фазы согласования (`completesApprovalPhase`;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useState, useMemo, useCallback, useEffect } from 'react';
|
|||||||
import styles from './VacationForm.module.scss';
|
import styles from './VacationForm.module.scss';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
FieldIssuesProvider,
|
||||||
|
issueLabels,
|
||||||
FormCard,
|
FormCard,
|
||||||
InitiatorLine,
|
InitiatorLine,
|
||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
@@ -10,7 +12,8 @@ import {
|
|||||||
RequestNumber,
|
RequestNumber,
|
||||||
SubmitActions,
|
SubmitActions,
|
||||||
getDraftDeleteHandler,
|
getDraftDeleteHandler,
|
||||||
useRequestSubmit
|
useRequestSubmit,
|
||||||
|
LeaveNotice
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
composeRemoveRowRule,
|
composeRemoveRowRule,
|
||||||
@@ -37,14 +40,15 @@ import { effectiveUserEmail } from '../../shared/impersonation';
|
|||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import { getEmployeeOrganizations, formatEmployeeOrganizationsWithDepartment, IEmployeeOrganization } from '../../shared/dictionary';
|
import { getEmployeeOrganizations, formatEmployeeOrganizationsWithDepartment, IEmployeeOrganization } from '../../shared/dictionary';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
|
|
||||||
import {
|
import {
|
||||||
approvalListTitle,
|
approvalListTitle,
|
||||||
approvalListTitleForListId,
|
approvalListTitleForListId,
|
||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_DAYS } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
|
||||||
|
import { formIssues } from './validation';
|
||||||
|
import { organizationRouteFieldValues } from '../../shared/dictionary/organizationRouteFields';
|
||||||
import { saveVacationRequest, vacationApprovalService } from './submit';
|
import { saveVacationRequest, vacationApprovalService } from './submit';
|
||||||
import { loadVacationRequest } from './load';
|
import { loadVacationRequest } from './load';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
@@ -169,16 +173,24 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
// Планируемый маршрут резолвится по e-mail ВЫБРАННОГО сотрудника (не текущего
|
// Планируемый маршрут резолвится по e-mail ВЫБРАННОГО сотрудника (не текущего
|
||||||
// пользователя) — так работает подача за другого. У заявки на редактировании
|
// пользователя) — так работает подача за другого. У заявки на редактировании
|
||||||
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
|
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
|
||||||
|
//
|
||||||
|
// Кадровый сотрудник и исполнитель в маршруте зависят от организации
|
||||||
|
// сотрудника (условия «Organization содержит …» в админке) — см.
|
||||||
|
// shared/dictionary/organizationRouteFields.ts. Организация приходит позже
|
||||||
|
// выбора сотрудника (Employee/ByEmail), поэтому она в ключе перезагрузки:
|
||||||
|
// маршрут пересчитается, когда придёт, а не останется рассчитанным без условий.
|
||||||
|
const routeFieldValues = useMemo(() => organizationRouteFieldValues(data.Organization), [data.Organization]);
|
||||||
|
const routeOrgKey = routeFieldValues.map((f) => f.value).join('|');
|
||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
||||||
getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval')
|
getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, routeFieldValues)
|
||||||
),
|
),
|
||||||
[context, approval, editId, requestStatus, data.Employee, requestTypeId]
|
[context, approval, editId, requestStatus, data.Employee, requestTypeId, routeFieldValues]
|
||||||
);
|
);
|
||||||
const matrix = useMatrixEditor(
|
const matrix = useMatrixEditor(
|
||||||
loadMatrix,
|
loadMatrix,
|
||||||
`${data.Employee}-${editId ?? 'new'}`,
|
`${data.Employee}-${editId ?? 'new'}-${routeOrgKey}`,
|
||||||
!readOnly,
|
!readOnly,
|
||||||
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.Employee && !!requestTypeId)),
|
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!data.Employee && !!requestTypeId)),
|
||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
@@ -193,27 +205,15 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
requestTypeId,
|
requestTypeId,
|
||||||
userEmail: data.Employee,
|
userEmail: data.Employee,
|
||||||
editId,
|
editId,
|
||||||
requestStatus
|
requestStatus,
|
||||||
|
fieldValues: routeFieldValues
|
||||||
});
|
});
|
||||||
|
|
||||||
const daysNum = Number(data.DaysCount);
|
// Проблемы полей (см. validation.ts) подсвечиваются под полями через FieldIssuesProvider.
|
||||||
// Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала
|
const issues = formIssues(data);
|
||||||
// отпуска — календарь (min у input type=date, см. RequestSection) не даёт
|
const isValid = issues.length === 0;
|
||||||
// выбрать более раннюю дату, но проверяем и здесь на случай обхода UI.
|
|
||||||
const minStartDate = isoDatePlusDays(MIN_NOTICE_DAYS);
|
|
||||||
const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate;
|
|
||||||
const isValid = !!(
|
|
||||||
data.Employee &&
|
|
||||||
data.StartDate &&
|
|
||||||
isStartDateValid &&
|
|
||||||
Number.isFinite(daysNum) &&
|
|
||||||
daysNum > 0
|
|
||||||
);
|
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
if (data.StartDate && !isStartDateValid) {
|
if (!isValid) return `Заполните или исправьте поля: ${issueLabels(issues)}.`;
|
||||||
return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`;
|
|
||||||
}
|
|
||||||
if (!isValid) return 'Заполните обязательные поля: сотрудник, дата начала, количество дней (> 0).';
|
|
||||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -323,7 +323,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<FieldIssuesProvider draftIssues={issues} submitIssues={issues}>
|
||||||
<button type="button" className={styles.back} onClick={onCancel}>
|
<button type="button" className={styles.back} onClick={onCancel}>
|
||||||
<Icon name="arrow-left" size="sm" />
|
<Icon name="arrow-left" size="sm" />
|
||||||
<span>К списку заявок</span>
|
<span>К списку заявок</span>
|
||||||
@@ -354,6 +354,8 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
)}
|
)}
|
||||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||||
|
|
||||||
|
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||||
|
|
||||||
<FormModeProvider readOnly={readOnly}>
|
<FormModeProvider readOnly={readOnly}>
|
||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
@@ -383,6 +385,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
submit={submit}
|
submit={submit}
|
||||||
isValid={isValid}
|
isValid={isValid}
|
||||||
|
formId={FORM_ID}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onSaveDraft={handleSaveDraft}
|
onSaveDraft={handleSaveDraft}
|
||||||
@@ -390,7 +393,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
submitLabel="Отправить на согласование"
|
submitLabel="Отправить на согласование"
|
||||||
/>
|
/>
|
||||||
</FormCard>
|
</FormCard>
|
||||||
</div>
|
</FieldIssuesProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './VacationView.module.scss';
|
import styles from './VacationView.module.scss';
|
||||||
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
FormCard,
|
||||||
|
InitiatorLine,
|
||||||
|
FormModeProvider,
|
||||||
|
PrintSplitButton,
|
||||||
|
OrgLetterhead,
|
||||||
|
LeaveNotice
|
||||||
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -21,13 +30,14 @@ import type { ApproverDecision } from '../../shared/approval';
|
|||||||
import { isAlreadyAccepted } from '../../shared/edo/edoErrors';
|
import { isAlreadyAccepted } from '../../shared/edo/edoErrors';
|
||||||
import { executionPhaseRows } from '../../shared/approval/useRouteExecutors';
|
import { executionPhaseRows } from '../../shared/approval/useRouteExecutors';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
|
import { VACATION_EDO_SIGNING_ENABLED } from '../../shared/config';
|
||||||
import {
|
import {
|
||||||
approvalListTitle,
|
approvalListTitle,
|
||||||
approvalListTitleForListId,
|
approvalListTitleForListId,
|
||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
|
||||||
import { loadVacationRequest } from './load';
|
import { loadVacationRequest } from './load';
|
||||||
import { vacationApprovalService } from './submit';
|
import { vacationApprovalService } from './submit';
|
||||||
import { IVacationSigningResult, sendVacationForSigning } from './sendToSign';
|
import { IVacationSigningResult, sendVacationForSigning } from './sendToSign';
|
||||||
@@ -170,7 +180,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
// заявка остаётся на согласовании, и ошибку видно в панели решения; согласовать можно ещё раз.
|
// заявка остаётся на согласовании, и ошибку видно в панели решения; согласовать можно ещё раз.
|
||||||
const sendBeforeFinalApproval = async (decision: ApproverDecision): Promise<void> => {
|
const sendBeforeFinalApproval = async (decision: ApproverDecision): Promise<void> => {
|
||||||
sentBeforeDecision.current = false;
|
sentBeforeDecision.current = false;
|
||||||
if (decision !== 'approve') return;
|
if (!VACATION_EDO_SIGNING_ENABLED || decision !== 'approve') return;
|
||||||
|
|
||||||
if (!completesApprovalPhase(status, await approval.getMatrixForRequest(itemId))) return;
|
if (!completesApprovalPhase(status, await approval.getMatrixForRequest(itemId))) return;
|
||||||
|
|
||||||
@@ -208,6 +218,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
}
|
}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
>
|
>
|
||||||
|
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||||
<FormModeProvider readOnly>
|
<FormModeProvider readOnly>
|
||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
@@ -225,18 +236,23 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<div className="rp-no-print">
|
||||||
{signingBusy && <Alert variant="ok">Отправляем заявление на подпись в ЭДО…</Alert>}
|
{/* Пока отправка в ЭДО выключена — ни одного сообщения о ней. */}
|
||||||
{!signingBusy && signingResult && signingResult.skipped.length > 0 && (
|
{VACATION_EDO_SIGNING_ENABLED && (
|
||||||
<Alert variant="err">
|
|
||||||
{`Не отправлено по компаниям: ${signingResult.skipped.map((c) => `${c.name} — ${c.reason}`).join('; ')}.`}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{!signingBusy && signingError && (
|
|
||||||
<>
|
<>
|
||||||
<Alert variant="err">{signingError}</Alert>
|
{signingBusy && <Alert variant="ok">Отправляем заявление на подпись в ЭДО…</Alert>}
|
||||||
<Button variant="secondary" onClick={retrySigning}>
|
{!signingBusy && signingResult && signingResult.skipped.length > 0 && (
|
||||||
Повторить отправку на подпись
|
<Alert variant="err">
|
||||||
</Button>
|
{`Не отправлено по компаниям: ${signingResult.skipped.map((c) => `${c.name} — ${c.reason}`).join('; ')}.`}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{!signingBusy && signingError && (
|
||||||
|
<>
|
||||||
|
<Alert variant="err">{signingError}</Alert>
|
||||||
|
<Button variant="secondary" onClick={retrySigning}>
|
||||||
|
Повторить отправку на подпись
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<DecisionPanel
|
<DecisionPanel
|
||||||
@@ -248,7 +264,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
notify();
|
notify();
|
||||||
return sentBeforeDecision.current ? 'Документы отправлены на подписание в ЭДО.' : undefined;
|
return VACATION_EDO_SIGNING_ENABLED && sentBeforeDecision.current ?'Документы отправлены на подписание в ЭДО.' : undefined;
|
||||||
}}
|
}}
|
||||||
onDelegated={() => {
|
onDelegated={() => {
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
|
|||||||
@@ -79,6 +79,25 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
/** Плейсхолдер для поля выбора сотрудника. */
|
/** Плейсхолдер для поля выбора сотрудника. */
|
||||||
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
||||||
/** Заявку нужно подавать не позднее чем за столько дней до даты начала отпуска. */
|
/** Заявку нужно подавать не позднее чем за столько дней до даты начала отпуска. */
|
||||||
export const MIN_NOTICE_DAYS = 14;
|
export const MIN_NOTICE_DAYS = 7;
|
||||||
|
/** Ежегодный трудовой отпуск — не более стольких календарных дней в одной заявке (требование HR). */
|
||||||
|
export const MAX_DAYS = 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Примечание вверху формы и просмотра (см. shared/ui/LeaveNotice). Текст — от HR,
|
||||||
|
* дословно. «Одну календарную неделю» — это MIN_NOTICE_DAYS: поменяли срок — поправьте и текст.
|
||||||
|
* Правило про часть не менее 14 дней только показываем, а не проверяем: форма не знает,
|
||||||
|
* брал ли сотрудник уже часть отпуска.
|
||||||
|
*/
|
||||||
|
export const NOTICE_TITLE = 'Примечание';
|
||||||
|
export const NOTICE_ITEMS: string[] = [
|
||||||
|
'Согласно пункту 3 статьи 92 Трудового кодекса Республики Казахстан, оплачиваемый ежегодный трудовой отпуск ' +
|
||||||
|
'может быть разделен на части по соглашению сторон, но одна из частей должна составлять не менее 14 календарных дней.',
|
||||||
|
'Заявление на оплачиваемый ежегодный трудовой отпуск направляется не менее чем за одну календарную неделю ' +
|
||||||
|
'до начала отпуска.',
|
||||||
|
'После согласования заявления на оплачиваемый ежегодный трудовой отпуск по маршруту на портале сотрудник ' +
|
||||||
|
'должен подойти в отдел кадров для подписания заявления на бумажном носителе.'
|
||||||
|
];
|
||||||
|
|
||||||
/** Плейсхолдер для поля выбора замещающего. */
|
/** Плейсхолдер для поля выбора замещающего. */
|
||||||
export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О. замещающего…';
|
export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О. замещающего…';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormGrid, FormField, TextInput, PeopleField, OrganizationTable } from '../../../shared/ui';
|
import { FormGrid, FormField, TextInput, PeopleField, OrganizationTable } from '../../../shared/ui';
|
||||||
import { ProjectPicker } from '../../../shared/dictionary';
|
import { ProjectPicker } from '../../../shared/dictionary';
|
||||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model';
|
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS, MAX_DAYS } from '../model';
|
||||||
import { isoDatePlusDays } from '../../../shared/dateUtils';
|
import { isoDatePlusDays } from '../../../shared/dateUtils';
|
||||||
|
|
||||||
/** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */
|
/** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */
|
||||||
@@ -38,7 +38,15 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Количество дней" htmlFor="DaysCount" required>
|
<FormField label="Количество дней" htmlFor="DaysCount" required>
|
||||||
<TextInput id="DaysCount" type="number" value={data.DaysCount} onChange={(v) => set('DaysCount', v)} />
|
<TextInput
|
||||||
|
id="DaysCount"
|
||||||
|
type="number"
|
||||||
|
value={data.DaysCount}
|
||||||
|
onChange={(v) => set('DaysCount', v)}
|
||||||
|
min={1}
|
||||||
|
max={MAX_DAYS}
|
||||||
|
placeholder={`от 1 до ${MAX_DAYS}`}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Лицо, замещающее на время отсутствия" htmlFor="Substitute" full>
|
<FormField label="Лицо, замещающее на время отсутствия" htmlFor="Substitute" full>
|
||||||
<PeopleField
|
<PeopleField
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { IFieldIssue } from '../../shared/ui';
|
||||||
|
import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils';
|
||||||
|
import { IFormData, MAX_DAYS, MIN_NOTICE_DAYS } from './model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дата начала: обязательна и не раньше чем через MIN_NOTICE_DAYS дней. Календарь
|
||||||
|
* (min у input type=date, см. RequestSection) раннюю дату выбрать не даст,
|
||||||
|
* но её можно вписать с клавиатуры — поэтому проверяем и здесь.
|
||||||
|
*/
|
||||||
|
function startDateIssue(data: IFormData): IFieldIssue | undefined {
|
||||||
|
if (!data.StartDate) return { field: 'StartDate', label: 'Дата начала' };
|
||||||
|
|
||||||
|
const minStartDate = isoDatePlusDays(MIN_NOTICE_DAYS);
|
||||||
|
if (data.StartDate >= minStartDate) return undefined;
|
||||||
|
return {
|
||||||
|
field: 'StartDate',
|
||||||
|
label: 'Дата начала',
|
||||||
|
message: `заявление подают не позднее чем за ${MIN_NOTICE_DAYS} дней — не раньше ${formatIsoDate(minStartDate)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Количество дней: обязательно, целое, от 1 до MAX_DAYS. */
|
||||||
|
function daysCountIssue(data: IFormData): IFieldIssue | undefined {
|
||||||
|
if (!data.DaysCount.trim()) return { field: 'DaysCount', label: 'Количество дней' };
|
||||||
|
|
||||||
|
const days = Number(data.DaysCount);
|
||||||
|
if (Number.isInteger(days) && days >= 1 && days <= MAX_DAYS) return undefined;
|
||||||
|
return { field: 'DaysCount', label: 'Количество дней', message: `от 1 до ${MAX_DAYS} календарных дней` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Всё, что мешает сохранить или отправить заявку, в порядке формы (пусто — можно).
|
||||||
|
* field — ключ FormField (htmlFor): по нему поле подсвечивается и к нему
|
||||||
|
* прокручивается форма.
|
||||||
|
*/
|
||||||
|
export function formIssues(data: IFormData): IFieldIssue[] {
|
||||||
|
const issues: Array<IFieldIssue | undefined> = [
|
||||||
|
data.Employee ? undefined : { field: 'Employee', label: 'Сотрудник' },
|
||||||
|
startDateIssue(data),
|
||||||
|
daysCountIssue(data)
|
||||||
|
];
|
||||||
|
return issues.filter((issue): issue is IFieldIssue => issue !== undefined);
|
||||||
|
}
|
||||||
@@ -55,6 +55,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
|
|||||||
'hr-transfer': React.lazy(
|
'hr-transfer': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'form-employee-transfer' */ './EmployeeTransfer/EmployeeTransferForm')
|
() => import(/* webpackChunkName: 'form-employee-transfer' */ './EmployeeTransfer/EmployeeTransferForm')
|
||||||
),
|
),
|
||||||
|
'acc-payment-request': React.lazy(
|
||||||
|
() => import(/* webpackChunkName: 'form-payment-request' */ './PaymentRequest/PaymentRequestForm')
|
||||||
|
),
|
||||||
'wh-concrete-request': React.lazy(
|
'wh-concrete-request': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'form-concrete-request' */ './ConcreteRequest/ConcreteRequestForm')
|
() => import(/* webpackChunkName: 'form-concrete-request' */ './ConcreteRequest/ConcreteRequestForm')
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
|
|||||||
'hr-transfer': React.lazy(
|
'hr-transfer': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'view-employee-transfer' */ './EmployeeTransfer/EmployeeTransferView')
|
() => import(/* webpackChunkName: 'view-employee-transfer' */ './EmployeeTransfer/EmployeeTransferView')
|
||||||
),
|
),
|
||||||
|
'acc-payment-request': React.lazy(
|
||||||
|
() => import(/* webpackChunkName: 'view-payment-request' */ './PaymentRequest/PaymentRequestView')
|
||||||
|
),
|
||||||
'wh-concrete-request': React.lazy(
|
'wh-concrete-request': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'view-concrete-request' */ './ConcreteRequest/ConcreteRequestView')
|
() => import(/* webpackChunkName: 'view-concrete-request' */ './ConcreteRequest/ConcreteRequestView')
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ export interface IRequestFormProps {
|
|||||||
* Формы, не поддерживающие производные заявки, игнорируют.
|
* Формы, не поддерживающие производные заявки, игнорируют.
|
||||||
*/
|
*/
|
||||||
sourceId?: number;
|
sourceId?: number;
|
||||||
|
/**
|
||||||
|
* Партиция (серверный url списка), где лежит sourceId — заявки нумерованные,
|
||||||
|
* Id в разных партициях повторяются (см. shared/approval/listPartitions.ts).
|
||||||
|
* Не задан — исходная заявка ищется в текущей партиции.
|
||||||
|
*/
|
||||||
|
sourceListId?: string;
|
||||||
/**
|
/**
|
||||||
* Параметры внешней ссылки (Dataverse «Строка заявки» + справочник
|
* Параметры внешней ссылки (Dataverse «Строка заявки» + справочник
|
||||||
* номенклатуры). Использует только форма «СЗ на перемещение ТМЦ»: по
|
* номенклатуры). Использует только форма «СЗ на перемещение ТМЦ»: по
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/** JSON-схема ответа в формате, который принимает прокси Gemini (как в СТ). */
|
||||||
|
export interface IGeminiSchema {
|
||||||
|
type: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean';
|
||||||
|
properties?: Record<string, IGeminiSchema>;
|
||||||
|
items?: IGeminiSchema;
|
||||||
|
required?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /Proxy/Gemini { prompt, responseSchema } → объект, подогнанный под схему.
|
||||||
|
* Тот же вызов делает СТ (CtService.scorePaymentTerms); ответ прокси — уже
|
||||||
|
* разобранный JSON, без обёртки.
|
||||||
|
*/
|
||||||
|
export async function askGemini<T>(context: WebPartContext, prompt: string, responseSchema: IGeminiSchema): Promise<T> {
|
||||||
|
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||||
|
const response: HttpClientResponse = await client.post(
|
||||||
|
`${DICTIONARY_API_BASE_URL}/Proxy/Gemini`,
|
||||||
|
AadHttpClient.configurations.v1,
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt, responseSchema })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Proxy/Gemini: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as T & { error?: unknown };
|
||||||
|
// Пустой ответ модели прокси отдаёт как 200 с { error } — это сбой, а не данные.
|
||||||
|
if (body && typeof body.error === 'string') {
|
||||||
|
throw new Error(`Proxy/Gemini: ${body.error}`);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ApprovalStatus,
|
ApprovalStatus,
|
||||||
ICurrentListPartition,
|
ICurrentListPartition,
|
||||||
IApiResponse,
|
IApiResponse,
|
||||||
|
IBasisRequest,
|
||||||
IRequestType,
|
IRequestType,
|
||||||
IRequestTypeListSeries,
|
IRequestTypeListSeries,
|
||||||
IResolvedRoute,
|
IResolvedRoute,
|
||||||
@@ -147,6 +148,32 @@ export async function getRequestTypeIdByPageUrl(context: WebPartContext, pageUrl
|
|||||||
return found.id;
|
return found.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/basis-requests — заявки типа requestTypeId, поданные по основанию
|
||||||
|
* basisKey («ct:<Id>», «need:<oid>»), по всем партициям. Читаются на сервере от
|
||||||
|
* имени приложения: права на элементы заявок разорваны поштучно, и прямым
|
||||||
|
* чтением списка пользователь увидел бы только свои.
|
||||||
|
*/
|
||||||
|
export async function getBasisRequests(
|
||||||
|
context: WebPartContext,
|
||||||
|
requestTypeId: number,
|
||||||
|
basisKey: string
|
||||||
|
): Promise<IBasisRequest[]> {
|
||||||
|
return withNetworkRetry(async () => {
|
||||||
|
const c = await client(context);
|
||||||
|
const query = `requestTypeId=${requestTypeId}&basisKey=${encodeURIComponent(basisKey)}`;
|
||||||
|
const response: HttpClientResponse = await c.get(
|
||||||
|
`${getApprovalApiBaseUrl()}/api/basis-requests?${query}`,
|
||||||
|
AadHttpClient.configurations.v1
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`basis-requests: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const body: IApiResponse<IBasisRequest[]> = await response.json();
|
||||||
|
return body.data ?? [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/request-type-access/{requestTypeId}/emails — email'ы, кому в
|
// GET /api/request-type-access/{requestTypeId}/emails — email'ы, кому в
|
||||||
// админке approval-route выдан доступ видеть ВСЕ заявки этого типа (не только
|
// админке approval-route выдан доступ видеть ВСЕ заявки этого типа (не только
|
||||||
// свои). Тот же эндпоинт дёргает и Power Automate — здесь просто ещё один
|
// свои). Тот же эндпоинт дёргает и Power Automate — здесь просто ещё один
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type {
|
|||||||
IListPartition,
|
IListPartition,
|
||||||
ICurrentListPartition,
|
ICurrentListPartition,
|
||||||
IRequestType,
|
IRequestType,
|
||||||
|
IBasisRequest,
|
||||||
IRequestTypeListSeries,
|
IRequestTypeListSeries,
|
||||||
IResolvedRoute,
|
IResolvedRoute,
|
||||||
IResolvedRouteStep,
|
IResolvedRouteStep,
|
||||||
@@ -32,9 +33,10 @@ export {
|
|||||||
getListPartitions,
|
getListPartitions,
|
||||||
getCurrentListPartition,
|
getCurrentListPartition,
|
||||||
getRequestTypeAccessEmails,
|
getRequestTypeAccessEmails,
|
||||||
|
getBasisRequests,
|
||||||
resolveRoute
|
resolveRoute
|
||||||
} from './approvalRoute.service';
|
} from './approvalRoute.service';
|
||||||
export { hasRequestTypeAccess, accessibleAllRequestsTypes } from './requestTypeAccess';
|
export { hasRequestTypeAccess, accessibleAllRequestsTypes, emailsForType } from './requestTypeAccess';
|
||||||
export { useAccessibleAllRequestsFormKeys } from './useAccessibleAllRequestsFormKeys';
|
export { useAccessibleAllRequestsFormKeys } from './useAccessibleAllRequestsFormKeys';
|
||||||
export {
|
export {
|
||||||
initListPartitions,
|
initListPartitions,
|
||||||
@@ -59,7 +61,7 @@ export { approvalServiceFor } from './approvalServiceFactory';
|
|||||||
export { ApproverService } from './approverDecision.service';
|
export { ApproverService } from './approverDecision.service';
|
||||||
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
|
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
|
||||||
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
||||||
export { notifyActiveStep } from './notify.service';
|
export { notifyActiveStep, postToFlow } from './notify.service';
|
||||||
export type { INotifyTarget } from './notify.service';
|
export type { INotifyTarget } from './notify.service';
|
||||||
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
||||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||||
|
|||||||
@@ -126,3 +126,19 @@ export interface IResolvedRouteStep {
|
|||||||
*/
|
*/
|
||||||
positionCode?: string;
|
positionCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заявка, поданная по тому же основанию (СТ, потребность) — GET /api/basis-requests.
|
||||||
|
* Сервер читает их от имени приложения, поэтому видны все, а не только свои;
|
||||||
|
* canOpen — есть ли у текущего пользователя права открыть саму заявку.
|
||||||
|
*/
|
||||||
|
export interface IBasisRequest {
|
||||||
|
id: number;
|
||||||
|
/** Серверный относительный URL списка партиции, где лежит заявка. */
|
||||||
|
listId: string;
|
||||||
|
/** Номер заявки; пока не присвоен — пусто (сервер шлёт null). */
|
||||||
|
uniqueCode?: string;
|
||||||
|
status: string;
|
||||||
|
amount: number;
|
||||||
|
canOpen: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,14 +12,15 @@ export interface INotifyTarget {
|
|||||||
approvalListName: string; // ApproveListName (список согласования)
|
approvalListName: string; // ApproveListName (список согласования)
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST в поток оповещения (Entra-триггер) через AadHttpClient — обычный fetch → 401,
|
// POST в поток Power Automate (Entra-триггер) через AadHttpClient — обычный fetch → 401,
|
||||||
// AadHttpClient даёт токен Flow Service (и снимает вопрос CORS).
|
// AadHttpClient даёт токен Flow Service (и снимает вопрос CORS). Общий для всех
|
||||||
async function postNotify(context: WebPartContext, body: Record<string, unknown>): Promise<void> {
|
// потоков оповещения портала (согласование, чат заявки — shared/chat/chatNotify.service.ts).
|
||||||
if (!FLOW_NOTIFY_URL) {
|
export async function postToFlow(context: WebPartContext, flowUrl: string, body: object): Promise<void> {
|
||||||
|
if (!flowUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const client = await context.aadHttpClientFactory.getClient(FLOW_RESOURCE);
|
const client = await context.aadHttpClientFactory.getClient(FLOW_RESOURCE);
|
||||||
const res = await client.post(FLOW_NOTIFY_URL, AadHttpClient.configurations.v1, {
|
const res = await client.post(flowUrl, AadHttpClient.configurations.v1, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
@@ -68,7 +69,7 @@ async function notifyRow(
|
|||||||
if (!(await isNotifyAllowed(approval, row.approverId))) {
|
if (!(await isNotifyAllowed(approval, row.approverId))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await postNotify(context, {
|
await postToFlow(context, FLOW_NOTIFY_URL, {
|
||||||
RequestTypeId: target.requestTypeId,
|
RequestTypeId: target.requestTypeId,
|
||||||
RowID: row.id,
|
RowID: row.id,
|
||||||
RequestID: requestId,
|
RequestID: requestId,
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import { effectiveUserEmail } from '../impersonation';
|
|||||||
// кешируется, чтобы временный сбой сети не «залип» на весь сеанс.
|
// кешируется, чтобы временный сбой сети не «залип» на весь сеанс.
|
||||||
const cache = new Map<number, Promise<string[]>>();
|
const cache = new Map<number, Promise<string[]>>();
|
||||||
|
|
||||||
function emailsForType(context: WebPartContext, requestTypeId: number): Promise<string[]> {
|
/**
|
||||||
|
* Кому выдан доступ видеть ВСЕ заявки этого типа (раздел «Все заявки»). Кешируется
|
||||||
|
* на сеанс. Нужен не только для проверки доступа: эти люди могут писать в чат
|
||||||
|
* любой заявки типа, значит, их можно и отмечать в нём (см. shared/chat).
|
||||||
|
*/
|
||||||
|
export function emailsForType(context: WebPartContext, requestTypeId: number): Promise<string[]> {
|
||||||
let pending = cache.get(requestTypeId);
|
let pending = cache.get(requestTypeId);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = getRequestTypeAccessEmails(context, requestTypeId).catch((e) => {
|
pending = getRequestTypeAccessEmails(context, requestTypeId).catch((e) => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user