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