feat: changed get time logic to approvers

This commit is contained in:
Тимур Городецкий
2026-08-13 10:01:08 +05:00
parent 2e6e138e8d
commit 8578653c16
55 changed files with 2249 additions and 46 deletions
@@ -130,7 +130,7 @@ export class ApprovalListService {
if (queuedRows.length === 0) return;
const minOrder = Math.min(...queuedRows.map((r) => r.order));
await Promise.all(
queuedRows.filter((r) => r.order === minOrder).map((r) => this.setStatus(r.id, activeStatus))
queuedRows.filter((r) => r.order === minOrder).map((r) => this.setActive(r.id, activeStatus))
);
};
@@ -146,6 +146,17 @@ export class ApprovalListService {
await this.list().items.getById(rowId).update({ [FIELDS.status]: status });
}
// Активация строки: статус + отметка времени, КОГДА строка стала текущей
// («получил на согласование/исполнение»). Отличается от setStatus именно
// датой — использовать только на переходе в активный статус, не для отката в
// «Ждёт очереди». Дату решения позже перезапишет setDecision. См.
// activateNextQueued (единая точка активации: отправка, одобрение, делегат).
private async setActive(rowId: number, status: string): Promise<void> {
await this.list()
.items.getById(rowId)
.update({ [FIELDS.status]: status, [FIELDS.date]: new Date().toISOString() });
}
// Добавляет одну строку согласования с заданным Order (для делегата).
// delegatedById — Id пользователя, который выполнил переназначение (не строки
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту
@@ -262,10 +273,14 @@ export class ApprovalListService {
}
// Должность пользователя из профиля SharePoint (SPS-JobTitle). '' если недоступно.
// Принимает и клеймовый логин (i:0#.f|membership|…), и голый e-mail: пикер
// сотрудника отдаёт именно e-mail (см. pickedLogin), а getPropertiesFor ждёт
// account name — приводим e-mail к клеймовому виду так же, как ensure().
public async getJobTitle(loginName: string): Promise<string> {
if (!loginName) return '';
const account = loginName.indexOf('|') >= 0 ? loginName : `i:0#.f|membership|${loginName}`;
try {
const props = (await getSP().profiles.getPropertiesFor(loginName)) as {
const props = (await getSP().profiles.getPropertiesFor(account)) as {
UserProfileProperties?: unknown;
};
const raw = props.UserProfileProperties;
@@ -326,11 +341,17 @@ export class ApprovalListService {
return getSP().web.ensureUser(login);
}
// Формат «дд.мм.гггг чч:мм» в казахстанском поясе (UTC+5) — жёстко, а не по
// поясу браузера: время должно совпадать у всех участников. В SharePoint дата
// хранится в UTC (setDecision/setActive пишут toISOString); Казахстан без
// перехода на летнее время, поэтому достаточно прибавить 5 часов и читать
// UTC-компоненты (getUTC*), не завися от локального пояса среды.
private formatDate(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
return `${dd}.${mm}.${d.getFullYear()}`;
const KZ_OFFSET_MS = 5 * 60 * 60 * 1000;
const d = new Date(new Date(iso).getTime() + KZ_OFFSET_MS);
const p = (n: number): string => (n < 10 ? `0${n}` : `${n}`);
const date = `${p(d.getUTCDate())}.${p(d.getUTCMonth() + 1)}.${d.getUTCFullYear()}`;
return `${date} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
}
}
@@ -41,12 +41,16 @@ export async function resolveRoute(
userEmail: string,
status: ApprovalStatus = 'Approval',
departmentId: string | null = null,
fieldValues: IResolveFieldValue[] = []
fieldValues: IResolveFieldValue[] = [],
projectId: string | null = null
): Promise<IResolvedRoute> {
const c = await client(context);
const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`;
// Эндпоинт сменился с GET (query) на POST (JSON-тело).
// projectId (uuid проекта) — для форм, где согласующий зависит от проекта
// (напр. «руководитель проекта-источника» в СЗ на перемещение ТМЦ): бэкенд по
// нему подставляет исполнителя роли. Формы без привязки к проекту шлют null.
const response: HttpClientResponse = await c.post(url, AadHttpClient.configurations.v1, {
headers: {
'Content-Type': 'application/json',
@@ -55,7 +59,7 @@ export async function resolveRoute(
body: JSON.stringify({
requestTypeId,
userEmail,
projectId: null,
projectId: projectId || null,
status,
fieldValues,
departmentId: departmentId || null
@@ -16,10 +16,11 @@ export async function getPlannedMatrix(
approval: ApprovalListService,
status: ApprovalStatus = 'Approval',
departmentId?: string | null,
fieldValues?: IResolveFieldValue[]
fieldValues?: IResolveFieldValue[],
projectId?: string | null
): Promise<IMatrixRow[]> {
if (!userEmail) return [];
const route = await resolveRoute(context, requestTypeId, userEmail, status, departmentId, fieldValues);
const route = await resolveRoute(context, requestTypeId, userEmail, status, departmentId, fieldValues, projectId);
const phase = status === 'Execution' ? APPROVAL_PHASE.execution : APPROVAL_PHASE.approval;
const ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo);
return Promise.all(
@@ -18,6 +18,15 @@ export interface IApprovalMatrixProps {
emptyText?: string;
/** Плейсхолдер пикера добавления (только если editor.editable). */
pickerPlaceholder?: string;
/**
* Режим ожидания: маршрут ещё нельзя построить (не выбрана зависимость,
* напр. проект). Показываем только заголовок и информационную заглушку —
* без обращения к API, таблицы и пикера. Состояние loading/error/rows
* редактора при этом игнорируется.
*/
waiting?: boolean;
/** Текст заглушки в режиме waiting. */
waitingText?: string;
}
/** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */
@@ -28,6 +37,8 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
roleLabel = 'Согласующий',
emptyText = 'Согласующие не заданы.',
pickerPlaceholder = 'Добавить согласующего: начните вводить ФИО…',
waiting = false,
waitingText = '',
}) => {
const { rows, loading, error, editable, blocks } = editor;
@@ -86,12 +97,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
return (
<div className={styles.matrixWrap}>
<div className={styles.matrixTitle}>{title}</div>
{loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>}
{!loading && error && <div className={styles.matrixNote}>Маршрут недоступен: {error}</div>}
{!loading && !error && rows.length === 0 && (
{waiting && <div className={styles.matrixNote}>{waitingText}</div>}
{!waiting && loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>}
{!waiting && !loading && error && <div className={styles.matrixNote}>Маршрут недоступен: {error}</div>}
{!waiting && !loading && !error && rows.length === 0 && (
<div className={styles.matrixNote}>{emptyText}</div>
)}
{!loading && !error && rows.length > 0 && (
{!waiting && !loading && !error && rows.length > 0 && (
<div className={styles.matrixScroll}>
<table className={styles.matrix}>
<thead>
@@ -193,7 +205,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
</table>
</div>
)}
{editable && !loading && (
{editable && !waiting && !loading && (
<div className={styles.matrixPicker}>
<UserPicker search={search} onPick={addApprover} placeholder={pickerPlaceholder} />
</div>
@@ -0,0 +1,28 @@
import { getSP } from './pnp/pnpjs';
/** Инициатор заявки — из системного поля «Автор» (Created By) элемента списка. */
export interface IInitiator {
name: string;
email: string;
}
/**
* Прочитать инициатора (создателя) элемента списка по его Id.
* Источник — системное поле Author (Created By), заполняется SharePoint
* автоматически при создании, работает для любой формы без доп. колонок.
* Возвращает undefined, если элемент/автор недоступен (не роняем шапку).
*/
export async function getCreatedBy(listTitle: string, itemId: number): Promise<IInitiator | undefined> {
try {
const item = (await getSP()
.web.lists.getByTitle(listTitle)
.items.getById(itemId)
.select('Author/Title', 'Author/EMail')
.expand('Author')()) as { Author?: { Title?: string; EMail?: string } };
const author = item.Author;
if (!author) return undefined;
return { name: author.Title || '', email: author.EMail || '' };
} catch {
return undefined;
}
}
@@ -2,3 +2,7 @@ export { getDepartments } from './departments.service';
export type { IDepartment } from './models/IDepartment';
export { DepartmentPicker } from './ui/DepartmentPicker';
export type { IDepartmentPickerProps } from './ui/DepartmentPicker';
export { getProjects } from './projects.service';
export type { IProject } from './models/IProject';
export { ProjectPicker } from './ui/ProjectPicker';
export type { IProjectPickerProps } from './ui/ProjectPicker';
@@ -0,0 +1,31 @@
// GET https://api.tnsu.kz/Project (пагинация ?page&pageSize&includeDeleted) →
// { items: IProject[] }. Ниже — только поля, которые реально используем в портале.
// В ответе есть и другие (latitude/longitude, contractType, managerHSE, security,
// longName, id1c и т.д.) — при необходимости добавить сюда.
export interface IProject {
/** Числовой Id проекта. */
id: number;
/** GUID проекта — стабильный идентификатор, храним его как SourceProjectId. */
oid: string;
/** Наименование проекта (то, что видит пользователь). */
name: string;
/** Код проекта (напр. «2026»). */
code: string;
/** Адрес / регион объекта. */
address: string;
/** Заказчик. */
customerName: string;
/** Руководитель проекта — email. Пригодится для маршрута согласования. */
projectManager: string;
/** Планировщик — email. */
planner: string;
/** Статус проекта (напр. «Текущий»). */
status: string;
/** Признак удалённого проекта. */
deleted: boolean;
}
/** Обёртка ответа /Project. */
export interface IProjectListResponse {
items: IProject[];
}
@@ -0,0 +1,44 @@
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';
import { IProject, IProjectListResponse } from './models/IProject';
// GET /Project — справочник проектов. Ответ обёрнут в { items: [...] } и
// ПАГИНИРОВАН, поэтому тянем ВСЕ страницы (иначе проекты за пределами первой
// страницы не находятся в поиске пикера). Авторизация та же (AadHttpClient +
// APPROVAL_API_CLIENT_ID, SSO текущего пользователя).
const PAGE_SIZE = 500;
const MAX_PAGES = 50; // страховка от бесконечного цикла (до 25 000 проектов)
async function fetchProjects(context: WebPartContext): Promise<IProject[]> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const all: IProject[] = [];
for (let page = 1; page <= MAX_PAGES; page++) {
const response: HttpClientResponse = await client.get(
`${DICTIONARY_API_BASE_URL}/Project?page=${page}&pageSize=${PAGE_SIZE}&includeDeleted=false`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`Project (page ${page}): ${response.status} ${response.statusText}`);
}
const body: IProjectListResponse = await response.json();
const items = body.items ?? [];
all.push(...items);
// Последняя страница — вернулось меньше запрошенного размера.
if (items.length < PAGE_SIZE) break;
}
return all;
}
// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же
// промис повторным вызовам (как в departments.service).
let cache: Promise<IProject[]> | undefined;
export function getProjects(context: WebPartContext): Promise<IProject[]> {
if (!cache) {
cache = fetchProjects(context).catch((e) => {
cache = undefined;
throw e;
});
}
return cache;
}
@@ -65,7 +65,13 @@ export const DepartmentPicker: React.FC<IDepartmentPickerProps> = ({
value={value}
placeholder={placeholder}
readOnly={effectiveReadOnly}
onChange={(e) => onChange(e.target.value, '')}
// setOpen(true) и при вводе, а не только по фокусу: после выбора список
// закрывается (handlePick), но фокус остаётся на поле — без этого
// повторный ввод в том же фокусе не открывал бы выпадашку заново.
onChange={(e) => {
onChange(e.target.value, '');
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
@@ -0,0 +1,90 @@
@import '../../../../../styles/design-tokens';
.wrap {
position: relative;
}
.input {
box-sizing: border-box;
width: 100%;
// справа место под крестик-очистку
padding: 11px 36px 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
@include readonly-field(':read-only');
&::placeholder {
color: $gray-400;
}
}
.clear {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-400;
font-size: 18px;
line-height: 1;
cursor: pointer;
transition: .15s;
&:hover {
background: $gray-100;
color: $gray-700;
}
}
.list {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin-top: $sp-1;
max-height: 260px;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-sm;
box-shadow: $sh-2;
}
.item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 9px 12px;
border: 0;
background: transparent;
text-align: left;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
cursor: pointer;
&:hover {
background: $navy-50;
}
}
.meta {
font-size: $fs-caption;
color: $gray-500;
}
@@ -0,0 +1,126 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './ProjectPicker.module.scss';
import { useFormReadOnly } from '../../ui/FormMode';
import { getProjects } from '../projects.service';
import { IProject } from '../models/IProject';
export interface IProjectPickerProps {
context: WebPartContext;
id?: string;
/** Текущее название проекта (то, что видно в поле). */
value: string;
/**
* name — введённый/выбранный текст; id — oid проекта ('' если текст введён
* вручную и не выбран из списка); project — выбранная запись (для снимка
* руководителя/планировщика на будущий маршрут). При ручном вводе project не передаётся.
*/
onChange: (name: string, id: string, project?: IProject) => void;
placeholder?: string;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
const MAX_SUGGESTIONS = 30;
/** Поле выбора проекта со справочником (GET /Project): ввод текста фильтрует список, выбор проставляет oid. */
export const ProjectPicker: React.FC<IProjectPickerProps> = ({
context,
id,
value,
onChange,
placeholder,
readOnly
}) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [projects, setProjects] = React.useState<IProject[]>([]);
const [open, setOpen] = React.useState<boolean>(false);
React.useEffect(() => {
let alive = true;
getProjects(context)
.then((list) => {
if (alive) setProjects(list);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [context]);
const suggestions = React.useMemo(() => {
const query = value.trim().toLowerCase();
if (!query) return [];
return projects
.filter(
(p) =>
p.name.toLowerCase().indexOf(query) !== -1 ||
(p.code && p.code.toLowerCase().indexOf(query) !== -1)
)
.slice(0, MAX_SUGGESTIONS);
}, [projects, value]);
const handlePick = (project: IProject): void => {
onChange(project.name, project.oid, project);
setOpen(false);
};
return (
<div className={styles.wrap}>
<input
id={id}
className={styles.input}
value={value}
placeholder={placeholder}
readOnly={effectiveReadOnly}
// setOpen(true) и при вводе, а не только по фокусу: после выбора список
// закрывается (handlePick), но фокус остаётся на поле — без этого
// повторный ввод в том же фокусе не открывал бы выпадашку заново.
onChange={(e) => {
onChange(e.target.value, '');
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
{/* Быстрая очистка: сбрасываем и текст, и oid проекта. preventDefault на
mousedown — чтобы клик не «съел» фокус до onClick (как в списке ниже). */}
{!effectiveReadOnly && value.length > 0 && (
<button
type="button"
className={styles.clear}
aria-label="Очистить"
title="Очистить"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange('', '');
setOpen(false);
}}
>
×
</button>
)}
{!effectiveReadOnly && open && suggestions.length > 0 && (
<div className={styles.list}>
{suggestions.map((p) => (
<button
key={p.oid}
type="button"
className={styles.item}
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick, и список закрывается до выбора.
onMouseDown={(e) => e.preventDefault()}
onClick={() => handlePick(p)}
>
{p.name}
<span className={styles.meta}>
{[p.code, p.customerName].filter(Boolean).join(' · ')}
</span>
</button>
))}
</div>
)}
</div>
);
};
@@ -1,11 +1,13 @@
/**
* Уникальный код заявки: {requestTypeId}-{YY}{MM}-{itemId}.
* requestTypeId — id типа заявки во внешнем approval-route API (GET /api/request-types).
* YY/MM — год и месяц подачи, itemId — Id элемента в списке данных. Пример: 1-2608-12.
* Уникальный код заявки: {prefix}-{YY}{MM}-{itemId}.
* prefix — по умолчанию числовой id типа заявки (approval-route API,
* GET /api/request-types), но может быть и строкой-меткой формы (напр. «1С»
* для заявок в отдел 1С). YY/MM — год и месяц подачи, itemId — Id элемента.
* Примеры: 1-2608-12, 1С-2608-12.
*/
export function buildRequestUniqueCode(requestTypeId: number, itemId: number, submittedAt: Date): string {
export function buildRequestUniqueCode(prefix: number | string, itemId: number, submittedAt: Date): string {
const yy = String(submittedAt.getFullYear()).slice(-2);
const month = submittedAt.getMonth() + 1;
const mm = month < 10 ? `0${month}` : String(month);
return `${requestTypeId}-${yy}${mm}-${itemId}`;
return `${prefix}-${yy}${mm}-${itemId}`;
}
@@ -0,0 +1,98 @@
@import '../../../../../styles/design-tokens';
// Поверх всего, включая SPFx-хром вебпарта.
.overlay {
position: fixed;
inset: 0;
z-index: 999997;
display: flex;
align-items: center;
justify-content: center;
padding: $sp-6;
background: rgba($navy-950, .55);
font-family: $font-sans;
// Плавное появление подложки.
animation: overlayIn .14s ease-out;
}
.card {
box-sizing: border-box;
width: 100%;
max-width: 440px;
padding: $sp-5 $sp-5 $sp-4;
border-radius: $r-lg;
background: $white;
box-shadow: $sh-modal;
animation: cardIn .16s ease-out;
}
.title {
font-family: $font-serif;
font-size: $fs-form-title;
font-weight: 600;
color: $navy-950;
}
.message {
margin-top: $sp-2;
font-size: $fs-sm;
line-height: 1.5;
color: $gray-700;
}
.actions {
display: flex;
justify-content: flex-end;
gap: $sp-2;
margin-top: $sp-5;
}
.cancelBtn {
padding: 9px 18px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
color: $navy-800;
font-family: $font-sans;
font-size: $fs-sm;
cursor: pointer;
transition: .15s;
&:hover {
border-color: $gold-400;
}
}
.confirmBtn {
@include btn-accent;
padding: 9px 18px;
border-radius: $r-sm;
font-size: $fs-sm;
cursor: pointer;
}
.confirmDanger {
padding: 9px 18px;
border: 0;
border-radius: $r-sm;
background: $red-500;
color: $white;
font-family: $font-sans;
font-size: $fs-sm;
cursor: pointer;
transition: .15s;
&:hover {
filter: brightness(.94);
}
}
@keyframes overlayIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(8px) scale(.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@@ -0,0 +1,82 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import styles from './ConfirmDialog.module.scss';
import { IConfirmOptions, registerConfirmHost } from './confirmModal';
interface IConfirmDialogProps extends IConfirmOptions {
onConfirm: () => void;
onCancel: () => void;
}
/** Само окно подтверждения. Рендерится в document.body поверх всего (portal). */
const ConfirmDialog: React.FC<IConfirmDialogProps> = ({
title,
message,
confirmText = 'Продолжить',
cancelText = 'Отмена',
danger,
onConfirm,
onCancel,
}) => {
// Esc — отмена. Слушаем на всё окно, пока модалка открыта.
React.useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onCancel();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onCancel]);
return createPortal(
// Клик по фону = отмена; клик внутри карточки не всплывает до фона.
<div className={styles.overlay} onMouseDown={onCancel}>
<div
className={styles.card}
role="dialog"
aria-modal="true"
onMouseDown={(e) => e.stopPropagation()}
>
{title && <div className={styles.title}>{title}</div>}
<div className={styles.message}>{message}</div>
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onCancel}>
{cancelText}
</button>
<button
type="button"
className={danger ? styles.confirmDanger : styles.confirmBtn}
onClick={onConfirm}
autoFocus
>
{confirmText}
</button>
</div>
</div>
</div>,
document.body
);
};
/**
* Хост модалки: монтируется ОДИН раз в портале. Слушает императивные вызовы
* confirmModal(...) и показывает окно; ответ пользователя резолвит их промис.
*/
export const ConfirmHost: React.FC = () => {
const [state, setState] = React.useState<{
opts: IConfirmOptions;
resolve: (ok: boolean) => void;
} | undefined>(undefined);
React.useEffect(() => registerConfirmHost((opts, resolve) => setState({ opts, resolve })), []);
if (!state) return null;
const close = (ok: boolean): void => {
state.resolve(ok);
setState(undefined);
};
return (
<ConfirmDialog {...state.opts} onConfirm={() => close(true)} onCancel={() => close(false)} />
);
};
@@ -0,0 +1,44 @@
// Императивный вызов модалки подтверждения из любого места (в т.ч. из обычных
// модулей вроде shared/unsavedChanges.ts, где нет React-контекста).
//
// Модалка живёт в React-дереве (ConfirmHost, монтируется один раз в
// RequestPortal). Здесь — только «мостик»: confirmModal(...) отдаёт промис,
// который резолвится, когда пользователь нажмёт «Продолжить»/«Отмена».
export interface IConfirmOptions {
/** Заголовок окна. */
title?: string;
/** Основной текст-вопрос. */
message: string;
/** Подпись подтверждающей кнопки. По умолчанию — «Продолжить». */
confirmText?: string;
/** Подпись кнопки отмены. По умолчанию — «Отмена». */
cancelText?: string;
/** Опасное действие (потеря данных) — подтверждающая кнопка красная. */
danger?: boolean;
}
type ConfirmHandler = (opts: IConfirmOptions, resolve: (ok: boolean) => void) => void;
// Единственный подписчик — смонтированный ConfirmHost. Больше одного окна
// подтверждения одновременно нам не нужно.
let handler: ConfirmHandler | undefined;
export function registerConfirmHost(fn: ConfirmHandler): () => void {
handler = fn;
return () => {
if (handler === fn) handler = undefined;
};
}
/**
* Показать модальное подтверждение. Промис резолвится в true («Продолжить») или
* false («Отмена»/Esc/клик по фону). Если хост почему-то не смонтирован —
* падаем на нативный confirm, чтобы не потерять защиту от потери данных.
*/
export function confirmModal(opts: IConfirmOptions): Promise<boolean> {
if (!handler) {
return Promise.resolve(window.confirm(opts.message));
}
return new Promise<boolean>((resolve) => handler!(opts, resolve));
}
@@ -6,17 +6,20 @@ interface IFormCardProps {
subtitle?: string;
/** Контент в правом верхнем углу шапки (например, номер заявки). */
headerRight?: React.ReactNode;
/** Строка под подзаголовком (например, инициатор заявки). */
belowSubtitle?: React.ReactNode;
children: React.ReactNode;
}
/** Белая карточка формы: заголовок (Lora) + золотой hairline + содержимое. */
export const FormCard: React.FC<IFormCardProps> = ({ title, subtitle, headerRight, children }) => {
export const FormCard: React.FC<IFormCardProps> = ({ title, subtitle, headerRight, belowSubtitle, children }) => {
return (
<div className={styles.card}>
<div className={styles.header}>
<div>
<div className={styles.title}>{title}</div>
{subtitle && <div className={styles.subtitle}>{subtitle}</div>}
{belowSubtitle}
</div>
{headerRight && <div className={styles.headerRight}>{headerRight}</div>}
</div>
@@ -0,0 +1,34 @@
@import '../../../../../styles/design-tokens';
.line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 4px;
font-size: 12px;
color: $gray-500;
}
.label {
color: $gray-400;
}
.name {
color: $gray-700;
font-weight: 500;
}
.sep {
color: $gray-400;
}
.email {
color: $navy-700;
text-decoration: none;
&:hover {
color: $gold-600;
text-decoration: underline;
}
}
@@ -0,0 +1,51 @@
import * as React from 'react';
import styles from './InitiatorLine.module.scss';
import { getCreatedBy, IInitiator } from '../../createdBy';
export interface IInitiatorLineProps {
/** Заголовок списка данных заявки. */
listTitle: string;
/** Id элемента заявки. undefined — заявка ещё не сохранена (ничего не показываем). */
itemId?: number;
}
/**
* Строка «Инициатор: Ф.И.О. · email» под номером заявки. Сама подгружает
* создателя (Created By) по listTitle+itemId. Пока не сохранено / нет данных — не рендерится.
*/
export const InitiatorLine: React.FC<IInitiatorLineProps> = ({ listTitle, itemId }) => {
const [initiator, setInitiator] = React.useState<IInitiator | undefined>(undefined);
React.useEffect(() => {
if (!itemId) {
setInitiator(undefined);
return;
}
let alive = true;
getCreatedBy(listTitle, itemId)
.then((info) => {
if (alive) setInitiator(info);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [listTitle, itemId]);
if (!initiator || (!initiator.name && !initiator.email)) {
return null;
}
return (
<div className={styles.line}>
<span className={styles.label}>Инициатор:</span>
{initiator.name && <span className={styles.name}>{initiator.name}</span>}
{initiator.name && initiator.email && <span className={styles.sep}>·</span>}
{initiator.email && (
<a className={styles.email} href={`mailto:${initiator.email}`}>
{initiator.email}
</a>
)}
</div>
);
};
@@ -0,0 +1,18 @@
/**
* Строка табличной части (позиция ТМЦ). Все поля — строки: значения приходят из
* текстовых полей ввода и вставки из Excel; количество не считаем арифметически,
* поэтому храним как есть. Массив таких строк сериализуется в JSON (колонка ItemsJson).
*/
export interface ILineItem {
/** Наименование. */
name: string;
/** Инвентарный номер. */
invNumber: string;
/** Единица измерения (по умолчанию «шт»). */
unit: string;
/** Количество. */
qty: string;
}
/** Пустая строка позиции (для «Добавить строку»). Ед.изм по умолчанию «шт». */
export const EMPTY_LINE_ITEM: ILineItem = { name: '', invNumber: '', unit: 'шт', qty: '' };
@@ -0,0 +1,166 @@
@import '../../../../../styles/design-tokens';
.tableWrap {
width: 100%;
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
th,
td {
border: 1px solid $gray-200;
padding: 0;
text-align: left;
vertical-align: middle;
}
th {
padding: 7px 9px;
background: $navy-50;
font-weight: 600;
font-size: $fs-caption;
color: $gray-700;
white-space: nowrap;
}
td {
padding: 4px 6px;
}
}
// Ширины служебных колонок.
.num {
width: 44px;
text-align: center;
}
.inv {
width: 150px;
}
.unit {
width: 78px;
text-align: center;
}
.qty {
width: 84px;
text-align: right;
}
.act {
width: 40px;
text-align: center;
}
// Ячейки без рамки — рамку рисует td, поле занимает всю ячейку.
.cell {
box-sizing: border-box;
width: 100%;
padding: 6px 7px;
border: 1px solid transparent;
border-radius: $r-sm;
background: transparent;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
&:focus {
outline: none;
border-color: $gold-400;
background: $white;
}
}
.cellCenter {
text-align: center;
}
.cellRight {
text-align: right;
}
.remove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 5px;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-500;
cursor: pointer;
transition: .18s;
&:hover {
background: $red-50;
color: $red-500;
}
}
.empty {
padding: 14px !important;
text-align: center;
color: $gray-500;
}
.toolbar {
display: flex;
align-items: center;
gap: $sp-2;
margin-top: $sp-3;
}
.count {
font-size: $fs-caption;
color: $gray-500;
}
.paste {
margin-top: $sp-3;
padding: $sp-3;
border: 1px solid $gray-200;
border-radius: $r-md;
background: $gray-50;
}
.pasteHint {
margin: 0 0 $sp-2;
font-size: $fs-caption;
color: $gray-500;
}
.pasteArea {
box-sizing: border-box;
width: 100%;
padding: 9px 11px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: 'Consolas', 'Courier New', monospace;
font-size: $fs-caption;
color: $gray-900;
resize: vertical;
@include input-focus-gold;
}
.pasteActions {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
margin-top: $sp-2;
flex-wrap: wrap;
}
.pasteButtons {
display: flex;
gap: $sp-2;
}
@@ -0,0 +1,207 @@
import * as React from 'react';
import styles from './ItemsTable.module.scss';
import { Button } from '../Button/Button';
import { Icon } from '../Icon/Icon';
import { useFormReadOnly } from '../FormMode';
import { ILineItem, EMPTY_LINE_ITEM } from './ILineItem';
import { parseExcelRows } from './parseExcelRows';
export interface IItemsTableProps {
value: ILineItem[];
onChange: (rows: ILineItem[]) => void;
/** Только чтение (просмотр/печать). Если не задан — берётся из FormMode. */
readOnly?: boolean;
}
/**
* Редактируемая табличная часть (позиции ТМЦ): № (авто) / Наименование /
* Инв. номер / Ед.изм / Кол-во. Ручной ввод — основной способ (добавить/удалить/
* править строки), плюс массовая вставка из Excel (кнопка → textarea → превью).
*/
export const ItemsTable: React.FC<IItemsTableProps> = ({ value, onChange, readOnly }) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [pasteOpen, setPasteOpen] = React.useState<boolean>(false);
const [pasteText, setPasteText] = React.useState<string>('');
const setCell = (index: number, key: keyof ILineItem, cellValue: string): void => {
const next = value.map((row, i) => (i === index ? { ...row, [key]: cellValue } : row));
onChange(next);
};
const addRow = (): void => onChange([...value, { ...EMPTY_LINE_ITEM }]);
const removeRow = (index: number): void => onChange(value.filter((_, i) => i !== index));
const parsed = React.useMemo(() => parseExcelRows(pasteText), [pasteText]);
const applyPaste = (mode: 'append' | 'replace'): void => {
onChange(mode === 'append' ? [...value, ...parsed] : parsed);
setPasteText('');
setPasteOpen(false);
};
// Просмотр/печать — статичная таблица без полей ввода.
if (effectiveReadOnly) {
return (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.num}>№</th>
<th>Наименование</th>
<th className={styles.inv}>Инв. номер</th>
<th className={styles.unit}>Ед.изм</th>
<th className={styles.qty}>Кол-во</th>
</tr>
</thead>
<tbody>
{value.length === 0 ? (
<tr>
<td colSpan={5} className={styles.empty}>Позиции не добавлены</td>
</tr>
) : (
value.map((row, i) => (
<tr key={i}>
<td className={styles.num}>{i + 1}</td>
<td>{row.name}</td>
<td>{row.invNumber}</td>
<td className={styles.unit}>{row.unit}</td>
<td className={styles.qty}>{row.qty}</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
}
return (
<div>
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.num}>№</th>
<th>Наименование</th>
<th className={styles.inv}>Инв. номер</th>
<th className={styles.unit}>Ед.изм</th>
<th className={styles.qty}>Кол-во</th>
<th className={styles.act} aria-label="Действия" />
</tr>
</thead>
<tbody>
{value.length === 0 ? (
<tr>
<td colSpan={6} className={styles.empty}>
Нет позиций. Добавьте строку или вставьте из Excel.
</td>
</tr>
) : (
value.map((row, i) => (
<tr key={i}>
<td className={styles.num}>{i + 1}</td>
<td>
<input
className={styles.cell}
value={row.name}
onChange={(e) => setCell(i, 'name', e.target.value)}
/>
</td>
<td>
<input
className={styles.cell}
value={row.invNumber}
onChange={(e) => setCell(i, 'invNumber', e.target.value)}
/>
</td>
<td>
<input
className={`${styles.cell} ${styles.cellCenter}`}
value={row.unit}
onChange={(e) => setCell(i, 'unit', e.target.value)}
/>
</td>
<td>
<input
className={`${styles.cell} ${styles.cellRight}`}
value={row.qty}
inputMode="decimal"
onChange={(e) => setCell(i, 'qty', e.target.value)}
/>
</td>
<td className={styles.act}>
<button
type="button"
className={styles.remove}
title="Удалить строку"
onClick={() => removeRow(i)}
>
<Icon name="trash" size="sm" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className={styles.toolbar}>
<Button variant="secondary" size="sm" onClick={addRow}>
<Icon name="plus" size="sm" />
Добавить строку
</Button>
<Button variant="ghost" size="sm" onClick={() => setPasteOpen((v) => !v)}>
<Icon name="upload" size="sm" />
Вставить из Excel
</Button>
{value.length > 0 && <span className={styles.count}>Позиций: {value.length}</span>}
</div>
{pasteOpen && (
<div className={styles.paste}>
<p className={styles.pasteHint}>
Скопируйте строки из Excel (колонки Наименование, Инв. номер, Ед.изм, Кол-во —
с «№» или без) и вставьте сюда (Ctrl+V).
</p>
<textarea
className={styles.pasteArea}
value={pasteText}
placeholder="Вставьте ячейки из Excel…"
onChange={(e) => setPasteText(e.target.value)}
rows={6}
/>
<div className={styles.pasteActions}>
<span className={styles.count}>
{parsed.length > 0 ? `Распознано строк: ${parsed.length}` : 'Пока ничего не распознано'}
</span>
<div className={styles.pasteButtons}>
<Button
variant="secondary"
size="sm"
disabled={parsed.length === 0}
onClick={() => applyPaste('append')}
>
Добавить {parsed.length > 0 ? `(${parsed.length})` : ''}
</Button>
<Button
variant="ghost"
size="sm"
disabled={parsed.length === 0}
onClick={() => applyPaste('replace')}
>
Заменить все
</Button>
<Button variant="ghost" size="sm" onClick={() => { setPasteText(''); setPasteOpen(false); }}>
Отмена
</Button>
</div>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,100 @@
import { ILineItem, EMPTY_LINE_ITEM } from './ILineItem';
/**
* Разбор вставленного из Excel/таблицы текста (TSV) в строки позиций.
*
* Excel кладёт в буфер обмена текст с табами: колонки разделены '\t', строки —
* переводом строки. Ячейки, содержащие таб/перенос/кавычку, оборачиваются в
* двойные кавычки, внутри кавычка экранируется удвоением ("") — это учитываем,
* иначе многострочное наименование разорвётся на несколько позиций.
*
* Отображение колонок по их числу в строке:
* 5 колонок → первая это «№» (генерим сами), берём 2..5;
* 4 колонки → Наименование / Инв.номер / Ед.изм / Кол-во как есть;
* иначе → берём по первым доступным (Наименование обязательно).
* Ед.изм по умолчанию «шт». Строка-заголовок и пустые строки отбрасываются.
*/
export function parseExcelRows(text: string): ILineItem[] {
if (!text || !text.trim()) return [];
return splitRecords(text)
.filter((cells) => cells.some((c) => c.trim() !== '')) // пустые строки убрать
.filter((cells) => !isHeaderRow(cells)) // строку-заголовок отбросить
.map(mapColumns);
}
/** Похожа ли строка на шапку таблицы (её вставлять как позицию не нужно). */
function isHeaderRow(cells: string[]): boolean {
const joined = cells.join(' ').toLowerCase();
return (
joined.indexOf('наимен') !== -1 ||
(joined.indexOf('инв') !== -1 && joined.indexOf('кол') !== -1)
);
}
/** Ячейки строки → позиция с учётом числа колонок. */
function mapColumns(cells: string[]): ILineItem {
// 5+ колонок: первая — порядковый номер из источника, отбрасываем.
const c = cells.length >= 5 ? cells.slice(1) : cells;
const get = (i: number): string => (c[i] || '').trim();
return {
...EMPTY_LINE_ITEM,
name: get(0),
invNumber: get(1),
unit: get(2) || 'шт',
qty: get(3)
};
}
/**
* Разбить TSV-текст на записи (строки) с учётом кавычек Excel.
* Возвращает массив строк, каждая — массив ячеек.
*/
function splitRecords(text: string): string[][] {
const records: string[][] = [];
let row: string[] = [];
let field = '';
let inQuotes = false;
const pushField = (): void => {
row.push(field);
field = '';
};
const pushRow = (): void => {
pushField();
records.push(row);
row = [];
};
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inQuotes) {
if (ch === '"') {
if (text[i + 1] === '"') {
field += '"'; // экранированная кавычка ""
i++;
} else {
inQuotes = false; // конец кавычек
}
} else {
field += ch;
}
continue;
}
if (ch === '"' && field === '') {
inQuotes = true; // кавычки только в начале ячейки
} else if (ch === '\t') {
pushField();
} else if (ch === '\n') {
pushRow();
} else if (ch === '\r') {
// игнорируем — часть '\r\n'
} else {
field += ch;
}
}
// последняя запись без завершающего перевода строки
if (field !== '' || row.length > 0) {
pushRow();
}
return records;
}
@@ -15,13 +15,23 @@ export { CollapseSection } from './CollapseSection/CollapseSection';
export { FormActions } from './FormActions/FormActions';
export { FileDropzone } from './FileDropzone/FileDropzone';
export { AttachmentList } from './AttachmentList/AttachmentList';
export { ItemsTable } from './ItemsTable/ItemsTable';
export type { IItemsTableProps } from './ItemsTable/ItemsTable';
export type { ILineItem } from './ItemsTable/ILineItem';
export { EMPTY_LINE_ITEM } from './ItemsTable/ILineItem';
export { parseExcelRows } from './ItemsTable/parseExcelRows';
export { PeopleField } from './PeopleField/PeopleField';
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
export { FormModeProvider, useFormReadOnly } from './FormMode';
export { Icon } from './Icon/Icon';
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
export { ConfirmHost } from './ConfirmDialog/ConfirmDialog';
export { confirmModal } from './ConfirmDialog/confirmModal';
export type { IConfirmOptions } from './ConfirmDialog/confirmModal';
export { SubmitActions } from './SubmitActions/SubmitActions';
export { RequestNumber } from './RequestNumber/RequestNumber';
export { InitiatorLine } from './InitiatorLine/InitiatorLine';
export type { IInitiatorLineProps } from './InitiatorLine/InitiatorLine';
export { PrintButton } from './PrintButton/PrintButton';
export { useRequestSubmit } from './useRequestSubmit';
export type { IRequestSubmit, SubmitKind } from './useRequestSubmit';
@@ -1,3 +1,5 @@
import { confirmModal } from './ui/ConfirmDialog/confirmModal';
// Есть ли в открытой форме «Новая заявка» правки, которые ещё не сохранены.
//
// Нужно, потому что раздел «Новая заявка» держится смонтированным при
@@ -23,11 +25,18 @@ export function hasUnsavedFormChanges(): boolean {
return dirty;
}
// Спросить подтверждение, если несохранённые правки есть. true — можно
// продолжать (правок нет либо пользователь согласился их потерять).
export function confirmDiscardFormChanges(): boolean {
if (!dirty) return true;
return window.confirm(
'В открытой форме «Новая заявка» есть несохранённые изменения — они будут потеряны. Продолжить?'
);
// Спросить подтверждение, если несохранённые правки есть. Промис резолвится в
// true — можно продолжать (правок нет либо пользователь согласился их потерять),
// false — отменить действие. Асинхронно, потому что показываем модалку в стиле
// портала (см. shared/ui/ConfirmDialog) вместо нативного window.confirm.
export function confirmDiscardFormChanges(): Promise<boolean> {
if (!dirty) return Promise.resolve(true);
return confirmModal({
title: 'Несохранённые изменения',
message:
'В открытой форме «Новая заявка» есть несохранённые изменения — они будут потеряны. Продолжить?',
confirmText: 'Да, продолжить',
cancelText: 'Остаться',
danger: true,
});
}