feat: changed get time logic to approvers
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user