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
@@ -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';