feat: config-driven реестр на реальных данных SP
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user