Панель со списками заявок (#1)

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/1
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tnsu.kz>
Co-committed-by: gorbenko.pavel <gorbenko.pavel@tnsu.kz>
This commit was merged in pull request #1.
This commit is contained in:
2026-08-04 12:02:11 +00:00
committed by timur.gorodetskiy
parent ba4e3feee5
commit 0aa0a60e40
21 changed files with 1442 additions and 121 deletions
@@ -0,0 +1,283 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getLinkedTypes, getOwnRequests, getPendingApproval } from './myRequests.service';
import { IMyRequest } from './models/IMyRequest';
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
import { onRequestsChanged } from '../../shared/requestEvents';
import styles from './RequestsTableView.module.scss';
const PAGE_SIZE = 20;
export interface IRequestsTableViewProps {
context: WebPartContext;
/** 'approvals' — ждут решения текущего пользователя; 'own' — созданные им. */
kind: 'approvals' | 'own';
/** Клик по строке — открыть заявку (в выезжающей панели). */
onOpen: (request: IMyRequest) => void;
}
function 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()}`;
}
function statusClass(status: string): string {
switch (status) {
case REQUEST_STATUS.approved:
return styles.statusApproved;
case REQUEST_STATUS.rejected:
return styles.statusRejected;
case REQUEST_STATUS.returned:
return styles.statusReturned;
case REQUEST_STATUS.onApproval:
return styles.statusOnApproval;
case REQUEST_STATUS.draft:
return styles.statusDraft;
default:
return styles.statusDefault;
}
}
// Постраничная навигация: номера страниц + вперёд/назад. При большом
// количестве страниц показывает только соседние с текущей + первую/последнюю.
const Pager: React.FC<{ page: number; totalPages: number; onChange: (page: number) => void }> = ({
page,
totalPages,
onChange,
}) => {
if (totalPages <= 1) return null;
const numbers: number[] = [];
const from = Math.max(1, page - 2);
const to = Math.min(totalPages, page + 2);
for (let i = from; i <= to; i++) numbers.push(i);
return (
<div className={styles.pager}>
<button
type="button"
className={styles.pagerBtn}
disabled={page <= 1}
onClick={() => onChange(page - 1)}
aria-label="Предыдущая страница"
>
‹
</button>
{from > 1 && (
<>
<button type="button" className={styles.pagerBtn} onClick={() => onChange(1)}>
1
</button>
{from > 2 && <span className={styles.pagerEllipsis}>…</span>}
</>
)}
{numbers.map((n) => (
<button
key={n}
type="button"
className={`${styles.pagerBtn} ${n === page ? styles.pagerBtnActive : ''}`}
onClick={() => onChange(n)}
>
{n}
</button>
))}
{to < totalPages && (
<>
{to < totalPages - 1 && <span className={styles.pagerEllipsis}>…</span>}
<button type="button" className={styles.pagerBtn} onClick={() => onChange(totalPages)}>
{totalPages}
</button>
</>
)}
<button
type="button"
className={styles.pagerBtn}
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
aria-label="Следующая страница"
>
›
</button>
</div>
);
};
// Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся
// строка, а не только название), поэтому строка — <tr> с onClick + tabIndex
// (доступ с клавиатуры: Enter/Space).
//
// «approvals»: автор — не текущий пользователь, поэтому нужна колонка «Автор»;
// статус не показываем — здесь все заявки по определению на согласовании.
// «own»: автор всегда текущий пользователь, зато важен статус.
const Table: React.FC<{
rows: IMyRequest[];
kind: 'approvals' | 'own';
onOpen: (request: IMyRequest) => void;
}> = ({ rows, kind, onOpen }) => (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.colId}>ID</th>
<th className={styles.colType}>Тип</th>
<th className={styles.colTitle}>Название</th>
<th className={styles.colDate}>Дата</th>
{kind === 'approvals' && <th className={styles.colAuthor}>Автор</th>}
{kind === 'own' && <th className={styles.colStatus}>Статус</th>}
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr
className={styles.row}
key={`${r.requestTypeId}-${r.id}`}
tabIndex={0}
role="button"
aria-label={`Открыть заявку «${r.title || r.id}»`}
onClick={() => onOpen(r)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen(r);
}
}}
>
<td className={styles.colId}>{r.id}</td>
<td className={styles.colType}>{r.requestTypeName}</td>
<td className={styles.colTitle}>{r.title || `#${r.id}`}</td>
<td className={styles.colDate}>{formatDate(r.created)}</td>
{kind === 'approvals' && (
<td className={styles.colAuthor}>
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
</td>
)}
{kind === 'own' && (
<td className={styles.colStatus}>
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
// Раздел с таблицей заявок: поиск по названию + постраничный вывод.
//
// 'approvals' грузится целиком (алгоритм «чья сейчас очередь» требует видеть
// весь маршрут заявки), поиск и пагинация — на клиенте.
// 'own' идёт постранично через обычный REST (см. myRequests.service), поиск —
// серверным OData-фильтром по Title.
//
// Оба варианта перечитываются по событию rp-requests-changed
// (shared/requestEvents) — после создания заявки и после решения согласующего.
export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({ context, kind, onOpen }) => {
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
const [totalRows, setTotalRows] = React.useState<number>(0);
const [page, setPage] = React.useState<number>(1);
const [query, setQuery] = React.useState<string>('');
const [error, setError] = React.useState<string>('');
React.useEffect(() => setPage(1), [query, kind]);
React.useEffect(() => {
let cancelled = false;
getLinkedTypes(context)
.then((t) => {
if (!cancelled) setTypes(t);
})
.catch((e) => {
if (!cancelled) setError((e as Error).message);
});
return () => {
cancelled = true;
};
}, [context]);
React.useEffect(() => {
if (types === undefined) return;
let cancelled = false;
const load = (): void => {
const request =
kind === 'own'
? getOwnRequests(context, types, query, page).then((r) => ({ rows: r.rows, total: r.totalRows }))
: getPendingApproval(context).then((r) => {
console.table(r.debug);
return { rows: r.rows, total: r.rows.length };
});
request
.then((result) => {
if (cancelled) return;
setRows(result.rows);
setTotalRows(result.total);
setError('');
})
.catch((e) => {
if (!cancelled) setError((e as Error).message);
});
};
load();
const unsubscribe = onRequestsChanged(load);
return () => {
cancelled = true;
unsubscribe();
};
}, [context, types, kind, query, page]);
// 'approvals' приходит целиком — фильтруем и нарезаем страницы на клиенте.
const q = query.trim().toLowerCase();
let visible = rows || [];
let total = totalRows;
if (kind === 'approvals' && rows) {
const filtered = q ? rows.filter((r) => r.title.toLowerCase().indexOf(q) !== -1) : rows;
total = filtered.length;
visible = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
}
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const heading = kind === 'approvals' ? 'Ждут вашего решения' : 'Мои заявки';
return (
<section className={styles.view}>
<div className={styles.head}>
<h2 className={styles.heading}>
{heading}
{rows && <span className={styles.count}>{total}</span>}
</h2>
<input
type="search"
className={styles.search}
placeholder="Поиск по названию…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
{error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>}
{!error && rows === undefined && <div className={styles.empty}>Загрузка…</div>}
{!error && rows && total === 0 && (
<div className={styles.empty}>
{q
? `По запросу «${query}» ничего не найдено.`
: kind === 'approvals'
? 'Нет заявок, ждущих вашего решения.'
: 'Вы ещё не создавали заявок.'}
</div>
)}
{!error && rows && total > 0 && (
<>
<Table rows={visible} kind={kind} onOpen={onOpen} />
<Pager page={page} totalPages={totalPages} onChange={setPage} />
</>
)}
</section>
);
};