теперь заявки на согласование тоже пишуться
This commit is contained in:
@@ -40,6 +40,22 @@
|
|||||||
color: $red-500;
|
color: $red-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section + .section {
|
||||||
|
margin-top: $sp-6;
|
||||||
|
padding-top: $sp-6;
|
||||||
|
border-top: 1px solid $gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $gray-500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
margin-bottom: $sp-4;
|
||||||
|
}
|
||||||
|
|
||||||
.group + .group {
|
.group + .group {
|
||||||
margin-top: $sp-5;
|
margin-top: $sp-5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,52 @@ function statusClass(status: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Постоянная боковая панель «Мои заявки»: заявки, которые создал текущий
|
const RequestItem: React.FC<{ r: IMyRequest }> = ({ r }) => (
|
||||||
// пользователь, по всем типам заявок, известным approval-route API.
|
<li>
|
||||||
|
<a
|
||||||
|
className={styles.item}
|
||||||
|
href={routeUrl({ mode: 'approve', formKey: r.formKey, itemId: r.id })}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Обычный левый клик — SPA-переход (без перезагрузки страницы).
|
||||||
|
// Ctrl/Cmd/Shift/среднюю кнопку — не трогаем, пусть браузер сам
|
||||||
|
// откроет в новой вкладке по href.
|
||||||
|
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
navigate({ mode: 'approve', formKey: r.formKey, itemId: r.id });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.itemMain}>
|
||||||
|
<span className={styles.itemTitle}>{r.title || `#${r.id}`}</span>
|
||||||
|
<span className={styles.itemAuthor}>
|
||||||
|
{r.authorEmail || r.authorName || '— автор не определён —'}
|
||||||
|
</span>
|
||||||
|
{r.created && <span className={styles.itemDate}>{formatDate(r.created)}</span>}
|
||||||
|
</div>
|
||||||
|
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RequestGroups: React.FC<{ rows: IMyRequest[] }> = ({ rows }) => (
|
||||||
|
<>
|
||||||
|
{groupByType(rows).map((group) => (
|
||||||
|
<div className={styles.group} key={group.requestTypeName}>
|
||||||
|
<div className={styles.groupTitle}>{group.requestTypeName}</div>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{group.rows.map((r) => (
|
||||||
|
<RequestItem r={r} key={`${r.requestTypeId}-${r.id}`} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Постоянная боковая панель «Текущие заявки»: заявки, созданные текущим
|
||||||
|
// пользователем, и заявки, ждущие сейчас его решения как согласующего —
|
||||||
|
// по всем типам заявок, известным approval-route API.
|
||||||
// Владелец: второй разработчик. Вся логика/данные — внутри этой папки.
|
// Владелец: второй разработчик. Вся логика/данные — внутри этой папки.
|
||||||
// Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять.
|
// Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять.
|
||||||
//
|
//
|
||||||
@@ -80,55 +124,34 @@ const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = ({ context }) => {
|
|||||||
};
|
};
|
||||||
}, [context]);
|
}, [context]);
|
||||||
|
|
||||||
const groups = rows ? groupByType(rows) : [];
|
const pendingApproval = rows ? rows.filter((r) => r.kind === 'approval') : [];
|
||||||
|
const own = rows ? rows.filter((r) => r.kind === 'own') : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={styles.panel} aria-label="Мои заявки">
|
<aside className={styles.panel} aria-label="Текущие заявки">
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<span className={styles.title}>Мои заявки</span>
|
<span className={styles.title}>Текущие заявки</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
{error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>}
|
{error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>}
|
||||||
{!error && rows === undefined && <div className={styles.empty}>Загрузка…</div>}
|
{!error && rows === undefined && <div className={styles.empty}>Загрузка…</div>}
|
||||||
{!error && rows && rows.length === 0 && (
|
{!error && rows && rows.length === 0 && (
|
||||||
<div className={styles.empty}>Вы ещё не создавали заявок.</div>
|
<div className={styles.empty}>Нет ни ваших заявок, ни заявок на ваше согласование.</div>
|
||||||
)}
|
)}
|
||||||
{groups.map((group) => (
|
|
||||||
<div className={styles.group} key={group.requestTypeName}>
|
{pendingApproval.length > 0 && (
|
||||||
<div className={styles.groupTitle}>{group.requestTypeName}</div>
|
<div className={styles.section}>
|
||||||
<ul className={styles.list}>
|
<div className={styles.sectionTitle}>Ждёт вашего решения</div>
|
||||||
{group.rows.map((r) => (
|
<RequestGroups rows={pendingApproval} />
|
||||||
<li key={`${r.requestTypeId}-${r.id}`}>
|
|
||||||
<a
|
|
||||||
className={styles.item}
|
|
||||||
href={routeUrl({ mode: 'approve', formKey: r.formKey, itemId: r.id })}
|
|
||||||
onClick={(e) => {
|
|
||||||
// Обычный левый клик — SPA-переход (без перезагрузки страницы).
|
|
||||||
// Ctrl/Cmd/Shift/среднюю кнопку — не трогаем, пусть браузер сам
|
|
||||||
// откроет в новой вкладке по href.
|
|
||||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
navigate({ mode: 'approve', formKey: r.formKey, itemId: r.id });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={styles.itemMain}>
|
|
||||||
<span className={styles.itemTitle}>{r.title || `#${r.id}`}</span>
|
|
||||||
<span className={styles.itemAuthor}>
|
|
||||||
{r.authorEmail || r.authorName || '— автор не определён —'}
|
|
||||||
</span>
|
|
||||||
{r.created && <span className={styles.itemDate}>{formatDate(r.created)}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
<span className={`${styles.status} ${statusClass(r.status)}`}>
|
)}
|
||||||
{r.status || '—'}
|
|
||||||
</span>
|
{own.length > 0 && (
|
||||||
</a>
|
<div className={styles.section}>
|
||||||
</li>
|
<div className={styles.sectionTitle}>Созданы вами</div>
|
||||||
))}
|
<RequestGroups rows={own} />
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,4 +13,6 @@ export interface IMyRequest {
|
|||||||
section: string;
|
section: string;
|
||||||
/** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */
|
/** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */
|
||||||
formKey: string;
|
formKey: string;
|
||||||
|
/** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */
|
||||||
|
kind: 'own' | 'approval';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ export interface IMyRequestsTypeDebug {
|
|||||||
rowCount?: number;
|
rowCount?: number;
|
||||||
/** Текст ошибки, если запрос к списку упал. */
|
/** Текст ошибки, если запрос к списку упал. */
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
||||||
|
/** Заголовок списка согласования, вычисленный из имени списка данных. */
|
||||||
|
approvalListTitle?: string;
|
||||||
|
/** Список данных не похож на «... — данные» — список согласования не искали. */
|
||||||
|
approvalSkippedReason?: string;
|
||||||
|
/** Сколько строк маршрута (фаза «Согласование») прочитано из списка согласования. */
|
||||||
|
approvalRowCount?: number;
|
||||||
|
/** Сколько заявок сейчас ждут решения текущего пользователя. */
|
||||||
|
approvalPendingTickets?: number;
|
||||||
|
/** Текст ошибки, если запрос к списку согласования упал. */
|
||||||
|
approvalError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IMyRequestsResult {
|
export interface IMyRequestsResult {
|
||||||
|
|||||||
@@ -1,20 +1,32 @@
|
|||||||
import { spfi, SPFx } from '@pnp/sp';
|
import { spfi, SPFI, SPFx } from '@pnp/sp';
|
||||||
|
import { IList } from '@pnp/sp/lists';
|
||||||
import '@pnp/sp/webs';
|
import '@pnp/sp/webs';
|
||||||
import '@pnp/sp/lists';
|
import '@pnp/sp/lists';
|
||||||
import '@pnp/sp/items';
|
import '@pnp/sp/items';
|
||||||
import '@pnp/sp/site-users/web';
|
import '@pnp/sp/site-users/web';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { getRequestTypes, IRequestType } from '../../shared/approval';
|
import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval';
|
||||||
import { IMyRequest } from './models/IMyRequest';
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
import { IMyRequestsResult, IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
import { IMyRequestsResult, IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
||||||
|
|
||||||
// Заявки текущего пользователя по всем типам заявок, известным approval-route API.
|
// Список данных «... — данные» → список согласования «... — согласование»
|
||||||
// Для каждого типа с заполненными listId/siteUrl читаем список данных на его сайте
|
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
|
||||||
// и берём строки, где Author — текущий пользователь. Типы без listId/siteUrl
|
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
||||||
// (ещё не настроены на бэкенде) пропускаются. Ошибка по одному списку (нет прав,
|
|
||||||
// список удалён и т.п.) не должна ронять всю панель — такие типы просто дают
|
interface IApprovalStep {
|
||||||
// пустой результат. debug возвращается всегда — панель показывает его как
|
status: string;
|
||||||
// отладочную информацию (что пропущено и почему, что упало с ошибкой).
|
order?: number;
|
||||||
|
approverEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заявки текущего пользователя по всем типам заявок, известным approval-route API:
|
||||||
|
// созданные им самим (kind: 'own') и ждущие сейчас его решения как согласующего
|
||||||
|
// (kind: 'approval'). Для каждого типа с заполненными listId/siteUrl читаем список
|
||||||
|
// данных на его сайте; список согласования ищем по имени списка данных (суффикс
|
||||||
|
// «— данные» → «— согласование»). Типы без listId/siteUrl (ещё не настроены на
|
||||||
|
// бэкенде) пропускаются. Ошибка по одному списку не должна ронять всю панель —
|
||||||
|
// такой тип просто даёт пустой результат. debug возвращается всегда — панель
|
||||||
|
// не показывает его в UI, но пишет в console.table при загрузке.
|
||||||
export async function getMyRequests(context: WebPartContext): Promise<IMyRequestsResult> {
|
export async function getMyRequests(context: WebPartContext): Promise<IMyRequestsResult> {
|
||||||
const types = await getRequestTypes(context);
|
const types = await getRequestTypes(context);
|
||||||
|
|
||||||
@@ -58,9 +70,29 @@ async function getMyRequestsForType(
|
|||||||
const list = sp.web.getList(type.listId as string);
|
const list = sp.web.getList(type.listId as string);
|
||||||
|
|
||||||
const me = await sp.web.currentUser.select('Id')();
|
const me = await sp.web.currentUser.select('Id')();
|
||||||
|
const tokenEmail = (context.pageContext.user.email || '').toLowerCase();
|
||||||
|
|
||||||
|
const ownRows = await getOwnRows(list, type, me.Id, tokenEmail, debug);
|
||||||
|
const pendingRows = await getApprovalPendingRows(sp, list, type, tokenEmail, debug);
|
||||||
|
|
||||||
|
return { rows: ownRows.concat(pendingRows), debug };
|
||||||
|
} catch (e) {
|
||||||
|
debug.error = (e as Error).message;
|
||||||
|
console.error(`Мои заявки: не удалось прочитать список типа «${type.name}»:`, debug.error);
|
||||||
|
return { rows: [], debug };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заявки, созданные текущим пользователем (Author = токен).
|
||||||
|
async function getOwnRows(
|
||||||
|
list: IList,
|
||||||
|
type: IRequestType,
|
||||||
|
myId: number,
|
||||||
|
tokenEmail: string,
|
||||||
|
debug: IMyRequestsTypeDebug
|
||||||
|
): Promise<IMyRequest[]> {
|
||||||
const rows = (await list.items
|
const rows = (await list.items
|
||||||
.filter(`AuthorId eq ${me.Id}`)
|
.filter(`AuthorId eq ${myId}`)
|
||||||
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
|
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
|
||||||
.expand('Author')
|
.expand('Author')
|
||||||
.orderBy('Id', false)
|
.orderBy('Id', false)
|
||||||
@@ -68,14 +100,133 @@ async function getMyRequestsForType(
|
|||||||
|
|
||||||
debug.rowCount = rows.length;
|
debug.rowCount = rows.length;
|
||||||
|
|
||||||
// Токен пользователя (context.pageContext.user.email) — источник правды для
|
// Токен пользователя — источник правды для сравнения с Author/EMail списка.
|
||||||
// сравнения с Author/EMail списка. Не совпало (напр. Author/EMail пуст на
|
// Не совпало (напр. Author/EMail пуст на профиле сайта) — строку не показываем.
|
||||||
// профиле сайта) — строку не показываем, а не показываем что попало.
|
return rows
|
||||||
const tokenEmail = (context.pageContext.user.email || '').toLowerCase();
|
|
||||||
|
|
||||||
const mapped = rows
|
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
const author = r.Author as { EMail?: string; Title?: string } | undefined;
|
const author = r.Author as { EMail?: string; Title?: string } | undefined;
|
||||||
|
return toMyRequest(r, author, type, 'own');
|
||||||
|
})
|
||||||
|
.filter((r) => !!tokenEmail && r.authorEmail.toLowerCase() === tokenEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заявки, где сейчас очередь текущего пользователя как согласующего.
|
||||||
|
async function getApprovalPendingRows(
|
||||||
|
sp: SPFI,
|
||||||
|
list: IList,
|
||||||
|
type: IRequestType,
|
||||||
|
tokenEmail: string,
|
||||||
|
debug: IMyRequestsTypeDebug
|
||||||
|
): Promise<IMyRequest[]> {
|
||||||
|
try {
|
||||||
|
const dataListInfo = await list.select('Title')();
|
||||||
|
const dataTitle = (dataListInfo.Title as string) || '';
|
||||||
|
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
|
||||||
|
debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
|
||||||
|
debug.approvalListTitle = approvalListTitle;
|
||||||
|
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
|
||||||
|
|
||||||
|
// Phase не смотрим вообще — работаем только по Status.
|
||||||
|
const stepRows = (await approvalList.items
|
||||||
|
.select('Id', 'StepOrder', 'Status', 'TicketId', 'Approver/EMail')
|
||||||
|
.expand('Approver')
|
||||||
|
.top(2000)()) as Array<Record<string, unknown>>;
|
||||||
|
console.log(
|
||||||
|
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», элементов в списке: ${stepRows.length}, ваш email из токена: «${tokenEmail}»`
|
||||||
|
);
|
||||||
|
console.table(
|
||||||
|
stepRows.map((r) => ({
|
||||||
|
Id: r.Id,
|
||||||
|
TicketId: r.TicketId,
|
||||||
|
StepOrder: r.StepOrder,
|
||||||
|
Status: r.Status,
|
||||||
|
'Approver/EMail': (r.Approver as { EMail?: string } | undefined)?.EMail,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
debug.approvalRowCount = stepRows.length;
|
||||||
|
|
||||||
|
const byTicket = new Map<number, IApprovalStep[]>();
|
||||||
|
stepRows.forEach((r) => {
|
||||||
|
const ticketId = r.TicketId as number | undefined;
|
||||||
|
if (!ticketId) return;
|
||||||
|
const approver = r.Approver as { EMail?: string } | undefined;
|
||||||
|
const steps = byTicket.get(ticketId) || [];
|
||||||
|
steps.push({
|
||||||
|
status: (r.Status as string) || '',
|
||||||
|
order: typeof r.StepOrder === 'number' ? (r.StepOrder as number) : undefined,
|
||||||
|
approverEmail: (approver?.EMail || '').toLowerCase(),
|
||||||
|
});
|
||||||
|
byTicket.set(ticketId, steps);
|
||||||
|
});
|
||||||
|
|
||||||
|
const myTicketIds: number[] = [];
|
||||||
|
const byTicketDebug: Array<Record<string, unknown>> = [];
|
||||||
|
byTicket.forEach((steps, ticketId) => {
|
||||||
|
const mine = isMyTurn(steps, tokenEmail);
|
||||||
|
byTicketDebug.push({ TicketId: ticketId, isMyTurn: mine, steps: JSON.stringify(steps) });
|
||||||
|
if (mine) myTicketIds.push(ticketId);
|
||||||
|
});
|
||||||
|
console.table(byTicketDebug);
|
||||||
|
|
||||||
|
debug.approvalPendingTickets = myTicketIds.length;
|
||||||
|
if (myTicketIds.length === 0) return [];
|
||||||
|
|
||||||
|
const ticketFilter = myTicketIds.map((id) => `Id eq ${id}`).join(' or ');
|
||||||
|
const ticketRows = (await list.items
|
||||||
|
.filter(ticketFilter)
|
||||||
|
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
|
||||||
|
.expand('Author')
|
||||||
|
.top(2000)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
return ticketRows.map((r) => {
|
||||||
|
const author = r.Author as { EMail?: string; Title?: string } | undefined;
|
||||||
|
return toMyRequest(r, author, type, 'approval');
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debug.approvalError = (e as Error).message;
|
||||||
|
console.error(
|
||||||
|
`Мои заявки: не удалось прочитать согласование для «${type.name}»:`,
|
||||||
|
debug.approvalError
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сейчас ли очередь пользователя с данным email среди строк маршрута одной заявки.
|
||||||
|
// Маршрут остановлен (где-то есть Отклонено/Возвращена) — не его очередь, заявка
|
||||||
|
// не активна. Иначе «текущие» строки — это ещё не решённые (Status = На согласовании)
|
||||||
|
// без указанного StepOrder (параллельно, все активны сразу) плюс, если есть строки
|
||||||
|
// с указанным StepOrder, ещё не решённая с минимальным StepOrder (последовательно).
|
||||||
|
function isMyTurn(steps: IApprovalStep[], tokenEmail: string): boolean {
|
||||||
|
if (!tokenEmail) return false;
|
||||||
|
const stopped = steps.some(
|
||||||
|
(s) => s.status === REQUEST_STATUS.rejected || s.status === REQUEST_STATUS.returned
|
||||||
|
);
|
||||||
|
if (stopped) return false;
|
||||||
|
|
||||||
|
const pending = steps.filter((s) => s.status === REQUEST_STATUS.onApproval);
|
||||||
|
const noOrder = pending.filter((s) => s.order === undefined);
|
||||||
|
const ordered = pending.filter((s): s is IApprovalStep & { order: number } => s.order !== undefined);
|
||||||
|
|
||||||
|
const current = [...noOrder];
|
||||||
|
if (ordered.length > 0) {
|
||||||
|
const minOrder = Math.min(...ordered.map((s) => s.order));
|
||||||
|
current.push(...ordered.filter((s) => s.order === minOrder));
|
||||||
|
}
|
||||||
|
|
||||||
|
return current.some((s) => s.approverEmail === tokenEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMyRequest(
|
||||||
|
r: Record<string, unknown>,
|
||||||
|
author: { EMail?: string; Title?: string } | undefined,
|
||||||
|
type: IRequestType,
|
||||||
|
kind: 'own' | 'approval'
|
||||||
|
): IMyRequest {
|
||||||
return {
|
return {
|
||||||
id: r.Id as number,
|
id: r.Id as number,
|
||||||
title: (r.Title as string) || '',
|
title: (r.Title as string) || '',
|
||||||
@@ -87,14 +238,6 @@ async function getMyRequestsForType(
|
|||||||
requestTypeName: type.name,
|
requestTypeName: type.name,
|
||||||
section: type.section,
|
section: type.section,
|
||||||
formKey: type.pageUrl,
|
formKey: type.pageUrl,
|
||||||
|
kind,
|
||||||
};
|
};
|
||||||
})
|
|
||||||
.filter((r) => !!tokenEmail && r.authorEmail.toLowerCase() === tokenEmail);
|
|
||||||
|
|
||||||
return { rows: mapped, debug };
|
|
||||||
} catch (e) {
|
|
||||||
debug.error = (e as Error).message;
|
|
||||||
console.error(`Мои заявки: не удалось прочитать список типа «${type.name}»:`, debug.error);
|
|
||||||
return { rows: [], debug };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user