добавлен комментарий
This commit is contained in:
@@ -198,6 +198,18 @@
|
|||||||
width: 190px;
|
width: 190px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Причина возврата — свободный текст согласующего, поэтому переносим по словам
|
||||||
|
// (в т.ч. длинное слово без пробелов, иначе оно растянет таблицу).
|
||||||
|
.colComment {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentEmpty {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
// Раскраска статуса — теперь только в shared/approval/ui/StatusBadge (см.
|
// Раскраска статуса — теперь только в shared/approval/ui/StatusBadge (см.
|
||||||
// использование ниже с size="sm"). Раньше здесь была своя копия этих цветов —
|
// использование ниже с size="sm"). Раньше здесь была своя копия этих цветов —
|
||||||
// она разошлась с эталоном (На согласовании/Возвращена на доработку оказались
|
// она разошлась с эталоном (На согласовании/Возвращена на доработку оказались
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getOwnRequests,
|
getOwnRequests,
|
||||||
getOwnRequestsByStatus,
|
getOwnRequestsByStatus,
|
||||||
hydratePendingAuthors,
|
hydratePendingAuthors,
|
||||||
|
hydrateReturnComments,
|
||||||
searchOwnRequests,
|
searchOwnRequests,
|
||||||
} from './myRequests.service';
|
} from './myRequests.service';
|
||||||
import { IMyRequest } from './models/IMyRequest';
|
import { IMyRequest } from './models/IMyRequest';
|
||||||
@@ -115,11 +116,27 @@ function statusOptionClassName(status: string): string {
|
|||||||
// 'own:created' — статус нужен, только пока показаны ВСЕ заявки. Стоит выбрать
|
// 'own:created' — статус нужен, только пока показаны ВСЕ заявки. Стоит выбрать
|
||||||
// его в фильтре, и колонка вырождается в тот же столбец одинаковых плашек,
|
// его в фильтре, и колонка вырождается в тот же столбец одинаковых плашек,
|
||||||
// поэтому она зависит не от одного вида, а ещё и от фильтра.
|
// поэтому она зависит не от одного вида, а ещё и от фильтра.
|
||||||
function columnsFor(view: ListView, statusFilter: string): { author: boolean; status: boolean } {
|
//
|
||||||
if (view === 'approvals' || view === 'execution') return { author: true, status: false };
|
// «Причина возврата» — там, где показаны ТОЛЬКО возвращённые заявки: для них
|
||||||
if (view === 'rework') return { author: false, status: false };
|
// это главное, ради чего в список и заходят (что именно просят исправить).
|
||||||
if (view === 'own:created') return { author: false, status: !statusFilter };
|
interface ITableColumns {
|
||||||
return { author: true, status: true };
|
author: boolean;
|
||||||
|
status: boolean;
|
||||||
|
returnComment: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnsFor(view: ListView, statusFilter: string): ITableColumns {
|
||||||
|
const returnedOnly =
|
||||||
|
view === 'rework' || (view === 'own:created' && statusFilter === REQUEST_STATUS.returned);
|
||||||
|
|
||||||
|
if (view === 'approvals' || view === 'execution') {
|
||||||
|
return { author: true, status: false, returnComment: false };
|
||||||
|
}
|
||||||
|
if (view === 'rework') return { author: false, status: false, returnComment: true };
|
||||||
|
if (view === 'own:created') {
|
||||||
|
return { author: false, status: !statusFilter, returnComment: returnedOnly };
|
||||||
|
}
|
||||||
|
return { author: true, status: true, returnComment: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IRequestsTableViewProps {
|
export interface IRequestsTableViewProps {
|
||||||
@@ -171,6 +188,7 @@ const Table: React.FC<{
|
|||||||
<th className={styles.colDate}>Дата</th>
|
<th className={styles.colDate}>Дата</th>
|
||||||
{columns.author && <th className={styles.colAuthor}>Автор</th>}
|
{columns.author && <th className={styles.colAuthor}>Автор</th>}
|
||||||
{columns.status && <th className={styles.colStatus}>Статус</th>}
|
{columns.status && <th className={styles.colStatus}>Статус</th>}
|
||||||
|
{columns.returnComment && <th className={styles.colComment}>Причина возврата</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -203,6 +221,11 @@ const Table: React.FC<{
|
|||||||
{r.status ? <StatusBadge status={r.status} size="sm" /> : '—'}
|
{r.status ? <StatusBadge status={r.status} size="sm" /> : '—'}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
|
{columns.returnComment && (
|
||||||
|
<td className={styles.colComment}>
|
||||||
|
{r.returnComment || <span className={styles.commentEmpty}>Без комментария</span>}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -323,7 +346,13 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
// зашитым в раздел, а не выбранным в фильтре.
|
// зашитым в раздел, а не выбранным в фильтре.
|
||||||
if (view === 'rework' || (view === 'own:created' && statusFilter)) {
|
if (view === 'rework' || (view === 'own:created' && statusFilter)) {
|
||||||
const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter;
|
const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter;
|
||||||
return getOwnRequestsByStatus(context, types, status, page, debouncedQuery);
|
const result = await getOwnRequestsByStatus(context, types, status, page, debouncedQuery);
|
||||||
|
// Причина возврата живёт в строке маршрута — дочитываем только для
|
||||||
|
// видимой страницы и только там, где колонка показывается.
|
||||||
|
if (status === REQUEST_STATUS.returned) {
|
||||||
|
await hydrateReturnComments(context, types, result.rows);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (view === 'own:created') {
|
if (view === 'own:created') {
|
||||||
|
|||||||
@@ -22,4 +22,10 @@ export interface IMyRequest {
|
|||||||
* не показывается — поэтому и не входит в pageSignature.
|
* не показывается — поэтому и не входит в pageSignature.
|
||||||
*/
|
*/
|
||||||
myStepStatus?: string;
|
myStepStatus?: string;
|
||||||
|
/**
|
||||||
|
* Комментарий согласующего, вернувшего заявку на доработку. Живёт не в списке
|
||||||
|
* данных, а в строке МАРШРУТА, поэтому дочитывается отдельно и только там,
|
||||||
|
* где нужен (см. hydrateReturnComments).
|
||||||
|
*/
|
||||||
|
returnComment?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -785,6 +785,93 @@ export async function getOwnRequestsByStatus(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Список согласования по типу заявки: Title списка данных → Title списка
|
||||||
|
// согласования по конвенции имён (та же логика, что в indexApprovalsForType).
|
||||||
|
// undefined — список заведён не по конвенции, маршрута для него не найти.
|
||||||
|
async function approvalListFor(
|
||||||
|
context: WebPartContext,
|
||||||
|
type: IRequestType
|
||||||
|
): Promise<IList | undefined> {
|
||||||
|
const sp = spForSite(type.siteUrl as string, context);
|
||||||
|
const info = (await sp.web.getList(type.listId as string).select('Title')()) as { Title?: string };
|
||||||
|
const dataTitle = info.Title || '';
|
||||||
|
if (!DATA_LIST_SUFFIX.test(dataTitle)) return undefined;
|
||||||
|
return sp.web.lists.getByTitle(`${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев:
|
||||||
|
// на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный.
|
||||||
|
const RETURN_COMMENT_ROW_LIMIT = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проставляет returnComment — комментарий согласующего, вернувшего заявку на
|
||||||
|
* доработку. Причина возврата хранится в СТРОКЕ МАРШРУТА (колонка Comment
|
||||||
|
* списка «— согласование»), а не в самой заявке, поэтому её нельзя получить
|
||||||
|
* тем же запросом, что и список: нужен отдельный проход по спискам согласования.
|
||||||
|
*
|
||||||
|
* Вызывается ТОЛЬКО там, где причина показывается (см. RequestsTableView) —
|
||||||
|
* счётчику в меню, например, она не нужна, и лишние запросы ему ни к чему.
|
||||||
|
* Мутирует переданные строки — как и hydrateAuthors рядом.
|
||||||
|
*/
|
||||||
|
export async function hydrateReturnComments(
|
||||||
|
context: WebPartContext,
|
||||||
|
types: IRequestType[],
|
||||||
|
rows: IMyRequest[]
|
||||||
|
): Promise<void> {
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
const typeById = new Map<number, IRequestType>();
|
||||||
|
types.forEach((t) => typeById.set(t.id, t));
|
||||||
|
|
||||||
|
const rowsByType = new Map<number, IMyRequest[]>();
|
||||||
|
rows.forEach((r) => {
|
||||||
|
const list = rowsByType.get(r.requestTypeId) || [];
|
||||||
|
list.push(r);
|
||||||
|
rowsByType.set(r.requestTypeId, list);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(rowsByType.entries()).map(async ([requestTypeId, typeRows]) => {
|
||||||
|
const type = typeById.get(requestTypeId);
|
||||||
|
if (!type) return;
|
||||||
|
try {
|
||||||
|
const approvalList = await approvalListFor(context, type);
|
||||||
|
if (!approvalList) return;
|
||||||
|
|
||||||
|
// Фильтруем только по TicketId (числовое поле), статус сравниваем в JS:
|
||||||
|
// кириллица в $filter по Choice-колонке молча возвращает 0 строк —
|
||||||
|
// ровно та же причина, что и в indexApprovalsForType.
|
||||||
|
const stepRows = (await approvalList.items
|
||||||
|
.filter(typeRows.map((r) => `TicketId eq ${r.id}`).join(' or '))
|
||||||
|
.select('TicketId', 'ApprovalStatus', 'Comment')
|
||||||
|
.top(RETURN_COMMENT_ROW_LIMIT)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
const commentByTicket = new Map<number, string>();
|
||||||
|
stepRows.forEach((r) => {
|
||||||
|
if (r.ApprovalStatus !== REQUEST_STATUS.returned) return;
|
||||||
|
const ticketId = r.TicketId as number | undefined;
|
||||||
|
const comment = ((r.Comment as string) || '').trim();
|
||||||
|
// Возврат по заявке один (при переотправке маршрут переписывается
|
||||||
|
// целиком, см. writeRoute) — но если строк вдруг несколько, берём
|
||||||
|
// первую непустую, а не последнюю пустую.
|
||||||
|
if (ticketId && comment && !commentByTicket.has(ticketId)) {
|
||||||
|
commentByTicket.set(ticketId, comment);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
typeRows.forEach((r) => {
|
||||||
|
r.returnComment = commentByTicket.get(r.id);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`[Текущие заявки] «${type.name}»: не удалось прочитать причину возврата:`,
|
||||||
|
(e as Error).message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// «На согласовании»: список заявок уже отобран по строкам согласования
|
// «На согласовании»: список заявок уже отобран по строкам согласования
|
||||||
// (getPendingApprovalIndex — там же определяется, чья сейчас очередь,
|
// (getPendingApprovalIndex — там же определяется, чья сейчас очередь,
|
||||||
// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search
|
// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search
|
||||||
|
|||||||
@@ -99,6 +99,18 @@ describe('pageSignature', () => {
|
|||||||
expect(pageSignature(before)).toBe(pageSignature(after));
|
expect(pageSignature(before)).toBe(pageSignature(after));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('изменение причины возврата меняет подпись — она видна в таблице «Доработка»', () => {
|
||||||
|
const before = page({ rows: [request({ returnComment: 'Исправьте должность' })] });
|
||||||
|
const after = page({ rows: [request({ returnComment: 'Приложите скан' })] });
|
||||||
|
expect(pageSignature(before)).not.toBe(pageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('появление причины возврата у строки без неё тоже меняет подпись', () => {
|
||||||
|
const before = page({ rows: [request()] });
|
||||||
|
const after = page({ rows: [request({ returnComment: 'Исправьте должность' })] });
|
||||||
|
expect(pageSignature(before)).not.toBe(pageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
it('пустая страница имеет стабильную подпись', () => {
|
it('пустая страница имеет стабильную подпись', () => {
|
||||||
expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 }));
|
expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 }));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export function writePage(key: string, page: IPage): void {
|
|||||||
// перерисовываясь тем же самым содержимым.
|
// перерисовываясь тем же самым содержимым.
|
||||||
export function pageSignature(page: IPage): string {
|
export function pageSignature(page: IPage): string {
|
||||||
return `${page.totalRows}|${page.rows
|
return `${page.totalRows}|${page.rows
|
||||||
.map((r) => [r.requestTypeId, r.id, r.title, r.status, r.created, r.authorName].join('~'))
|
.map((r) =>
|
||||||
|
[r.requestTypeId, r.id, r.title, r.status, r.created, r.authorName, r.returnComment ?? ''].join('~')
|
||||||
|
)
|
||||||
.join(';')}`;
|
.join(';')}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user