diff --git a/.gitignore b/.gitignore index f9e08d6..b900fc7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ jest-output/ *.sppkg .heft/ sharepoint/solution/ + +# JetBrains IDEA +.idea/ diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 6a43257..1236ea4 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -5,17 +5,10 @@ import { IconSprite } from '../shared/ui/Icon/IconSprite'; import { parseRoute } from '../shared/routing'; import SubmitFeature from '../features/submit/SubmitFeature'; import ApproveFeature from '../features/approve/ApproveFeature'; +import MyRequestsPanel from '../features/myRequests/MyRequestsPanel'; // Оболочка портала. Логики фич тут нет — файл общий, меняется редко. // Роутинг по URL: mode=approve&type=&id= → фича согласования, иначе — подача. -// -// Панель «Мои заявки» (features/myRequests) готова как компонент, но НЕ смонтирована — -// её подключает второй разработчик, когда наполнит. Для этого: -// 1) import MyRequestsPanel from '../features/myRequests/MyRequestsPanel'; -// 2) обернуть контент подачи в
с двумя колонками: -//
-//
-// (классы layout/main/side уже есть в RequestPortal.module.scss) const RequestPortal: React.FC = ({ hasTeamsContext, context }) => { const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : ''] .filter(Boolean) @@ -29,7 +22,14 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context {route.mode === 'approve' && route.formKey && route.itemId ? ( ) : ( - +
+
+ +
+
+ +
+
)} ); diff --git a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss index 038b24a..a577f1f 100644 --- a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss +++ b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.module.scss @@ -28,6 +28,114 @@ flex: 1; overflow-y: auto; padding: $sp-5; - color: $gray-500; font-size: $fs-sm; } + +.empty, +.error { + color: $gray-500; +} + +.error { + color: $red-500; +} + +.group + .group { + margin-top: $sp-5; +} + +.groupTitle { + font-family: $font-serif; + font-size: $fs-label; + font-weight: 600; + color: $navy-800; + margin-bottom: $sp-2; +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: $sp-2; +} + +.item { + display: flex; + align-items: center; + justify-content: space-between; + gap: $sp-2; + padding: $sp-2 $sp-3; + border: 1px solid $gray-200; + border-radius: $r-sm; + background: $gray-50; +} + +.itemMain { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + overflow: hidden; +} + +.itemTitle { + color: $gray-900; + font-size: $fs-sm; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.itemAuthor { + color: $gray-500; + font-size: $fs-caption; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.itemDate { + color: $gray-400; + font-size: $fs-caption; +} + +.status { + flex-shrink: 0; + padding: 2px 8px; + border-radius: $r-full; + font-size: $fs-caption; + font-weight: 500; + white-space: nowrap; +} + +.statusDraft { + background: $gray-100; + color: $gray-700; +} + +.statusOnApproval { + background: $gold-100; + color: $gold-600; +} + +.statusApproved { + background: $green-50; + color: $green-600; +} + +.statusRejected { + background: $red-50; + color: $red-500; +} + +.statusReturned { + background: $blue-50; + color: $blue-600; +} + +.statusDefault { + background: $gray-100; + color: $gray-500; +} diff --git a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx index bdc0039..1c42263 100644 --- a/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx +++ b/src/webparts/requestPortal/features/myRequests/MyRequestsPanel.tsx @@ -1,23 +1,118 @@ import * as React from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import { getMyRequests } from './myRequests.service'; +import { IMyRequest } from './models/IMyRequest'; +import { REQUEST_STATUS } from '../../shared/approval'; import styles from './MyRequestsPanel.module.scss'; export interface IMyRequestsPanelProps { context: WebPartContext; } -// Постоянная боковая панель «Мои заявки»: пользователь видит, что он отправлял -// и согласовывал. Всегда открыта (без кнопки/оверлея). +interface IGroup { + requestTypeName: string; + rows: IMyRequest[]; +} + +function groupByType(rows: IMyRequest[]): IGroup[] { + const order: string[] = []; + const map: Record = {}; + rows.forEach((r) => { + if (!map[r.requestTypeName]) { + map[r.requestTypeName] = []; + order.push(r.requestTypeName); + } + map[r.requestTypeName].push(r); + }); + return order.map((name) => ({ requestTypeName: name, rows: map[name] })); +} + +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; + } +} + +// Постоянная боковая панель «Мои заявки»: заявки, которые создал текущий +// пользователь, по всем типам заявок, известным approval-route API. // Владелец: второй разработчик. Вся логика/данные — внутри этой папки. // Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять. -const MyRequestsPanel: React.FC = (/* { context } */) => { +// +// Диагностика по типам заявок (listId/siteUrl/rowCount/error) в UI не выводится — +// см. console.table в devtools при загрузке панели. +const MyRequestsPanel: React.FC = ({ context }) => { + const [rows, setRows] = React.useState(undefined); + const [error, setError] = React.useState(''); + + React.useEffect(() => { + let cancelled = false; + getMyRequests(context) + .then((result) => { + if (cancelled) return; + setRows(result.rows); + console.table(result.debug); + }) + .catch((e) => { + if (!cancelled) setError((e as Error).message); + }); + return () => { + cancelled = true; + }; + }, [context]); + + const groups = rows ? groupByType(rows) : []; + return ( ); diff --git a/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts new file mode 100644 index 0000000..40e1acc --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/models/IMyRequest.ts @@ -0,0 +1,14 @@ +// Строка заявки пользователя для боковой панели «Мои заявки». +// Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts). +export interface IMyRequest { + id: number; + title: string; + status: string; + authorEmail: string; + authorName: string; + /** Дата создания (ISO-строка, колонка Created). */ + created: string; + requestTypeId: number; + requestTypeName: string; + section: string; +} diff --git a/src/webparts/requestPortal/features/myRequests/models/IMyRequestsDebug.ts b/src/webparts/requestPortal/features/myRequests/models/IMyRequestsDebug.ts new file mode 100644 index 0000000..6afdf65 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/models/IMyRequestsDebug.ts @@ -0,0 +1,20 @@ +import { IMyRequest } from './IMyRequest'; + +// Диагностика по одному типу заявки — для отладочного блока в панели «Мои заявки». +export interface IMyRequestsTypeDebug { + requestTypeId: number; + requestTypeName: string; + listId?: string; + siteUrl?: string; + /** Тип пропущен: у него нет listId/siteUrl в approval-route API. */ + skipped: boolean; + /** Сколько строк текущего пользователя найдено (undefined, если не дошли до запроса). */ + rowCount?: number; + /** Текст ошибки, если запрос к списку упал. */ + error?: string; +} + +export interface IMyRequestsResult { + rows: IMyRequest[]; + debug: IMyRequestsTypeDebug[]; +} diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts new file mode 100644 index 0000000..e55361a --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -0,0 +1,99 @@ +import { spfi, SPFx } from '@pnp/sp'; +import '@pnp/sp/webs'; +import '@pnp/sp/lists'; +import '@pnp/sp/items'; +import '@pnp/sp/site-users/web'; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { getRequestTypes, IRequestType } from '../../shared/approval'; +import { IMyRequest } from './models/IMyRequest'; +import { IMyRequestsResult, IMyRequestsTypeDebug } from './models/IMyRequestsDebug'; + +// Заявки текущего пользователя по всем типам заявок, известным approval-route API. +// Для каждого типа с заполненными listId/siteUrl читаем список данных на его сайте +// и берём строки, где Author — текущий пользователь. Типы без listId/siteUrl +// (ещё не настроены на бэкенде) пропускаются. Ошибка по одному списку (нет прав, +// список удалён и т.п.) не должна ронять всю панель — такие типы просто дают +// пустой результат. debug возвращается всегда — панель показывает его как +// отладочную информацию (что пропущено и почему, что упало с ошибкой). +export async function getMyRequests(context: WebPartContext): Promise { + const types = await getRequestTypes(context); + + const perType = await Promise.all( + types.map((t) => (t.listId && t.siteUrl ? getMyRequestsForType(context, t) : skipType(t))) + ); + + return { + rows: perType.reduce((all, r) => all.concat(r.rows), [] as IMyRequest[]), + debug: perType.map((r) => r.debug), + }; +} + +function skipType(type: IRequestType): { rows: IMyRequest[]; debug: IMyRequestsTypeDebug } { + return { + rows: [], + debug: { + requestTypeId: type.id, + requestTypeName: type.name, + listId: type.listId, + siteUrl: type.siteUrl, + skipped: true, + }, + }; +} + +async function getMyRequestsForType( + context: WebPartContext, + type: IRequestType +): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> { + const debug: IMyRequestsTypeDebug = { + requestTypeId: type.id, + requestTypeName: type.name, + listId: type.listId, + siteUrl: type.siteUrl, + skipped: false, + }; + + try { + const sp = spfi(type.siteUrl as string).using(SPFx(context)); + const list = sp.web.getList(type.listId as string); + + const me = await sp.web.currentUser.select('Id')(); + + const rows = (await list.items + .filter(`AuthorId eq ${me.Id}`) + .select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title') + .expand('Author') + .orderBy('Id', false) + .top(200)()) as Array>; + + debug.rowCount = rows.length; + + // Токен пользователя (context.pageContext.user.email) — источник правды для + // сравнения с Author/EMail списка. Не совпало (напр. Author/EMail пуст на + // профиле сайта) — строку не показываем, а не показываем что попало. + const tokenEmail = (context.pageContext.user.email || '').toLowerCase(); + + const mapped = rows + .map((r) => { + const author = r.Author as { EMail?: string; Title?: string } | undefined; + return { + id: r.Id as number, + title: (r.Title as string) || '', + status: (r.Status as string) || '', + authorEmail: author?.EMail || '', + authorName: author?.Title || '', + created: (r.Created as string) || '', + requestTypeId: type.id, + requestTypeName: type.name, + section: type.section, + }; + }) + .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 }; + } +} diff --git a/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts b/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts index 6c9d4a1..a0ae620 100644 --- a/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts +++ b/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts @@ -8,6 +8,11 @@ export interface IRequestType { id: number; name: string; pageUrl: string; + /** Серверный относительный URL списка данных (напр. "/sites/requests/Lists/List1"). undefined, пока не настроено. */ + listId?: string; + /** Абсолютный URL сайта, где живёт этот список. undefined, пока не настроено. */ + siteUrl?: string; + section: string; } // Обёртка ответа API (все ответы приходят в поле data).