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,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;
}