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