feat: request portal — bespoke forms + approval mechanism
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
gap: $sp-1;
|
||||
padding: 0 $sp-5;
|
||||
background: $grad-header;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: 0 1px 0 rgba(212, 160, 30, .35), $sh-2;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
padding: 14px 18px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, .5);
|
||||
font-family: $font-sans;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: .2px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color .18s, border-color .18s;
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, .85);
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
color: $gold-300;
|
||||
border-bottom-color: $gold-400;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react';
|
||||
import styles from './CategoryTabs.module.scss';
|
||||
import { Icon } from '../../shared/ui/Icon/Icon';
|
||||
import { ICategory } from '../../models/types';
|
||||
|
||||
interface ICategoryTabsProps {
|
||||
categories: ICategory[];
|
||||
activeId: string;
|
||||
onChange: (categoryId: string) => void;
|
||||
}
|
||||
|
||||
export const CategoryTabs: React.FC<ICategoryTabsProps> = ({ categories, activeId, onChange }) => {
|
||||
return (
|
||||
<nav className={styles.tabs} aria-label="Категории заявок">
|
||||
{categories.map((category) => {
|
||||
const isActive = category.id === activeId;
|
||||
const tabClasses = [styles.tab, isActive ? styles.active : ''].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
className={tabClasses}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => onChange(category.id)}
|
||||
>
|
||||
<Icon name={category.icon} size="sm" />
|
||||
<span>{category.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
|
||||
export interface IRequestPortalProps {
|
||||
description: string;
|
||||
isDarkTheme: boolean;
|
||||
environmentMessage: string;
|
||||
hasTeamsContext: boolean;
|
||||
userDisplayName: string;
|
||||
context: WebPartContext;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: $sp-6;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
padding: 22px 24px;
|
||||
text-align: left;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
box-shadow: $sh-1;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
font-family: $font-sans;
|
||||
transition: box-shadow .22s, transform .22s, border-color .22s;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: $grad-gold-v;
|
||||
opacity: 0;
|
||||
transition: opacity .22s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: $sh-3;
|
||||
transform: translateY(-2px);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tileIcon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: $r-md;
|
||||
background: $navy-50;
|
||||
border: 1px solid $navy-100;
|
||||
color: $navy-700;
|
||||
transition: background .22s, color .22s, border-color .22s;
|
||||
|
||||
.tile:hover & {
|
||||
background: $gold-50;
|
||||
border-color: $gold-300;
|
||||
color: $gold-600;
|
||||
}
|
||||
}
|
||||
|
||||
.tileBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tileTitle {
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .1px;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.tileDesc {
|
||||
font-size: 12px;
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
.tileArrow {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
color: $gray-300;
|
||||
transition: color .22s, transform .22s;
|
||||
|
||||
.tile:hover & {
|
||||
color: $gold-500;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin-top: $sp-6;
|
||||
color: $gray-500;
|
||||
font-size: $fs-sm;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RequestGrid.module.scss';
|
||||
import { Icon } from '../../shared/ui/Icon/Icon';
|
||||
import { IRequestType } from '../../models/types';
|
||||
|
||||
interface IRequestGridProps {
|
||||
requests: IRequestType[];
|
||||
onSelect: (request: IRequestType) => void;
|
||||
}
|
||||
|
||||
export const RequestGrid: React.FC<IRequestGridProps> = ({ requests, onSelect }) => {
|
||||
if (requests.length === 0) {
|
||||
return <p className={styles.empty}>В этой категории пока нет заявок.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.grid}>
|
||||
{requests.map((request) => (
|
||||
<button
|
||||
key={request.id}
|
||||
type="button"
|
||||
className={styles.tile}
|
||||
onClick={() => onSelect(request)}
|
||||
>
|
||||
<span className={styles.tileIcon}>
|
||||
<Icon name={request.icon} />
|
||||
</span>
|
||||
<span className={styles.tileBody}>
|
||||
<span className={styles.tileTitle}>{request.title}</span>
|
||||
{request.description && <span className={styles.tileDesc}>{request.description}</span>}
|
||||
</span>
|
||||
<span className={styles.tileArrow}>
|
||||
<Icon name="chevron-right" size="sm" />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
@import '../../../styles/design-tokens';
|
||||
|
||||
// Full-width: снимаем max-width канвы SharePoint — и в workbench (dev), и на живой
|
||||
// странице. !important оправдан: перебиваем встроенные стили канвы, иначе их не снять.
|
||||
// Если после обновления платформы ширина вернётся — проверить актуальные селекторы в DevTools.
|
||||
:global {
|
||||
#workbenchPageContent,
|
||||
.CanvasComponent.LCS .CanvasZone {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.requestPortal {
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-body;
|
||||
line-height: 1.55;
|
||||
color: $gray-900;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
box-sizing: border-box;
|
||||
|
||||
// Полная ширина обеспечивается через "supportsFullBleed": true в манифесте
|
||||
// + размещение web part в полноширинной секции (как в projects-list).
|
||||
// Корень занимает 100% контейнера, без CSS-трюков с 100vw.
|
||||
width: 100%;
|
||||
|
||||
// Только вертикальные отступы; горизонтальные — на внутренних секциях.
|
||||
padding: $sp-4 $sp-5 $sp-8;
|
||||
}
|
||||
|
||||
// Центрированная колонка контента. 1080px — ширина контента из дизайн-системы
|
||||
// (как .sec в референсе): форма и плитки читаются аккуратно, не растягиваясь.
|
||||
.content {
|
||||
width: 100%;
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.teams {
|
||||
// Задел под специфику Teams-контекста; пока совпадает с базовым.
|
||||
font-family: $font-sans;
|
||||
}
|
||||
|
||||
// ── Заглушка формы (до появления реальных форм) ──
|
||||
.formPlaceholder {
|
||||
margin-top: $sp-6;
|
||||
padding: $sp-8;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: $sh-1;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
margin-bottom: $sp-4;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $navy-700;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
cursor: pointer;
|
||||
transition: color .18s;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholderText {
|
||||
font-family: $font-serif;
|
||||
font-size: 18px;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.loading {
|
||||
margin-top: $sp-6;
|
||||
padding: $sp-8;
|
||||
color: $gray-500;
|
||||
font-size: $fs-sm;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import styles from './RequestPortal.module.scss';
|
||||
import type { IRequestPortalProps } from './IRequestPortalProps';
|
||||
import { IconSprite } from '../shared/ui/Icon/IconSprite';
|
||||
import { Icon } from '../shared/ui/Icon/Icon';
|
||||
import { CategoryTabs } from './CategoryTabs/CategoryTabs';
|
||||
import { RequestGrid } from './RequestGrid/RequestGrid';
|
||||
import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../data/categories';
|
||||
import { getRequestsByCategory } from '../data/requests';
|
||||
import { getFormComponent } from '../forms/registry';
|
||||
import { IRequestType } from '../models/types';
|
||||
|
||||
const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context }) => {
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<string>(DEFAULT_CATEGORY_ID);
|
||||
const [selectedRequest, setSelectedRequest] = useState<IRequestType | null>(null);
|
||||
|
||||
const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const requests = getRequestsByCategory(activeCategoryId);
|
||||
|
||||
const handleCategoryChange = (categoryId: string): void => {
|
||||
setActiveCategoryId(categoryId);
|
||||
setSelectedRequest(null); // при смене категории возвращаемся к списку плиток
|
||||
};
|
||||
|
||||
const handleBack = (): void => setSelectedRequest(null);
|
||||
|
||||
const renderSelected = (request: IRequestType): React.ReactNode => {
|
||||
const FormComponent = getFormComponent(request.id);
|
||||
|
||||
// Форма ещё не реализована — показываем заглушку.
|
||||
if (!FormComponent) {
|
||||
return (
|
||||
<div className={styles.formPlaceholder}>
|
||||
<button type="button" className={styles.backButton} onClick={handleBack}>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
<span>К списку заявок</span>
|
||||
</button>
|
||||
<p className={styles.placeholderText}>Форма «{request.title}» ещё в разработке.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ленивый рендер формы: её код подтягивается только сейчас, при открытии.
|
||||
return (
|
||||
<React.Suspense fallback={<div className={styles.loading}>Загрузка формы…</div>}>
|
||||
<FormComponent requestType={request} context={context} onCancel={handleBack} />
|
||||
</React.Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={rootClasses}>
|
||||
<IconSprite />
|
||||
<div className={styles.content}>
|
||||
<CategoryTabs
|
||||
categories={CATEGORIES}
|
||||
activeId={activeCategoryId}
|
||||
onChange={handleCategoryChange}
|
||||
/>
|
||||
|
||||
{selectedRequest ? (
|
||||
renderSelected(selectedRequest)
|
||||
) : (
|
||||
<RequestGrid requests={requests} onSelect={setSelectedRequest} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestPortal;
|
||||
Reference in New Issue
Block a user