feat: config-driven реестр на реальных данных SP

This commit is contained in:
Тимур Городецкий
2026-08-17 11:23:57 +05:00
commit 31ef6fdbb9
99 changed files with 33739 additions and 0 deletions
@@ -0,0 +1,29 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "459579c7-954c-469e-b707-ca75d808603f",
"alias": "CtFormsPortalWebPart",
"componentType": "WebPart",
// The "*" signifies that the version should be taken from the package.json
"version": "*",
"manifestVersion": 2,
// If true, the component can only be installed on sites where Custom Script is allowed.
// Components that allow authors to embed arbitrary script code should set this to true.
// https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
"requiresCustomScript": false,
"supportedHosts": ["SharePointWebPart", "TeamsPersonalApp", "TeamsTab", "SharePointFullPage"],
"supportsThemeVariants": true,
"supportsFullBleed": true,
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Advanced
"group": { "default": "Advanced" },
"title": { "default": "ct-forms-portal" },
"description": { "default": "ct-forms-portal description" },
"officeFabricIconFontName": "Page",
"properties": {
"description": "ct-forms-portal"
}
}]
}
@@ -0,0 +1,122 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
type IPropertyPaneConfiguration,
PropertyPaneTextField
} from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import * as strings from 'CtFormsPortalWebPartStrings';
import CtFormsPortal from './components/CtFormsPortal';
import { ICtFormsPortalProps } from './components/ICtFormsPortalProps';
export interface ICtFormsPortalWebPartProps {
description: string;
}
export default class CtFormsPortalWebPart extends BaseClientSideWebPart<ICtFormsPortalWebPartProps> {
private _isDarkTheme: boolean = false;
private _environmentMessage: string = '';
public render(): void {
const element: React.ReactElement<ICtFormsPortalProps> = React.createElement(
CtFormsPortal,
{
description: this.properties.description,
isDarkTheme: this._isDarkTheme,
environmentMessage: this._environmentMessage,
hasTeamsContext: !!this.context.sdks.microsoftTeams,
userDisplayName: this.context.pageContext.user.displayName,
context: this.context
}
);
ReactDom.render(element, this.domElement);
}
protected onInit(): Promise<void> {
return this._getEnvironmentMessage().then(message => {
this._environmentMessage = message;
});
}
private _getEnvironmentMessage(): Promise<string> {
if (!!this.context.sdks.microsoftTeams) { // running in Teams, office.com or Outlook
return this.context.sdks.microsoftTeams.teamsJs.app.getContext()
.then(context => {
let environmentMessage: string = '';
switch (context.app.host.name) {
case 'Office': // running in Office
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOffice : strings.AppOfficeEnvironment;
break;
case 'Outlook': // running in Outlook
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOutlook : strings.AppOutlookEnvironment;
break;
case 'Teams': // running in Teams
case 'TeamsModern':
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentTeams : strings.AppTeamsTabEnvironment;
break;
default:
environmentMessage = strings.UnknownEnvironment;
}
return environmentMessage;
});
}
return Promise.resolve(this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentSharePoint : strings.AppSharePointEnvironment);
}
protected onThemeChanged(currentTheme: IReadonlyTheme | undefined): void {
if (!currentTheme) {
return;
}
this._isDarkTheme = !!currentTheme.isInverted;
const {
semanticColors
} = currentTheme;
if (semanticColors) {
this.domElement.style.setProperty('--bodyText', semanticColors.bodyText || null);
this.domElement.style.setProperty('--link', semanticColors.link || null);
this.domElement.style.setProperty('--linkHovered', semanticColors.linkHovered || null);
}
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
header: {
description: strings.PropertyPaneDescription
},
groups: [
{
groupName: strings.BasicGroupName,
groupFields: [
PropertyPaneTextField('description', {
label: strings.DescriptionFieldLabel
})
]
}
]
}
]
};
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,17 @@
// Full-width: снимаем max-width канвы SharePoint — и в workbench (dev), и на живой
// странице. !important оправдан: перебиваем встроенные стили канвы, иначе их не снять.
// Требует "supportsFullBleed": true в манифесте + размещение в полноширинной секции.
// Если после обновления платформы ширина вернётся — проверить актуальные селекторы в DevTools.
:global {
#workbenchPageContent,
#spPageCanvasContent,
.SPCanvas-canvas,
.CanvasComponent.LCS .CanvasZone,
.CanvasZone {
max-width: 100% !important;
}
}
.root {
width: 100%;
}
@@ -0,0 +1,17 @@
import * as React from 'react';
import '@tnsu/ui-kit/styles.css';
import { IconSprite } from '@tnsu/ui-kit';
import styles from './CtFormsPortal.module.scss';
import type { ICtFormsPortalProps } from './ICtFormsPortalProps';
import { Dashboard } from './Dashboard';
// Корень веб-парта: спрайт иконок (один раз) + дашборд-реестр.
// Импорт CtFormsPortal.module.scss обязателен — в нём :global full-width правила канвы.
const CtFormsPortal: React.FC<ICtFormsPortalProps> = (props) => (
<div className={styles.root}>
<IconSprite />
<Dashboard context={props.context} />
</div>
);
export default CtFormsPortal;
@@ -0,0 +1,57 @@
// Раскладку (сайдбар+контент) даёт @tnsu/ui-kit AppLayout. Здесь — только
// оформление содержимого реестра (заголовок, тулбар, карточка таблицы).
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.title {
margin: 0;
font-family: 'Lora', Georgia, serif;
font-size: 22px;
font-weight: 600;
color: #0f2440;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search {
width: 280px;
max-width: 100%;
}
.filter {
width: 200px;
}
.tableWrap {
margin-top: 16px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
box-shadow: 0 1px 2px rgba(15, 36, 64, 0.04), 0 1px 3px rgba(15, 36, 64, 0.06);
overflow: hidden;
}
.state {
margin-top: 16px;
color: #6b7280;
font-size: 13px;
}
@media (max-width: 640px) {
.search,
.filter {
width: 100%;
}
}
@@ -0,0 +1,171 @@
import * as React from 'react';
import { useState } from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './Dashboard.module.scss';
import {
AppLayout,
Sidebar,
SidebarToggle,
ISidebarItem,
Tabs,
ITab,
DataTable,
IDataTableColumn,
Badge,
BadgeVariant,
Button,
TextInput,
Select,
ISelectOption,
Alert,
IconName
} from '@tnsu/ui-kit';
import { DOCUMENT_TYPES } from './config';
import { useDocumentDashboard } from './registry/useDocumentDashboard';
import { DashboardRow } from './registry/documentRegistry';
type QueueKey = 'pending' | 'mine' | 'approved';
// Иконка сайдбара по типу (kit-иконки; Fluent-иконки из конфигов тут не используем).
const TYPE_ICON: Record<string, IconName> = { ct: 'clipboard', oid: 'chart', kps: 'building' };
// Цвет статуса DashboardRow → вариант нашего Badge (error → danger).
const VARIANT_MAP: Record<DashboardRow['statusVariant'], BadgeVariant> = {
neutral: 'neutral',
info: 'info',
success: 'success',
error: 'danger'
};
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()}`;
}
// Дашборд-реестр (config-driven, данные из SharePoint). Оболочка и элементы — из @tnsu/ui-kit.
export const Dashboard: React.FC<{ context: WebPartContext }> = ({ context }) => {
const { myDocs, approvedDocs, pendingDocs, isLoading, error } = useDocumentDashboard(context, DOCUMENT_TYPES);
const [activeType, setActiveType] = useState<string>(DOCUMENT_TYPES[0].typeKey);
const [queue, setQueue] = useState<QueueKey>('pending');
const [collapsed, setCollapsed] = useState<boolean>(false);
const [mobileOpen, setMobileOpen] = useState<boolean>(false);
const [search, setSearch] = useState<string>('');
const [status, setStatus] = useState<string>('');
const cfg = DOCUMENT_TYPES.filter((t) => t.typeKey === activeType)[0];
const byType = (arr: DashboardRow[]): DashboardRow[] => arr.filter((r) => r.typeKey === activeType);
const pendingForType = byType(pendingDocs);
const mineForType = byType(myDocs);
const approvedForType = byType(approvedDocs);
const sidebarItems: ISidebarItem[] = DOCUMENT_TYPES.map((t) => ({
id: t.typeKey,
title: t.typeLabel,
icon: TYPE_ICON[t.typeKey] ?? 'document',
count: pendingDocs.filter((r) => r.typeKey === t.typeKey).length
}));
const tabs: ITab[] = [
{ id: 'pending', label: 'На согласовании у меня', count: pendingForType.length },
{ id: 'mine', label: 'Инициализированные мной', count: mineForType.length },
{ id: 'approved', label: 'Согласованные мной', count: approvedForType.length }
];
const activeRows = queue === 'pending' ? pendingForType : queue === 'mine' ? mineForType : approvedForType;
const statusOptions: ISelectOption[] = Array.from(new Set(activeRows.map((r) => r.statusLabel))).map((s) => ({
value: s,
label: s
}));
const rows = activeRows
.filter((r) => !search || `${r.title} ${r.subtitle ?? ''}`.toLowerCase().indexOf(search.toLowerCase()) !== -1)
.filter((r) => !status || r.statusLabel === status);
const renderCell = (row: DashboardRow, key: string): React.ReactNode => {
if (key === 'status') {
return (
<Badge variant={VARIANT_MAP[row.statusVariant]} size="sm">
{row.statusLabel}
</Badge>
);
}
if (key === 'created') return formatDate(row.createdDate);
return String((row as unknown as Record<string, unknown>)[key] ?? '');
};
const columns: IDataTableColumn<DashboardRow>[] = cfg.columns.map((c) => ({
key: c.key,
title: c.label,
width: c.minWidth ? `${c.minWidth}px` : undefined,
render: (row) => renderCell(row, c.key)
}));
const selectType = (id: string | number): void => {
setActiveType(String(id));
setQueue('pending');
setSearch('');
setStatus('');
};
return (
<AppLayout
sidebar={
<Sidebar
items={sidebarItems}
activeId={activeType}
onChange={selectType}
brand="Договора"
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((c) => !c)}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
}
mobileToggle={<SidebarToggle onClick={() => setMobileOpen(true)} />}
>
<div className={styles.head}>
<h2 className={styles.title}>{cfg.typeLabel}</h2>
{cfg.createNewUrl && (
<Button variant="accent" onClick={() => window.open(cfg.createNewUrl, '_blank')}>
{cfg.createNewLabel ?? 'Создать'}
</Button>
)}
</div>
<div className={styles.toolbar}>
<div className={styles.search}>
<TextInput value={search} onChange={setSearch} placeholder="Поиск по номеру или проекту" />
</div>
<div className={styles.filter}>
<Select value={status} onChange={setStatus} options={statusOptions} placeholder="Все статусы" />
</div>
</div>
<Tabs tabs={tabs} activeId={queue} onChange={(id) => setQueue(id as QueueKey)} />
{error && (
<div className={styles.state}>
<Alert variant="err">Не удалось загрузить данные: {error}</Alert>
</div>
)}
{!error && isLoading && <div className={styles.state}>Загрузка…</div>}
{!error && !isLoading && (
<div className={styles.tableWrap}>
<DataTable
columns={columns}
rows={rows}
rowKey={(r) => `${r.typeKey}-${r.id}`}
onRowClick={(r) => window.open(cfg.buildLinkUrl(r), '_blank')}
emptyText="Нет документов"
/>
</div>
)}
</AppLayout>
);
};
@@ -0,0 +1,10 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
export interface ICtFormsPortalProps {
description: string;
isDarkTheme: boolean;
environmentMessage: string;
hasTeamsContext: boolean;
userDisplayName: string;
context: WebPartContext;
}
@@ -0,0 +1,78 @@
import { DocumentTypeConfig, DashboardRow } from '../registry/documentRegistry';
const STATUS_LABEL: Record<string, string> = {
'Черновик': 'Сформирована',
'На согласовании': 'На согласовании',
'Согласовано': 'Согласована',
'Отклонено': 'Отклонена'
};
const STATUS_VARIANT: Record<string, DashboardRow['statusVariant']> = {
'Черновик': 'neutral',
'На согласовании': 'info',
'Согласовано': 'success',
'Отклонено': 'error'
};
export function createCtDocumentConfig(): DocumentTypeConfig {
return {
typeKey: 'ct',
typeLabel: 'СТ ОМТС',
icon: 'ShoppingCart',
// Списки СТ ОМТС — на сайте СЭД. Явно, чтобы портал не зависел от места размещения.
siteUrl: 'https://tnsukz.sharepoint.com/sites/sed',
myDocsListName: 'Сравнительная таблица',
buildMyDocsFilter: (userId) => `AuthorId eq ${userId}`,
myDocsSelect: 'Id,Title,number,project,status,Created',
pendingApproval: {
approvalListName: 'Согласование СТ',
buildPendingFilter: (userId) => `aproover/Id eq ${userId} and status eq 'На согласовании'`,
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: (parentIds) => ({
listName: 'Сравнительная таблица',
filter: parentIds.map((id) => `Id eq ${id}`).join(' or '),
select: 'Id,Title,number,project,status,Created'
})
},
approvedByMe: {
approvalListName: 'Согласование СТ',
buildPendingFilter: (userId) => `aproover/Id eq ${userId} and status eq 'Согласовано'`,
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: (parentIds) => ({
listName: 'Сравнительная таблица',
filter: parentIds.map((id) => `Id eq ${id}`).join(' or '),
select: 'Id,Title,number,project,status,Created'
})
},
buildLinkUrl: (row) => {
const base = 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица.aspx';
const param = row.status === 'Черновик' ? 'ctId' : 'ctid';
return `${base}?${param}=${row.id}`;
},
createNewUrl: 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица.aspx',
createNewLabel: 'Создать новую форму СТ',
mapItem: (raw): DashboardRow => {
const spStatus = String(raw['status'] ?? '');
return {
typeKey: 'ct',
id: String(raw['Id']),
title: `СТ №${raw['number'] ?? raw['Title']}`,
subtitle: String(raw['project'] ?? ''),
status: spStatus,
statusLabel: STATUS_LABEL[spStatus] ?? spStatus,
statusVariant: STATUS_VARIANT[spStatus] ?? 'neutral',
createdDate: raw['Created'] ? String(raw['Created']) : undefined
};
},
columns: [
{ key: 'title', label: 'Номер СТ', minWidth: 120 },
{ key: 'subtitle', label: 'Проект', minWidth: 180 },
{ key: 'status', label: 'Статус', minWidth: 130 },
{ key: 'created', label: 'Создана', minWidth: 110 }
]
};
}
@@ -0,0 +1,11 @@
import { DocumentTypeConfig } from '../registry/documentRegistry';
import { createCtDocumentConfig } from './ctDocumentConfig';
import { createOidDocumentConfig } from './oidDocumentConfig';
import { createKpsDocumentConfig } from './kpsDocumentConfig';
// Все типы документов портала. Добавить тип = добавить конфиг сюда.
export const DOCUMENT_TYPES: DocumentTypeConfig[] = [
createCtDocumentConfig(),
createOidDocumentConfig(),
createKpsDocumentConfig()
];
@@ -0,0 +1,80 @@
import { DocumentTypeConfig, DashboardRow } from '../registry/documentRegistry';
const KPS_SITE_URL = 'https://tnsukz.sharepoint.com/sites/kps';
const KPS_FORM_PAGE = `${KPS_SITE_URL}/SitePages/Сравнительная-таблица.aspx`;
const STATUS_LABEL: Record<string, string> = {
'Черновик': 'Сформирована',
'На согласовании': 'На согласовании',
'Согласовано': 'Согласована',
'Отклонено': 'Отклонена'
};
const STATUS_VARIANT: Record<string, DashboardRow['statusVariant']> = {
'Черновик': 'neutral',
'На согласовании': 'info',
'Согласовано': 'success',
'Отклонено': 'error'
};
export function createKpsDocumentConfig(): DocumentTypeConfig {
return {
typeKey: 'kps',
typeLabel: 'СТ КПС',
icon: 'Processing',
// Списки КПС — на отдельном сайт-коллекшене /sites/kps.
siteUrl: KPS_SITE_URL,
myDocsListName: 'Сравнительная таблица',
buildMyDocsFilter: (userId) => `AuthorId eq ${userId}`,
myDocsSelect: 'Id,Title,number,project,status,Created',
pendingApproval: {
approvalListName: 'Согласование СТ',
buildPendingFilter: (userId) => `aproover/Id eq ${userId} and status eq 'На согласовании'`,
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: (parentIds) => ({
listName: 'Сравнительная таблица',
filter: parentIds.map((id) => `Id eq ${id}`).join(' or '),
select: 'Id,Title,number,project,status,Created'
})
},
approvedByMe: {
approvalListName: 'Согласование СТ',
buildPendingFilter: (userId) => `aproover/Id eq ${userId} and status eq 'Согласовано'`,
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: (parentIds) => ({
listName: 'Сравнительная таблица',
filter: parentIds.map((id) => `Id eq ${id}`).join(' or '),
select: 'Id,Title,number,project,status,Created'
})
},
buildLinkUrl: (row) => {
const param = row.status === 'Черновик' ? 'ctId' : 'ctid';
return `${KPS_FORM_PAGE}?${param}=${row.id}`;
},
createNewUrl: KPS_FORM_PAGE,
createNewLabel: 'Создать новую форму КПС',
mapItem: (raw): DashboardRow => {
const spStatus = String(raw['status'] ?? '');
return {
typeKey: 'kps',
id: String(raw['Id']),
title: `СТ №${raw['number'] ?? raw['Title']}`,
subtitle: String(raw['project'] ?? ''),
status: spStatus,
statusLabel: STATUS_LABEL[spStatus] ?? spStatus,
statusVariant: STATUS_VARIANT[spStatus] ?? 'neutral',
createdDate: raw['Created'] ? String(raw['Created']) : undefined
};
},
columns: [
{ key: 'title', label: 'Номер СТ', minWidth: 120 },
{ key: 'subtitle', label: 'Проект', minWidth: 180 },
{ key: 'status', label: 'Статус', minWidth: 130 },
{ key: 'created', label: 'Создана', minWidth: 110 }
]
};
}
@@ -0,0 +1,84 @@
import { DocumentTypeConfig, DashboardRow } from '../registry/documentRegistry';
const STATUS_LABEL: Record<string, string> = {
'Черновик': 'Сформирована',
'formed': 'Сформирована',
'На согласовании': 'На согласовании',
'on_approval': 'На согласовании',
'Согласовано': 'Согласована',
'approved': 'Согласована',
'Отклонено': 'Отклонена'
};
const STATUS_VARIANT: Record<string, DashboardRow['statusVariant']> = {
'Черновик': 'neutral',
'formed': 'neutral',
'На согласовании': 'info',
'on_approval': 'info',
'Согласовано': 'success',
'approved': 'success',
'Отклонено': 'error'
};
export function createOidDocumentConfig(): DocumentTypeConfig {
return {
typeKey: 'oid',
typeLabel: 'СТ ОИД',
icon: 'CRMServices',
// Списки СТ ОИД — на сайте СЭД. Явно, чтобы портал не зависел от места размещения.
siteUrl: 'https://tnsukz.sharepoint.com/sites/sed',
myDocsListName: 'Сравнительная таблица ОИД',
buildMyDocsFilter: (userId) => `AuthorId eq ${userId}`,
myDocsSelect: 'Id,Title,number,project,status,Created',
pendingApproval: {
approvalListName: 'Согласование СТ ОИД',
buildPendingFilter: (userId) => `aproover/Id eq ${userId} and status eq 'На согласовании'`,
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: (parentIds) => ({
listName: 'Сравнительная таблица ОИД',
filter: parentIds.map((id) => `Id eq ${id}`).join(' or '),
select: 'Id,Title,number,project,status,Created'
})
},
approvedByMe: {
approvalListName: 'Согласование СТ ОИД',
buildPendingFilter: (userId) => `aproover/Id eq ${userId} and status eq 'Согласовано'`,
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: (parentIds) => ({
listName: 'Сравнительная таблица ОИД',
filter: parentIds.map((id) => `Id eq ${id}`).join(' or '),
select: 'Id,Title,number,project,status,Created'
})
},
buildLinkUrl: (row) => {
const base = 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица-ОИД.aspx';
const param = row.status === 'Черновик' || row.status === 'formed' ? 'oidId' : 'oidid';
return `${base}?${param}=${row.id}`;
},
createNewUrl: 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица-ОИД.aspx',
createNewLabel: 'Создать новую форму ОИД',
mapItem: (raw): DashboardRow => {
const spStatus = String(raw['status'] ?? '');
return {
typeKey: 'oid',
id: String(raw['Id']),
title: `СТ №${raw['number'] ?? raw['Title']}`,
subtitle: String(raw['project'] ?? ''),
status: spStatus,
statusLabel: STATUS_LABEL[spStatus] ?? spStatus,
statusVariant: STATUS_VARIANT[spStatus] ?? 'neutral',
createdDate: raw['Created'] ? String(raw['Created']) : undefined
};
},
columns: [
{ key: 'title', label: 'Номер СТ', minWidth: 120 },
{ key: 'subtitle', label: 'Проект', minWidth: 180 },
{ key: 'status', label: 'Статус', minWidth: 130 },
{ key: 'created', label: 'Создана', minWidth: 110 }
]
};
}
@@ -0,0 +1,53 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
// Унифицированная строка реестра (перенесено из ct-forms-list/myDocuments).
export interface DashboardRow {
typeKey: string;
id: string;
title: string;
subtitle?: string;
status: string;
statusLabel: string;
statusVariant: 'neutral' | 'info' | 'success' | 'error';
createdDate?: string;
}
export interface ColumnDef {
key: string;
label: string;
minWidth?: number;
}
export interface ApprovalSectionConfig {
approvalListName: string;
buildPendingFilter: (userId: number) => string;
approvalSelect: string;
approvalExpand?: string;
/** поле в записи согласования, хранящее ID родителя */
parentIdField: string;
buildParentQuery: (parentIds: number[]) => { listName: string; filter: string; select: string };
}
// Конфиг типа документа (CT/OID/KPS): где лежат SP-списки, как фильтровать,
// как маппить в строку и как открыть форму. Различия типов живут ЗДЕСЬ.
export interface DocumentTypeConfig {
typeKey: string;
typeLabel: string;
/** Имя иконки Fluent (легаси из myDocuments); в портале сайдбар берёт kit-иконку по typeKey. */
icon?: string;
/** Абсолютный URL сайта со списками этого типа. Не задан — текущий сайт. */
siteUrl?: string;
myDocsListName: string;
buildMyDocsFilter: (userId: number) => string;
myDocsSelect: string;
myDocsExpand?: string;
/** null = нет согласования у этого типа */
pendingApproval: ApprovalSectionConfig | null;
/** null = нет вкладки "Согласованные мной" */
approvedByMe: ApprovalSectionConfig | null;
mapItem: (raw: Record<string, unknown>, context: WebPartContext) => DashboardRow;
buildLinkUrl: (row: DashboardRow) => string;
createNewUrl?: string;
createNewLabel?: string;
columns: ColumnDef[];
}
@@ -0,0 +1,120 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { SPHttpClient } from '@microsoft/sp-http';
import { DocumentTypeConfig, DashboardRow } from './documentRegistry';
// Загрузка реестра из SharePoint (перенесено из ct-forms-list/myDocuments).
// Поддерживает типы на разных сайт-коллекшенах (KPS на /sites/kps).
export class DocumentsService {
constructor(private readonly context: WebPartContext) {}
private readonly userIdCache = new Map<string, number>();
get currentUserId(): number {
return Number((this.context.pageContext.legacyPageContext as Record<string, unknown>)['userId'] ?? 0);
}
private get webUrl(): string {
return this.context.pageContext.web.absoluteUrl;
}
private siteUrlFor(config: DocumentTypeConfig): string {
return config.siteUrl ?? this.webUrl;
}
private async resolveUserId(config: DocumentTypeConfig): Promise<number> {
const site = this.siteUrlFor(config);
if (site === this.webUrl) return this.currentUserId;
const cached = this.userIdCache.get(site);
if (cached !== undefined) return cached;
const response = await this.context.spHttpClient.get(
`${site}/_api/web/currentUser?$select=Id`,
SPHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`Не удалось определить пользователя на сайте "${site}": ${response.status}`);
}
const json: { Id: number } = await response.json();
this.userIdCache.set(site, json.Id);
return json.Id;
}
private buildListUrl(siteUrl: string, listName: string): string {
return `${siteUrl}/_api/web/lists/getByTitle('${listName}')/items`;
}
private async fetchItems(
siteUrl: string,
listName: string,
params: { filter?: string; select?: string; expand?: string; orderby?: string; top?: number }
): Promise<Record<string, unknown>[]> {
const q: string[] = [];
if (params.filter) q.push(`$filter=${params.filter}`);
if (params.select) q.push(`$select=${params.select}`);
if (params.expand) q.push(`$expand=${params.expand}`);
if (params.orderby) q.push(`$orderby=${params.orderby}`);
if (params.top !== undefined) q.push(`$top=${params.top}`);
const url = q.length > 0 ? `${this.buildListUrl(siteUrl, listName)}?${q.join('&')}` : this.buildListUrl(siteUrl, listName);
const response = await this.context.spHttpClient.get(url, SPHttpClient.configurations.v1);
if (!response.ok) {
throw new Error(`Ошибка загрузки списка "${listName}": ${response.status}`);
}
const json: { value: Record<string, unknown>[] } = await response.json();
return json.value;
}
async fetchMyDocs(config: DocumentTypeConfig): Promise<DashboardRow[]> {
const siteUrl = this.siteUrlFor(config);
const userId = await this.resolveUserId(config);
const items = await this.fetchItems(siteUrl, config.myDocsListName, {
filter: config.buildMyDocsFilter(userId),
select: config.myDocsSelect,
expand: config.myDocsExpand,
orderby: 'Created desc',
top: 100
});
return items.map((item) => config.mapItem(item, this.context));
}
async fetchApprovedByMe(config: DocumentTypeConfig): Promise<DashboardRow[]> {
if (!config.approvedByMe) return [];
return this._fetchApprovalSection(config, config.approvedByMe);
}
async fetchPendingApprovals(config: DocumentTypeConfig): Promise<DashboardRow[]> {
if (!config.pendingApproval) return [];
return this._fetchApprovalSection(config, config.pendingApproval);
}
private async _fetchApprovalSection(
config: DocumentTypeConfig,
pa: NonNullable<DocumentTypeConfig['pendingApproval']>
): Promise<DashboardRow[]> {
const siteUrl = this.siteUrlFor(config);
const userId = await this.resolveUserId(config);
const approvalItems = await this.fetchItems(siteUrl, pa.approvalListName, {
filter: pa.buildPendingFilter(userId),
select: pa.approvalSelect,
expand: pa.approvalExpand,
top: 200
});
const parentIds = Array.from(
new Set(approvalItems.map((item) => Number(item[pa.parentIdField])).filter((id) => !isNaN(id) && id > 0))
);
if (parentIds.length === 0) return [];
const parentQuery = pa.buildParentQuery(parentIds);
const parentItems = await this.fetchItems(siteUrl, parentQuery.listName, {
filter: parentQuery.filter,
select: parentQuery.select,
top: 200
});
return parentItems.map((item) => config.mapItem(item, this.context));
}
}
@@ -0,0 +1,52 @@
import { useState, useEffect, useCallback } from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { DashboardRow, DocumentTypeConfig } from './documentRegistry';
import { DocumentsService } from './documentsService';
interface IDashboardState {
myDocs: DashboardRow[];
approvedDocs: DashboardRow[];
pendingDocs: DashboardRow[];
isLoading: boolean;
error: string | null;
reload: () => void;
}
// Грузит три очереди (мои / согласованные / на согласовании) по всем типам сразу.
export function useDocumentDashboard(
context: WebPartContext,
documentTypes: DocumentTypeConfig[]
): IDashboardState {
const [myDocs, setMyDocs] = useState<DashboardRow[]>([]);
const [approvedDocs, setApprovedDocs] = useState<DashboardRow[]>([]);
const [pendingDocs, setPendingDocs] = useState<DashboardRow[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState<number>(0);
const reload = useCallback(() => setTick((t) => t + 1), []);
useEffect(() => {
const service = new DocumentsService(context);
setIsLoading(true);
setError(null);
Promise.all([
Promise.all(documentTypes.map((cfg) => service.fetchMyDocs(cfg))),
Promise.all(documentTypes.map((cfg) => service.fetchApprovedByMe(cfg))),
Promise.all(documentTypes.map((cfg) => service.fetchPendingApprovals(cfg)))
])
.then(([myDocGroups, approvedGroups, pendingGroups]) => {
setMyDocs(myDocGroups.reduce((acc, arr) => acc.concat(arr), [] as DashboardRow[]));
setApprovedDocs(approvedGroups.reduce((acc, arr) => acc.concat(arr), [] as DashboardRow[]));
setPendingDocs(pendingGroups.reduce((acc, arr) => acc.concat(arr), [] as DashboardRow[]));
setIsLoading(false);
})
.catch((err: Error) => {
setError(err.message ?? 'Произошла ошибка при загрузке данных');
setIsLoading(false);
});
}, [context, tick]); // eslint-disable-line react-hooks/exhaustive-deps
return { myDocs, approvedDocs, pendingDocs, isLoading, error, reload };
}
+16
View File
@@ -0,0 +1,16 @@
define([], function() {
return {
"PropertyPaneDescription": "Description",
"BasicGroupName": "Group Name",
"DescriptionFieldLabel": "Description Field",
"AppLocalEnvironmentSharePoint": "The app is running on your local environment as SharePoint web part",
"AppLocalEnvironmentTeams": "The app is running on your local environment as Microsoft Teams app",
"AppLocalEnvironmentOffice": "The app is running on your local environment in office.com",
"AppLocalEnvironmentOutlook": "The app is running on your local environment in Outlook",
"AppSharePointEnvironment": "The app is running on SharePoint page",
"AppTeamsTabEnvironment": "The app is running in Microsoft Teams",
"AppOfficeEnvironment": "The app is running in office.com",
"AppOutlookEnvironment": "The app is running in Outlook",
"UnknownEnvironment": "The app is running in an unknown environment"
}
});
+19
View File
@@ -0,0 +1,19 @@
declare interface ICtFormsPortalWebPartStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
DescriptionFieldLabel: string;
AppLocalEnvironmentSharePoint: string;
AppLocalEnvironmentTeams: string;
AppLocalEnvironmentOffice: string;
AppLocalEnvironmentOutlook: string;
AppSharePointEnvironment: string;
AppTeamsTabEnvironment: string;
AppOfficeEnvironment: string;
AppOutlookEnvironment: string;
UnknownEnvironment: string;
}
declare module 'CtFormsPortalWebPartStrings' {
const strings: ICtFormsPortalWebPartStrings;
export = strings;
}