refactor: миграция форм на ui-kit, удаление локальных дублей
Tests / frontend-test (push) Successful in 4m11s

This commit is contained in:
Тимур Городецкий
2026-08-14 11:09:11 +05:00
parent 9d04fb4be4
commit c98a665611
57 changed files with 367 additions and 1709 deletions
+315
View File
@@ -0,0 +1,315 @@
# =============================================================
# SPFx + Heft project bootstrap script (PowerShell)
# ВАРИАНТ С ПОДКЛЮЧЕНИЕМ ОБЩЕЙ UI-БИБЛИОТЕКИ @tnsu/ui-kit.
# Запустите из корня вашей ПУСТОЙ папки проекта:
# .\create-spfx-uikit.ps1
#
# Если запуск скриптов заблокирован, выполните сначала:
# Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
#
# Требование: у запускающего должен быть токен @tnsu в ~/.npmrc
# (см. README пакета tnsu-ui-kit) — иначе установка @tnsu/ui-kit не пройдёт.
# =============================================================
$ErrorActionPreference = "Stop"
# ---- Цвета вывода ----
function Info { param($msg) Write-Host "[INFO] $msg" -ForegroundColor Cyan }
function Success { param($msg) Write-Host "[OK] $msg" -ForegroundColor Green }
function Warn { param($msg) Write-Host "[WARN] $msg" -ForegroundColor Yellow }
function Header { param($msg) Write-Host "`n$msg" -ForegroundColor White }
Header "================================================="
Header " SPFx + Heft — автоматическая инициализация"
Header " (с общей UI-библиотекой @tnsu/ui-kit)"
Header "=================================================`n"
# ---------------------------------------------------------
# 1. Проверка Node.js (SPFx 1.20+ требует Node 18+)
# ---------------------------------------------------------
Info "Проверка Node.js..."
try {
$nodeVersion = node -e "process.stdout.write(process.version)" 2>&1
} catch {
Warn "Node.js не найден. Установите Node 18 LTS: https://nodejs.org"
exit 1
}
$nodeMajor = [int]($nodeVersion.TrimStart('v').Split('.')[0])
if ($nodeMajor -lt 18) {
Warn "Обнаружен Node.js $nodeVersion. SPFx 1.20+ требует Node 18.x или выше."
Warn "Установите Node 18 LTS через nvm-windows или с https://nodejs.org"
exit 1
}
Success "Node.js $nodeVersion — подходит."
# ---------------------------------------------------------
# 2. Вспомогательная функция: установка глобального пакета
# ---------------------------------------------------------
function Install-GlobalIfMissing {
param(
[string]$Package,
[string]$Command = ""
)
if ($Command -eq "") { $Command = $Package.Split('/')[-1] }
if (-not (Get-Command $Command -ErrorAction SilentlyContinue)) {
Info "Устанавливаем $Package глобально..."
npm install -g $Package
Success "$Package установлен."
} else {
Success "$Package уже установлен."
}
}
Install-GlobalIfMissing "@microsoft/generator-sharepoint" "yo"
Install-GlobalIfMissing "yo" "yo"
Install-GlobalIfMissing "@rushstack/heft" "heft"
# ---------------------------------------------------------
# 3. Создание package.json
# ---------------------------------------------------------
Info "Создаём package.json..."
$packageJson = @'
{
"name": "spfx-heft-project",
"version": "0.0.1",
"private": true,
"scripts": {
"build": "heft build",
"bundle": "heft build && gulp bundle --ship",
"package-solution": "heft build && gulp package-solution --ship",
"serve": "heft build && gulp serve",
"clean": "heft clean"
},
"overrides": {
"ajv": "^8.0.0"
}
}
'@
$packageJson | Set-Content -Encoding UTF8 "package.json"
Success "package.json создан."
# ---------------------------------------------------------
# 4. Создание структуры папок
# ---------------------------------------------------------
Info "Создаём структуру папок..."
New-Item -ItemType Directory -Force -Path "config" | Out-Null
New-Item -ItemType Directory -Force -Path "src\webparts" | Out-Null
Success "Папки созданы."
# ---------------------------------------------------------
# 5. Запуск Yeoman-генератора SPFx
# ---------------------------------------------------------
Info "Запускаем Yeoman-генератор SPFx..."
Write-Host ""
Write-Host ">>> Сейчас откроется интерактивный генератор Yeoman." -ForegroundColor Yellow
Write-Host ">>> Выберите тип: WebPart, Framework: No framework (или React)." -ForegroundColor Yellow
Write-Host ""
yo "@microsoft/sharepoint"
npm uninstall @microsoft/sp-build-web ajv gulp
npm uninstall @microsoft/rush-stack-compiler-5.3
# ---------------------------------------------------------
# 6. Установка Heft и Rush Stack зависимостей
# ---------------------------------------------------------
Info "Устанавливаем Heft и плагины..."
$devDeps = @(
"@microsoft/spfx-web-build-rig@1.22.1",
"@microsoft/spfx-heft-plugins@1.22.1",
"@microsoft/eslint-config-spfx@1.22.1",
"@microsoft/eslint-plugin-spfx@1.22.17",
"@microsoft/sp-module-interfaces@1.22.1",
"@rushstack/eslint-config@4.5.2",
"@rushstack/heft@1.1.2",
"@types/heft-jest@1.0.2",
"@typescript-eslint/parser@8.46.2"
)
npm install --save-dev @($devDeps) --save-exact --force --legacy-peer-deps
npm install typescript@~5.8.0 --save-dev
Success "Heft и плагины установлены."
# ---------------------------------------------------------
# 7. config/rig.json
# ---------------------------------------------------------
Info "Создаём config\rig.json..."
New-Item -ItemType Directory -Force -Path "config" | Out-Null
$rigJson = @'
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@microsoft/spfx-web-build-rig"
}
'@
$rigJson | Set-Content -Encoding UTF8 "config\rig.json"
Success "config\rig.json создан."
# ---------------------------------------------------------
# 8. config/sass.json
# ---------------------------------------------------------
Info "Создаём config\sass.json..."
New-Item -ItemType Directory -Force -Path "config" | Out-Null
$sassJson = @'
{
"$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft-sass-plugin.schema.json",
"extends": "@microsoft/spfx-web-build-rig/profiles/default/config/sass.json"
}
'@
$sassJson | Set-Content -Encoding UTF8 "config\sass.json"
Success "config\sass.json создан."
# ---------------------------------------------------------
# 9. config/typescript.json
# ---------------------------------------------------------
Info "Создаём config\typescript.json..."
New-Item -ItemType Directory -Force -Path "config" | Out-Null
$typescriptJson = @'
{
"extends": "@microsoft/spfx-web-build-rig/profiles/default/config/typescript.json",
"staticAssetsToCopy": {
"fileExtensions": [".resx", ".jpg", ".png", ".woff", ".eot", ".ttf", ".svg", ".gif"],
"includeGlobs": ["webparts/*/loc/*.js"]
}
}
'@
$typescriptJson | Set-Content -Encoding UTF8 "config\typescript.json"
Success "config\typescript.json создан."
# ---------------------------------------------------------
# 8. tsconfig.json (если Yeoman ещё не создал)
# ---------------------------------------------------------
if (-not (Test-Path "tsconfig.json")) {
Info "Создаём tsconfig.json..."
$tsConfig = @'
{
"extends": "./node_modules/@microsoft/spfx-web-build-rig/profiles/default/tsconfig-base.json"
}
'@
$tsConfig | Set-Content -Encoding UTF8 "tsconfig.json"
Success "tsconfig.json создан."
} else {
Success "tsconfig.json уже существует, пропускаем."
}
# ---------------------------------------------------------
# 9. .eslintrc.js (если не создан)
# ---------------------------------------------------------
if (-not (Test-Path ".eslintrc.js")) {
Info "Создаём .eslintrc.js..."
$eslintConfig = @'
require('@rushstack/eslint-config/patch/modern-module-resolution');
module.exports = {
extends: ['@microsoft/eslint-config-spfx/lib/profiles/default'],
parserOptions: { tsconfigRootDir: __dirname }
};
'@
$eslintConfig | Set-Content -Encoding UTF8 ".eslintrc.js"
Success ".eslintrc.js создан."
} else {
Success ".eslintrc.js уже существует, пропускаем."
}
# ---------------------------------------------------------
# 10. .gitignore
# ---------------------------------------------------------
Info "Создаём .gitignore..."
$gitignore = @'
node_modules/
lib/
dist/
temp/
*.sppkg
.heft/
sharepoint/solution/
'@
$gitignore | Set-Content -Encoding UTF8 ".gitignore"
Success ".gitignore создан."
# ---------------------------------------------------------
# 11. Финальный npm install
# ---------------------------------------------------------
$devDeps = @(
"@microsoft/sp-component-base@1.22.1",
"@microsoft/sp-core-library@1.22.1",
"@microsoft/sp-lodash-subset@1.22.1",
"@microsoft/sp-office-ui-fabric-core@1.22.1",
"@microsoft/sp-property-pane@1.22.1",
"@microsoft/sp-webpart-base@1.22.1"
)
Info "Финальный npm install..."
npm install @($devDeps) --save-exact --legacy-peer-deps
Success "Все зависимости установлены."
# ---------------------------------------------------------
# 12. Подключение общей UI-библиотеки @tnsu/ui-kit
# ---------------------------------------------------------
Info "Подключаем @tnsu/ui-kit..."
# .npmrc: адрес registry для скоупа @tnsu (НЕ секрет, можно коммитить).
# Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём).
$npmrc = @'
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
'@
$npmrc | Set-Content -Encoding UTF8 ".npmrc"
Success ".npmrc создан (адрес registry @tnsu)."
try {
npm install @tnsu/ui-kit
Success "@tnsu/ui-kit установлен."
Write-Host " Импорт: import { Button } from '@tnsu/ui-kit';" -ForegroundColor DarkGray
Write-Host " Стили: import '@tnsu/ui-kit/styles.css'; (один раз в корневом компоненте)" -ForegroundColor DarkGray
} catch {
Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc."
Warn "Создай токен (Gitea → Настройки → Приложения → package: Read) и пропиши в ~/.npmrc:"
Warn " //git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=ТВОЙ_ТОКЕН"
Warn "Потом выполни: npm install @tnsu/ui-kit"
}
# ---------------------------------------------------------
# Готово
# ---------------------------------------------------------
Write-Host ""
Write-Host "=================================================" -ForegroundColor Green
Write-Host " Проект успешно инициализирован!" -ForegroundColor Green
Write-Host "=================================================" -ForegroundColor Green
Write-Host ""
Write-Host " Доступные команды:" -ForegroundColor White
Write-Host ""
Write-Host " npm run serve " -NoNewline -ForegroundColor Cyan
Write-Host "— локальная разработка (gulp serve)"
Write-Host " npm run build " -NoNewline -ForegroundColor Cyan
Write-Host "— сборка через Heft"
Write-Host " npm run bundle " -NoNewline -ForegroundColor Cyan
Write-Host "— бандлинг для продакшна"
Write-Host " npm run package-solution " -NoNewline -ForegroundColor Cyan
Write-Host "— создание .sppkg пакета"
Write-Host " npm run clean " -NoNewline -ForegroundColor Cyan
Write-Host "— очистка артефактов сборки"
Write-Host ""
Write-Host " UI-библиотека: " -NoNewline -ForegroundColor Yellow
Write-Host "@tnsu/ui-kit подключён (см. .npmrc)" -ForegroundColor Cyan
Write-Host ""
Write-Host " Следующий шаг: " -NoNewline -ForegroundColor Yellow
Write-Host "npm run serve" -ForegroundColor Cyan
Write-Host ""
+4 -4
View File
@@ -17,7 +17,7 @@
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0", "@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0", "@pnp/spfx-controls-react": "^3.25.0",
"@tnsu/ui-kit": "^0.0.1", "@tnsu/ui-kit": "^0.2.0",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
"tslib": "2.3.1" "tslib": "2.3.1"
@@ -9870,9 +9870,9 @@
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/@tnsu/ui-kit": { "node_modules/@tnsu/ui-kit": {
"version": "0.0.1", "version": "0.2.0",
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.0.1/ui-kit-0.0.1.tgz", "resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.2.0/ui-kit-0.2.0.tgz",
"integrity": "sha512-fdw5KEsahQ10qT/vf/ZWvwMHorcfk7ketUkvgUa8Zlg8imVWTCq1qxxEkS439qXPZw/vTtYYpkSzgM1JOGOFNg==", "integrity": "sha512-8vQsA91lSbmtlqROlIYkgSH3cNEH5DCVI5vM4mks2RWwvSpYet0zfe2B3wfBcnb+cAWrbLGfCwe81KRDcJ/k+Q==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"@fluentui/react": "^8.106.4", "@fluentui/react": "^8.106.4",
+1 -1
View File
@@ -21,7 +21,7 @@
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0", "@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0", "@pnp/spfx-controls-react": "^3.25.0",
"@tnsu/ui-kit": "^0.0.1", "@tnsu/ui-kit": "^0.2.0",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
"tslib": "2.3.1" "tslib": "2.3.1"
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import styles from './CategoryTabs.module.scss'; import styles from './CategoryTabs.module.scss';
import { Icon } from '../../shared/ui/Icon/Icon'; import { Icon } from '@tnsu/ui-kit';
import { ICategory } from '../../models/types'; import { ICategory } from '../../models/types';
interface ICategoryTabsProps { interface ICategoryTabsProps {
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import styles from './Drawer.module.scss'; import styles from './Drawer.module.scss';
import { Icon } from '../../shared/ui/Icon/Icon'; import { Icon } from '@tnsu/ui-kit';
interface IDrawerProps { interface IDrawerProps {
title: string; title: string;
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import styles from './RequestGrid.module.scss'; import styles from './RequestGrid.module.scss';
import { Icon } from '../../shared/ui/Icon/Icon'; import { Icon } from '@tnsu/ui-kit';
import { IRequestType } from '../../models/types'; import { IRequestType } from '../../models/types';
interface IRequestGridProps { interface IRequestGridProps {
@@ -2,7 +2,7 @@ import * as React from 'react';
import styles from './RequestPortal.module.scss'; import styles from './RequestPortal.module.scss';
import '@tnsu/ui-kit/styles.css'; import '@tnsu/ui-kit/styles.css';
import type { IRequestPortalProps } from './IRequestPortalProps'; import type { IRequestPortalProps } from './IRequestPortalProps';
import { IconSprite } from '../shared/ui/Icon/IconSprite'; import { IconSprite } from '@tnsu/ui-kit';
import { ConfirmHost } from '../shared/ui'; import { ConfirmHost } from '../shared/ui';
import { import {
navigate, navigate,
@@ -1,7 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import styles from './SideNav.module.scss'; import styles from './SideNav.module.scss';
import { Icon } from '../../shared/ui/Icon/Icon'; import { Icon, IconName } from '@tnsu/ui-kit';
import { IconName } from '../../shared/ui/Icon/iconNames';
import { NavSection } from '../../shared/routing'; import { NavSection } from '../../shared/routing';
interface INavItem { interface INavItem {
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useMemo, useRef } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './SubmitFeature.module.scss'; import styles from './SubmitFeature.module.scss';
import { Icon } from '../../shared/ui/Icon/Icon'; import { Icon } from '@tnsu/ui-kit';
import { RequestGrid } from '../../components/RequestGrid/RequestGrid'; import { RequestGrid } from '../../components/RequestGrid/RequestGrid';
import { getRequestsByCategory, REQUEST_TYPES } from '../../data/requests'; import { getRequestsByCategory, REQUEST_TYPES } from '../../data/requests';
import { getFormComponent } from '../../forms/registry'; import { getFormComponent } from '../../forms/registry';
+1 -1
View File
@@ -1,5 +1,5 @@
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { IconName } from '../shared/ui/Icon/iconNames'; import { IconName } from '@tnsu/ui-kit';
/** Идентификатор типа заявки — ключ в реестре форм (formRegistry). */ /** Идентификатор типа заявки — ключ в реестре форм (formRegistry). */
export type RequestTypeId = string; export type RequestTypeId = string;
@@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './DepartmentPicker.module.scss'; import styles from './DepartmentPicker.module.scss';
import { useFormReadOnly } from '../../ui/FormMode'; import { useFormReadOnly } from '@tnsu/ui-kit';
import { getDepartments } from '../departments.service'; import { getDepartments } from '../departments.service';
import { IDepartment } from '../models/IDepartment'; import { IDepartment } from '../models/IDepartment';
@@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './ProjectPicker.module.scss'; import styles from './ProjectPicker.module.scss';
import { useFormReadOnly } from '../../ui/FormMode'; import { useFormReadOnly } from '@tnsu/ui-kit';
import { getProjects } from '../projects.service'; import { getProjects } from '../projects.service';
import { IProject } from '../models/IProject'; import { IProject } from '../models/IProject';
@@ -1,30 +0,0 @@
@import '../../../../../styles/design-tokens';
.alert {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border: 1px solid;
border-radius: $r-md;
font-size: 12.5px;
margin-bottom: 18px;
}
.warn {
background: $gold-50;
color: $amber-700;
border-color: $gold-300;
}
.ok {
background: $green-50;
color: $green-600;
border-color: #bbf7d0;
}
.err {
background: $red-50;
color: $red-500;
border-color: #fecaca;
}
@@ -1,34 +0,0 @@
import * as React from 'react';
import styles from './Alert.module.scss';
import { Icon } from '../Icon/Icon';
import { IconName } from '../Icon/iconNames';
type AlertVariant = 'warn' | 'ok' | 'err';
interface IAlertProps {
variant: AlertVariant;
children: React.ReactNode;
}
const variantClass: Record<AlertVariant, string> = {
warn: styles.warn,
ok: styles.ok,
err: styles.err
};
const variantIcon: Record<AlertVariant, IconName> = {
warn: 'warning',
ok: 'check',
err: 'close'
};
export const Alert: React.FC<IAlertProps> = ({ variant, children }) => {
const classes = [styles.alert, variantClass[variant]].join(' ');
return (
<div className={classes} role="status">
<Icon name={variantIcon[variant]} size="sm" />
<span>{children}</span>
</div>
);
};
@@ -1,59 +0,0 @@
@import '../../../../../styles/design-tokens';
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $gray-50;
font-size: 13px;
}
.link {
flex: 1;
min-width: 0;
color: $navy-700;
text-decoration: none;
&:hover {
color: $gold-600;
text-decoration: underline;
}
}
.removeBtn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
color: $gray-500;
cursor: pointer;
transition: .16s;
&:hover {
border-color: $red-500;
color: $red-500;
}
}
.empty {
color: $gray-500;
font-size: $fs-sm;
}
@@ -1,41 +0,0 @@
import * as React from 'react';
import styles from './AttachmentList.module.scss';
import { Icon } from '../Icon/Icon';
import { IAttachmentInfo } from '../../attachments/attachments.service';
interface IAttachmentListProps {
items: IAttachmentInfo[];
/** Если задан — рядом с файлом показывается кнопка удаления (режим правки). */
onRemove?: (fileName: string) => void;
}
/** Список вложений заявки как ссылки на скачивание; с onRemove — ещё и удаление. */
export const AttachmentList: React.FC<IAttachmentListProps> = ({ items, onRemove }) => {
if (items.length === 0) {
return <p className={styles.empty}>Вложений нет.</p>;
}
return (
<ul className={styles.list}>
{items.map((attachment) => (
<li key={attachment.url} className={styles.item}>
<Icon name="document" size="sm" />
<a href={attachment.url} target="_blank" rel="noreferrer" className={styles.link}>
{attachment.fileName}
</a>
{onRemove && (
<button
type="button"
className={styles.removeBtn}
title="Удалить вложение"
aria-label={`Удалить ${attachment.fileName}`}
onClick={() => onRemove(attachment.fileName)}
>
<Icon name="trash" size="sm" />
</button>
)}
</li>
))}
</ul>
);
};
@@ -1,73 +0,0 @@
@import '../../../../../styles/design-tokens';
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 10px 20px;
border: 1px solid transparent;
border-radius: $r-sm;
font-family: $font-sans;
font-size: 13px;
font-weight: 500;
letter-spacing: .15px;
white-space: nowrap;
cursor: pointer;
transition: .18s;
&:disabled {
opacity: .55;
cursor: default;
pointer-events: none;
}
}
.sm {
padding: 7px 14px;
font-size: 12.5px;
}
.full {
width: 100%;
}
.primary {
background: $navy-800;
border-color: $navy-800;
color: #fff;
&:hover { background: $navy-700; box-shadow: $sh-2; }
}
.accent {
background: $grad-gold;
border-color: $gold-500;
color: $navy-950;
font-weight: 600;
&:hover { box-shadow: $sh-gold; transform: translateY(-1px); }
}
.secondary {
background: $white;
border-color: $gray-300;
color: $navy-800;
&:hover { border-color: $gold-500; color: $navy-900; }
}
.ghost {
background: transparent;
color: $navy-700;
&:hover { background: $navy-100; }
}
.danger {
background: $white;
border-color: $gray-300;
color: $red-500;
&:hover { border-color: $red-500; background: $red-50; }
}
@@ -1,46 +0,0 @@
import * as React from 'react';
import styles from './Button.module.scss';
type ButtonVariant = 'primary' | 'accent' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'md' | 'sm';
interface IButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
fullWidth?: boolean;
}
const variantClass: Record<ButtonVariant, string> = {
primary: styles.primary,
accent: styles.accent,
secondary: styles.secondary,
ghost: styles.ghost,
danger: styles.danger
};
export const Button: React.FC<IButtonProps> = ({
variant = 'secondary',
size = 'md',
fullWidth = false,
type = 'button',
className,
children,
...rest
}) => {
const classes = [
styles.btn,
variantClass[variant],
size === 'sm' ? styles.sm : '',
fullWidth ? styles.full : '',
className
]
.filter(Boolean)
.join(' ');
// type по умолчанию 'button', чтобы кнопки внутри формы не сабмитили её случайно.
return (
<button type={type} className={classes} {...rest}>
{children}
</button>
);
};
@@ -1,62 +0,0 @@
@import '../../../../../styles/design-tokens';
.section {
margin-top: 8px;
}
.header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 14px 0 10px;
border: 0;
border-top: 1px solid $gray-200;
background: transparent;
cursor: pointer;
&::before {
content: '';
position: absolute;
top: -1px;
left: 0;
width: 48px;
height: 1px;
background: $gold-500;
}
}
.title {
display: flex;
align-items: center;
gap: 10px;
font-family: $font-serif;
font-size: 14.5px;
font-weight: 600;
color: $navy-900;
letter-spacing: .2px;
svg { color: $gold-600; }
}
.chevron {
display: flex;
color: $gray-400;
transition: transform .2s;
}
.open {
transform: rotate(180deg);
}
.body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px 22px;
padding-top: 16px;
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
}
@@ -1,32 +0,0 @@
import * as React from 'react';
import { useState } from 'react';
import styles from './CollapseSection.module.scss';
import { Icon } from '../Icon/Icon';
import { IconName } from '../Icon/iconNames';
interface ICollapseSectionProps {
title: string;
icon: IconName;
defaultOpen?: boolean;
children: React.ReactNode;
}
/** Сворачиваемая секция формы: заголовок с иконкой и шевроном + тело. */
export const CollapseSection: React.FC<ICollapseSectionProps> = ({ title, icon, defaultOpen = true, children }) => {
const [open, setOpen] = useState<boolean>(defaultOpen);
return (
<div className={styles.section}>
<button type="button" className={styles.header} onClick={() => setOpen((prev) => !prev)}>
<span className={styles.title}>
<Icon name={icon} size="sm" />
{title}
</span>
<span className={[styles.chevron, open ? styles.open : ''].filter(Boolean).join(' ')}>
<Icon name="chevron-down" size="sm" />
</span>
</button>
{open && <div className={styles.body}>{children}</div>}
</div>
);
};
@@ -1,98 +0,0 @@
@import '../../../../../styles/design-tokens';
// Поверх всего, включая SPFx-хром вебпарта.
.overlay {
position: fixed;
inset: 0;
z-index: 999997;
display: flex;
align-items: center;
justify-content: center;
padding: $sp-6;
background: rgba($navy-950, .55);
font-family: $font-sans;
// Плавное появление подложки.
animation: overlayIn .14s ease-out;
}
.card {
box-sizing: border-box;
width: 100%;
max-width: 440px;
padding: $sp-5 $sp-5 $sp-4;
border-radius: $r-lg;
background: $white;
box-shadow: $sh-modal;
animation: cardIn .16s ease-out;
}
.title {
font-family: $font-serif;
font-size: $fs-form-title;
font-weight: 600;
color: $navy-950;
}
.message {
margin-top: $sp-2;
font-size: $fs-sm;
line-height: 1.5;
color: $gray-700;
}
.actions {
display: flex;
justify-content: flex-end;
gap: $sp-2;
margin-top: $sp-5;
}
.cancelBtn {
padding: 9px 18px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
color: $navy-800;
font-family: $font-sans;
font-size: $fs-sm;
cursor: pointer;
transition: .15s;
&:hover {
border-color: $gold-400;
}
}
.confirmBtn {
@include btn-accent;
padding: 9px 18px;
border-radius: $r-sm;
font-size: $fs-sm;
cursor: pointer;
}
.confirmDanger {
padding: 9px 18px;
border: 0;
border-radius: $r-sm;
background: $red-500;
color: $white;
font-family: $font-sans;
font-size: $fs-sm;
cursor: pointer;
transition: .15s;
&:hover {
filter: brightness(.94);
}
}
@keyframes overlayIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(8px) scale(.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@@ -1,82 +0,0 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import styles from './ConfirmDialog.module.scss';
import { IConfirmOptions, registerConfirmHost } from './confirmModal';
interface IConfirmDialogProps extends IConfirmOptions {
onConfirm: () => void;
onCancel: () => void;
}
/** Само окно подтверждения. Рендерится в document.body поверх всего (portal). */
const ConfirmDialog: React.FC<IConfirmDialogProps> = ({
title,
message,
confirmText = 'Продолжить',
cancelText = 'Отмена',
danger,
onConfirm,
onCancel,
}) => {
// Esc — отмена. Слушаем на всё окно, пока модалка открыта.
React.useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onCancel();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onCancel]);
return createPortal(
// Клик по фону = отмена; клик внутри карточки не всплывает до фона.
<div className={styles.overlay} onMouseDown={onCancel}>
<div
className={styles.card}
role="dialog"
aria-modal="true"
onMouseDown={(e) => e.stopPropagation()}
>
{title && <div className={styles.title}>{title}</div>}
<div className={styles.message}>{message}</div>
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onCancel}>
{cancelText}
</button>
<button
type="button"
className={danger ? styles.confirmDanger : styles.confirmBtn}
onClick={onConfirm}
autoFocus
>
{confirmText}
</button>
</div>
</div>
</div>,
document.body
);
};
/**
* Хост модалки: монтируется ОДИН раз в портале. Слушает императивные вызовы
* confirmModal(...) и показывает окно; ответ пользователя резолвит их промис.
*/
export const ConfirmHost: React.FC = () => {
const [state, setState] = React.useState<{
opts: IConfirmOptions;
resolve: (ok: boolean) => void;
} | undefined>(undefined);
React.useEffect(() => registerConfirmHost((opts, resolve) => setState({ opts, resolve })), []);
if (!state) return null;
const close = (ok: boolean): void => {
state.resolve(ok);
setState(undefined);
};
return (
<ConfirmDialog {...state.opts} onConfirm={() => close(true)} onCancel={() => close(false)} />
);
};
@@ -1,44 +0,0 @@
// Императивный вызов модалки подтверждения из любого места (в т.ч. из обычных
// модулей вроде shared/unsavedChanges.ts, где нет React-контекста).
//
// Модалка живёт в React-дереве (ConfirmHost, монтируется один раз в
// RequestPortal). Здесь — только «мостик»: confirmModal(...) отдаёт промис,
// который резолвится, когда пользователь нажмёт «Продолжить»/«Отмена».
export interface IConfirmOptions {
/** Заголовок окна. */
title?: string;
/** Основной текст-вопрос. */
message: string;
/** Подпись подтверждающей кнопки. По умолчанию — «Продолжить». */
confirmText?: string;
/** Подпись кнопки отмены. По умолчанию — «Отмена». */
cancelText?: string;
/** Опасное действие (потеря данных) — подтверждающая кнопка красная. */
danger?: boolean;
}
type ConfirmHandler = (opts: IConfirmOptions, resolve: (ok: boolean) => void) => void;
// Единственный подписчик — смонтированный ConfirmHost. Больше одного окна
// подтверждения одновременно нам не нужно.
let handler: ConfirmHandler | undefined;
export function registerConfirmHost(fn: ConfirmHandler): () => void {
handler = fn;
return () => {
if (handler === fn) handler = undefined;
};
}
/**
* Показать модальное подтверждение. Промис резолвится в true («Продолжить») или
* false («Отмена»/Esc/клик по фону). Если хост почему-то не смонтирован —
* падаем на нативный confirm, чтобы не потерять защиту от потери данных.
*/
export function confirmModal(opts: IConfirmOptions): Promise<boolean> {
if (!handler) {
return Promise.resolve(window.confirm(opts.message));
}
return new Promise<boolean>((resolve) => handler!(opts, resolve));
}
@@ -1,75 +0,0 @@
@import '../../../../../styles/design-tokens';
.zone {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 24px;
border: 1.5px dashed $gray-300;
border-radius: $r-md;
color: $gray-400;
font-size: 12.5px;
cursor: pointer;
transition: .18s;
&:hover,
&.over {
border-color: $gold-500;
color: $gold-600;
background: $gold-50;
}
}
.hidden {
display: none;
}
.list {
list-style: none;
margin: 10px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $gray-50;
font-size: 13px;
color: $gray-700;
}
.name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.size {
color: $gray-400;
font-size: 11.5px;
flex-shrink: 0;
}
.remove {
display: inline-flex;
padding: 2px;
border: 0;
background: transparent;
color: $gray-400;
cursor: pointer;
flex-shrink: 0;
&:hover {
color: $red-500;
}
}
@@ -1,87 +0,0 @@
import * as React from 'react';
import { useState, useRef } from 'react';
import styles from './FileDropzone.module.scss';
import { Icon } from '../Icon/Icon';
interface IFileDropzoneProps {
files: File[];
onChange: (files: File[]) => void;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} КБ`;
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
}
/** Зона выбора файлов (перетащить/кликнуть) со списком выбранных и удалением. */
export const FileDropzone: React.FC<IFileDropzoneProps> = ({ files, onChange }) => {
const inputRef = useRef<HTMLInputElement>(null);
const [dragOver, setDragOver] = useState<boolean>(false);
const addFiles = (incoming: FileList | null): void => {
if (!incoming || incoming.length === 0) {
return;
}
onChange([...files, ...Array.from(incoming)]);
};
const removeAt = (index: number): void => {
onChange(files.filter((_, i) => i !== index));
};
const zoneClasses = [styles.zone, dragOver ? styles.over : ''].filter(Boolean).join(' ');
return (
<div>
<div
className={zoneClasses}
onClick={() => inputRef.current && inputRef.current.click()}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
addFiles(e.dataTransfer.files);
}}
>
<Icon name="upload" size="sm" />
<span>Нажмите или перетащите файлы сюда</span>
</div>
<input
ref={inputRef}
type="file"
multiple
className={styles.hidden}
onChange={(e) => {
addFiles(e.target.files);
e.target.value = ''; // чтобы можно было выбрать тот же файл повторно
}}
/>
{files.length > 0 && (
<ul className={styles.list}>
{files.map((file, index) => (
<li key={`${file.name}-${index}`} className={styles.item}>
<Icon name="document" size="sm" />
<span className={styles.name}>{file.name}</span>
<span className={styles.size}>{formatSize(file.size)}</span>
<button
type="button"
className={styles.remove}
aria-label="Удалить файл"
onClick={() => removeAt(index)}
>
<Icon name="close" size="sm" />
</button>
</li>
))}
</ul>
)}
</div>
);
};
@@ -1,15 +0,0 @@
@import '../../../../../styles/design-tokens';
.actions {
position: sticky;
bottom: 0;
display: flex;
justify-content: flex-end;
gap: 10px;
margin: 26px -32px -32px;
padding: 16px 32px;
background: $white;
border-top: 1px solid $gray-200;
border-radius: 0 0 $r-lg $r-lg;
box-shadow: 0 -4px 16px rgba(15, 36, 64, .04);
}
@@ -1,11 +0,0 @@
import * as React from 'react';
import styles from './FormActions.module.scss';
interface IFormActionsProps {
children: React.ReactNode;
}
/** Нижняя липкая панель действий формы («Отмена» / «Сохранить»). */
export const FormActions: React.FC<IFormActionsProps> = ({ children }) => {
return <div className={styles.actions}>{children}</div>;
};
@@ -1,42 +0,0 @@
@import '../../../../../styles/design-tokens';
.card {
background: $white;
border: 1px solid $gray-200;
border-radius: $r-lg;
box-shadow: $sh-1;
padding: 32px;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.title {
font-family: $font-serif;
font-size: $fs-form-title;
font-weight: 600;
color: $navy-900;
}
.headerRight {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
}
.subtitle {
font-size: 12px;
color: $gray-400;
margin-top: 6px;
letter-spacing: .3px;
}
.hairline {
@include hairline;
margin: 6px 0 22px;
}
@@ -1,30 +0,0 @@
import * as React from 'react';
import styles from './FormCard.module.scss';
interface IFormCardProps {
title: string;
subtitle?: string;
/** Контент в правом верхнем углу шапки (например, номер заявки). */
headerRight?: React.ReactNode;
/** Строка под подзаголовком (например, инициатор заявки). */
belowSubtitle?: React.ReactNode;
children: React.ReactNode;
}
/** Белая карточка формы: заголовок (Lora) + золотой hairline + содержимое. */
export const FormCard: React.FC<IFormCardProps> = ({ title, subtitle, headerRight, belowSubtitle, children }) => {
return (
<div className={styles.card}>
<div className={styles.header}>
<div>
<div className={styles.title}>{title}</div>
{subtitle && <div className={styles.subtitle}>{subtitle}</div>}
{belowSubtitle}
</div>
{headerRight && <div className={styles.headerRight}>{headerRight}</div>}
</div>
<hr className={styles.hairline} />
{children}
</div>
);
};
@@ -1,23 +0,0 @@
@import '../../../../../styles/design-tokens';
.field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.full {
grid-column: 1 / -1;
}
.label {
font-size: $fs-label;
font-weight: 500;
color: $gray-700;
letter-spacing: .2px;
}
.req {
color: $gold-600;
}
@@ -1,26 +0,0 @@
import * as React from 'react';
import styles from './FormField.module.scss';
interface IFormFieldProps {
label: string;
htmlFor?: string;
required?: boolean;
/** Занять обе колонки сетки. */
full?: boolean;
children: React.ReactNode;
}
/** Ячейка формы: подпись со звёздочкой для обязательных + контрол. */
export const FormField: React.FC<IFormFieldProps> = ({ label, htmlFor, required, full, children }) => {
const classes = [styles.field, full ? styles.full : ''].filter(Boolean).join(' ');
return (
<div className={classes}>
<label className={styles.label} htmlFor={htmlFor}>
{label}
{required && <span className={styles.req}> *</span>}
</label>
{children}
</div>
);
};
@@ -1,11 +0,0 @@
@import '../../../../../styles/design-tokens';
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px 22px;
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
}
@@ -1,11 +0,0 @@
import * as React from 'react';
import styles from './FormGrid.module.scss';
interface IFormGridProps {
children: React.ReactNode;
}
/** Сетка полей в 2 колонки. Поля-дети — FormField (full — на всю ширину). */
export const FormGrid: React.FC<IFormGridProps> = ({ children }) => {
return <div className={styles.grid}>{children}</div>;
};
@@ -1,16 +0,0 @@
import * as React from 'react';
// Режим формы «только чтение». Проставляется один раз оборачиванием
// (<FormModeProvider readOnly>...), а поля-примитивы сами его подхватывают —
// не нужно тащить readOnly через каждую секцию и каждое поле.
const FormModeContext = React.createContext<boolean>(false);
export const FormModeProvider: React.FC<{ readOnly: boolean; children: React.ReactNode }> = ({
readOnly,
children
}) => <FormModeContext.Provider value={readOnly}>{children}</FormModeContext.Provider>;
/** true, если форма отрисовывается в режиме «только чтение». */
export function useFormReadOnly(): boolean {
return React.useContext(FormModeContext);
}
@@ -1,22 +0,0 @@
@import '../../../../../styles/design-tokens';
.icon {
width: 20px;
height: 20px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.sm {
width: 16px;
height: 16px;
}
.lg {
width: 24px;
height: 24px;
}
@@ -1,30 +0,0 @@
import * as React from 'react';
import styles from './Icon.module.scss';
import { IconName } from './iconNames';
type IconSize = 'sm' | 'md' | 'lg';
interface IIconProps {
name: IconName;
size?: IconSize;
className?: string;
}
const sizeClass: Record<IconSize, string> = {
sm: styles.sm,
md: '',
lg: styles.lg
};
/**
* Иконка из общего спрайта. Спрайт (IconSprite) должен быть отрисован
* один раз в корне портала, иначе ссылки #i-<name> ничего не покажут.
*/
export const Icon: React.FC<IIconProps> = ({ name, size = 'md', className }) => {
const classes = [styles.icon, sizeClass[size], className].filter(Boolean).join(' ');
return (
<svg className={classes} aria-hidden="true" focusable="false">
<use href={`#i-${name}`} />
</svg>
);
};
@@ -1,140 +0,0 @@
import * as React from 'react';
/**
* Скрытый SVG-спрайт со всеми иконками портала.
* Рендерится один раз в корне (RequestPortal). Компонент Icon ссылается
* на символы по id `#i-<name>` (см. iconNames.ts).
*/
export const IconSprite: React.FC = () => (
<svg xmlns="http://www.w3.org/2000/svg" style={{ display: 'none' }} aria-hidden="true">
<symbol id="i-building" viewBox="0 0 24 24">
<path d="M3 21V5a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v16" />
<path d="M13 21V9a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v12" />
<path d="M3 21h18" />
<rect x="7" y="7" width="2" height="2" rx=".5" fill="currentColor" stroke="none" />
<rect x="7" y="12" width="2" height="2" rx=".5" fill="currentColor" stroke="none" />
<rect x="16" y="11" width="2" height="2" rx=".5" fill="currentColor" stroke="none" />
</symbol>
<symbol id="i-chart" viewBox="0 0 24 24">
<rect x="3" y="12" width="4" height="9" rx="1" />
<rect x="10" y="7" width="4" height="14" rx="1" />
<rect x="17" y="3" width="4" height="18" rx="1" />
</symbol>
<symbol id="i-monitor" viewBox="0 0 24 24">
<rect x="2" y="4" width="20" height="13" rx="2" />
<path d="M8 21h8M12 17v4" />
</symbol>
<symbol id="i-helmet" viewBox="0 0 24 24">
<path d="M3 16a9 9 0 0 1 18 0" />
<path d="M2 16h20" />
<path d="M9 8V6a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</symbol>
<symbol id="i-user-check" viewBox="0 0 24 24">
<circle cx="9" cy="7" r="4" />
<path d="M3 21v-1a5 5 0 0 1 5-5h3" />
<path d="M15 12l2.5 2.5L22 10" />
</symbol>
<symbol id="i-calculator" viewBox="0 0 24 24">
<rect x="4" y="2" width="16" height="20" rx="2" />
<path d="M8 6h8M8 10h.01M12 10h.01M16 10h.01M8 14h.01M12 14h.01M16 14h.01M8 18h4" />
</symbol>
<symbol id="i-send" viewBox="0 0 24 24">
<path d="M22 2 11 13" />
<path d="m22 2-7 20-4-9-9-4z" />
</symbol>
<symbol id="i-download" viewBox="0 0 24 24">
<path d="M12 3v12m0 0-4-4m4 4 4-4" />
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
</symbol>
<symbol id="i-upload" viewBox="0 0 24 24">
<path d="M12 15V3m0 0-4 4m4-4 4 4" />
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
</symbol>
<symbol id="i-folder" viewBox="0 0 24 24">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</symbol>
<symbol id="i-document" viewBox="0 0 24 24">
<path d="M6 3h8l5 5v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" />
<path d="M14 3v5h5" />
</symbol>
<symbol id="i-calendar" viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="17" rx="2" />
<path d="M3 9h18M8 2v4M16 2v4" />
</symbol>
<symbol id="i-clipboard" viewBox="0 0 24 24">
<path d="M9 3h6v2a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1z" />
<rect x="5" y="5" width="14" height="16" rx="2" />
<path d="M9 12h6M9 16h4" />
</symbol>
<symbol id="i-people" viewBox="0 0 24 24">
<circle cx="9" cy="7" r="3" />
<path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2" />
<circle cx="17" cy="8" r="2.5" />
<path d="M17 13.5a3.5 3.5 0 0 1 3.5 3.5v1" />
</symbol>
<symbol id="i-vehicle" viewBox="0 0 24 24">
<path d="M5 11l1.5-4.5A2 2 0 0 1 8.4 5h7.2a2 2 0 0 1 1.9 1.5L19 11" />
<rect x="3" y="11" width="18" height="7" rx="2" />
<circle cx="7.5" cy="15.5" r="1.5" fill="currentColor" stroke="none" />
<circle cx="16.5" cy="15.5" r="1.5" fill="currentColor" stroke="none" />
</symbol>
<symbol id="i-chat" viewBox="0 0 24 24">
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-5.46-1.84L3 21l1.84-3.54A9 9 0 1 1 21 12z" />
</symbol>
<symbol id="i-camera" viewBox="0 0 24 24">
<path d="M3 8a2 2 0 0 1 2-2h2l1.5-2h7L17 6h2a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<circle cx="12" cy="13" r="4" />
</symbol>
<symbol id="i-image" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" stroke="none" />
<path d="m21 15-5-5L5 21" />
</symbol>
<symbol id="i-edit" viewBox="0 0 24 24">
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z" />
</symbol>
<symbol id="i-copy" viewBox="0 0 24 24">
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</symbol>
<symbol id="i-more" viewBox="0 0 24 24">
<circle cx="12" cy="5" r="1.5" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none" />
<circle cx="12" cy="19" r="1.5" fill="currentColor" stroke="none" />
</symbol>
<symbol id="i-trash" viewBox="0 0 24 24">
<path d="M4 7h16M10 11v6M14 11v6" />
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12" />
<path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
</symbol>
<symbol id="i-close" viewBox="0 0 24 24">
<path d="M18 6 6 18M6 6l12 12" />
</symbol>
<symbol id="i-chevron-right" viewBox="0 0 24 24">
<path d="m9 6 6 6-6 6" />
</symbol>
<symbol id="i-chevron-down" viewBox="0 0 24 24">
<path d="m6 9 6 6 6-6" />
</symbol>
<symbol id="i-chevron-left" viewBox="0 0 24 24">
<path d="m15 6-6 6 6 6" />
</symbol>
<symbol id="i-arrow-left" viewBox="0 0 24 24">
<path d="M19 12H5m0 0 5-5m-5 5 5 5" />
</symbol>
<symbol id="i-search" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.35-4.35" />
</symbol>
<symbol id="i-warning" viewBox="0 0 24 24">
<path d="M12 2 1 21h22z" strokeLinejoin="round" />
<path d="M12 9v5M12 17h.01" />
</symbol>
<symbol id="i-check" viewBox="0 0 24 24">
<path d="M5 12l5 5L20 7" />
</symbol>
<symbol id="i-plus" viewBox="0 0 24 24">
<path d="M12 5v14M5 12h14" />
</symbol>
</svg>
);
@@ -1,37 +0,0 @@
// Единый набор имён иконок. Значение = id символа в спрайте (см. IconSprite).
// Добавляя иконку: 1) добавь имя сюда, 2) добавь <symbol> в IconSprite.
export const ICON_NAMES = [
'building',
'chart',
'monitor',
'calculator',
'helmet',
'user-check',
'send',
'download',
'upload',
'folder',
'document',
'calendar',
'clipboard',
'people',
'vehicle',
'chat',
'camera',
'image',
'edit',
'copy',
'more',
'trash',
'close',
'chevron-right',
'chevron-down',
'chevron-left',
'arrow-left',
'search',
'warning',
'check',
'plus'
] as const;
export type IconName = (typeof ICON_NAMES)[number];
@@ -1,8 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import styles from './ItemsTable.module.scss'; import styles from './ItemsTable.module.scss';
import { Button } from '../Button/Button'; import { Button, Icon } from '@tnsu/ui-kit';
import { Icon } from '../Icon/Icon'; import { useFormReadOnly } from '@tnsu/ui-kit';
import { useFormReadOnly } from '../FormMode';
import { ILineItem, EMPTY_LINE_ITEM } from './ILineItem'; import { ILineItem, EMPTY_LINE_ITEM } from './ILineItem';
import { parseExcelRows } from './parseExcelRows'; import { parseExcelRows } from './parseExcelRows';
@@ -1,31 +0,0 @@
import * as React from 'react';
import { TextInput } from '../TextInput/TextInput';
// Показ с разделителем тысяч пробелом: 350000 → «350 000».
function formatThousands(input: string): string {
const digits = input.replace(/\D/g, '');
if (!digits) {
return '';
}
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
}
interface IMoneyInputProps {
id?: string;
value: string;
onChange: (value: string) => void;
readOnly?: boolean;
}
/**
* Ввод суммы с разделителем тысяч. В модель формы кладём «сырые» цифры
* (без пробелов), поэтому запись в числовую колонку SP работает как есть.
*/
export const MoneyInput: React.FC<IMoneyInputProps> = ({ id, value, onChange, readOnly }) => (
<TextInput
id={id}
value={formatThousands(value)}
onChange={(v) => onChange(v.replace(/\D/g, ''))}
readOnly={readOnly}
/>
);
@@ -2,7 +2,7 @@ import * as React from 'react';
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker'; import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext'; import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
import { useFormReadOnly } from '../FormMode'; import { useFormReadOnly } from '@tnsu/ui-kit';
interface IPeopleFieldProps { interface IPeopleFieldProps {
context: WebPartContext; context: WebPartContext;
@@ -2,7 +2,7 @@ import * as React from 'react';
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker'; import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext'; import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
import { useFormReadOnly } from '../FormMode'; import { useFormReadOnly } from '@tnsu/ui-kit';
interface IPeopleFieldMultiProps { interface IPeopleFieldMultiProps {
context: WebPartContext; context: WebPartContext;
@@ -1,45 +0,0 @@
import * as React from 'react';
import { TextInput } from '../TextInput/TextInput';
// Маска номера Казахстана: +7 (XXX) XXX-XX-XX. Идемпотентна — можно применять
// и к уже отформатированному значению (напр. загруженному из SharePoint).
function formatKzPhone(input: string): string {
let digits = input.replace(/\D/g, '');
if (digits.length === 0) {
return '';
}
if (digits[0] === '8') {
digits = '7' + digits.slice(1);
}
if (digits[0] !== '7') {
digits = '7' + digits;
}
digits = digits.slice(0, 11); // код страны + 10 цифр
const rest = digits.slice(1);
let out = '+7';
if (rest.length > 0) out += ' (' + rest.slice(0, 3);
if (rest.length >= 3) out += ')';
if (rest.length > 3) out += ' ' + rest.slice(3, 6);
if (rest.length > 6) out += '-' + rest.slice(6, 8);
if (rest.length > 8) out += '-' + rest.slice(8, 10);
return out;
}
interface IPhoneInputProps {
id?: string;
value: string;
onChange: (value: string) => void;
readOnly?: boolean;
}
/** Ввод телефона с маской +7 (XXX) XXX-XX-XX. */
export const PhoneInput: React.FC<IPhoneInputProps> = ({ id, value, onChange, readOnly }) => (
<TextInput
id={id}
value={formatKzPhone(value)}
onChange={(v) => onChange(formatKzPhone(v))}
placeholder="+7 (___) ___-__-__"
readOnly={readOnly}
/>
);
@@ -1,117 +0,0 @@
import * as React from 'react';
import { Button } from '../Button/Button';
import { Icon } from '../Icon/Icon';
// Значение поля для печати. Дату (input type=date, ISO yyyy-mm-dd) — в дд.мм.гггг.
function formatValue(el: HTMLInputElement): string {
if (el.type === 'date' && /^\d{4}-\d{2}-\d{2}$/.test(el.value)) {
const [y, m, d] = el.value.split('-');
return `${d}.${m}.${y}`;
}
return el.value;
}
// Печатаем как ДАННЫЕ, а не как форму: каждое поле ввода в клоне заменяем на
// обычный текст его значения (значения берём из живого DOM — React держит их как
// свойства, в клонированном HTML их нет). Так textarea/поля не занимают лишнего
// места и лист выглядит как справка, а не как незаполненный бланк.
function renderValuesAsText(source: Element, clone: Element): void {
const put = (cloneEl: Element | undefined, text: string): void => {
if (!cloneEl) return;
const div = document.createElement('div');
div.className = 'rp-print-value';
div.textContent = text && text.length > 0 ? text : '—';
(cloneEl.closest('label') ?? cloneEl).replaceWith(div);
};
const cInputs = clone.querySelectorAll('input');
source.querySelectorAll('input').forEach((el, i) => {
if (el.type === 'checkbox' || el.type === 'radio') {
put(cInputs[i], el.checked ? 'Да' : 'Нет');
} else {
put(cInputs[i], formatValue(el));
}
});
const cAreas = clone.querySelectorAll('textarea');
source.querySelectorAll('textarea').forEach((el, i) => put(cAreas[i], el.value));
const cSelects = clone.querySelectorAll('select');
source.querySelectorAll('select').forEach((el, i) => {
const opt = el.options[el.selectedIndex];
put(cSelects[i], opt && opt.value ? opt.text : '');
});
}
// Стили печатного окна: чистый лист (без «коробки» карточки), без иконок,
// значения — обычным текстом с переносами.
const PRINT_CSS =
// height:auto перебивает глобальный CSS SharePoint (html/body 100vh) — иначе
// body раздувается выше листа A4 и в печать идёт лишняя пустая страница.
'html,body{margin:0;background:#fff;height:auto!important;min-height:0!important}' +
'body{padding:24px;font-size:13px}' +
'svg{display:none!important}' +
// Фото сотрудника (аватар персоны PeoplePicker) в печать не идёт — на бумаге
// нужны только ФИО и e-mail. Прячем весь круг-аватар, чтобы не оставался
// пустой кружок. Текст персоны (имя/почта) остаётся.
'.ms-Persona-imageArea,.ms-Persona-image{display:none!important}' +
'.rp-print-root>*{border:0!important;box-shadow:none!important;padding:0!important}' +
'.rp-print-value{white-space:pre-wrap;color:#000;padding:1px 0 2px}';
/**
* Кнопка «Печать»: переносит документ (`.rp-print-root`) в отдельное чистое окно,
* заменяя поля ввода на текст значений, и печатает его. Не зависит от скролл-
* контейнеров/высоты SharePoint, корректно разбивается на страницы. Общая для форм.
*/
export const PrintButton: React.FC<{ label?: string }> = ({ label = 'Печать' }) => {
const onPrint = (): void => {
const source = document.querySelector('.rp-print-root');
if (!source) {
window.print();
return;
}
const win = window.open('', '_blank', 'width=980,height=1200');
if (!win) {
window.print(); // всплывающее окно заблокировано — печатаем как есть
return;
}
const clone = source.cloneNode(true) as Element;
// Убираем из печати всё помеченное rp-no-print (напр. блок «Вложения»).
clone.querySelectorAll('.rp-no-print').forEach((el) => el.remove());
renderValuesAsText(source, clone);
// Переносим стили страницы (CSS-модули инжектятся как <style>, шрифты — <link>).
const head = Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))
.map((n) => n.outerHTML)
.join('\n');
win.document.open();
win.document.write(
`<!doctype html><html><head><meta charset="utf-8">${head}` +
`<style>${PRINT_CSS}</style></head><body>${clone.outerHTML}</body></html>`
);
win.document.close();
win.focus();
// Даём стилям/шрифтам примениться, затем печатаем и закрываем окно.
const done = (): void => {
win.print();
win.close();
};
if (win.document.readyState === 'complete') {
window.setTimeout(done, 350);
} else {
win.onload = (): void => {
window.setTimeout(done, 350);
};
}
};
return (
<Button variant="secondary" onClick={onPrint}>
<Icon name="document" size="sm" />
{label}
</Button>
);
};
@@ -1,16 +0,0 @@
@import '../../../../../styles/design-tokens';
.badge {
display: inline-flex;
align-items: center;
padding: 5px 12px;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $navy-50;
color: $navy-800;
font-family: $font-sans;
font-size: 13px;
font-weight: 600;
letter-spacing: .3px;
white-space: nowrap;
}
@@ -1,7 +0,0 @@
import * as React from 'react';
import styles from './RequestNumber.module.scss';
/** Уникальный код заявки ({requestTypeId}-{YY}{MM}-{itemId}) — плашка в правом углу шапки формы. */
export const RequestNumber: React.FC<{ code: string }> = ({ code }) => (
<span className={styles.badge}>№{code}</span>
);
@@ -1,20 +0,0 @@
@import '../../../../../styles/design-tokens';
.select {
appearance: none;
padding: 11px 36px 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background-color: $white;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236B7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-900;
cursor: pointer;
transition: .18s;
@include input-focus-gold;
@include readonly-field(':disabled');
}
@@ -1,40 +0,0 @@
import * as React from 'react';
import styles from './Select.module.scss';
import { useFormReadOnly } from '../FormMode';
export interface ISelectOption {
value: string;
label: string;
}
interface ISelectProps {
id?: string;
value: string;
onChange: (value: string) => void;
options: ISelectOption[];
placeholder?: string;
/** Только чтение (режим согласующего). У select нет readonly — используем disabled. */
readOnly?: boolean;
}
/** Выпадающий список (SharePoint Choice). */
export const Select: React.FC<ISelectProps> = ({ id, value, onChange, options, placeholder, readOnly }) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
return (
<select
id={id}
className={styles.select}
value={value}
disabled={effectiveReadOnly}
onChange={(e) => onChange(e.target.value)}
>
{placeholder && <option value="">{placeholder}</option>}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
};
@@ -1,7 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { Button } from '@tnsu/ui-kit'; import { Button, Icon, FormActions } from '@tnsu/ui-kit';
import { Icon } from '../Icon/Icon';
import { FormActions } from '../FormActions/FormActions';
import { IRequestSubmit } from '../useRequestSubmit'; import { IRequestSubmit } from '../useRequestSubmit';
export interface ISubmitActionsProps { export interface ISubmitActionsProps {
@@ -1,21 +0,0 @@
@import '../../../../../styles/design-tokens';
.textarea {
padding: 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-900;
resize: vertical;
min-height: 80px;
transition: .18s;
@include input-focus-gold;
@include readonly-field(':read-only');
&::placeholder {
color: $gray-400;
}
}
@@ -1,30 +0,0 @@
import * as React from 'react';
import styles from './TextArea.module.scss';
import { useFormReadOnly } from '../FormMode';
interface ITextAreaProps {
id?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
/** Многострочный ввод (SharePoint Note). */
export const TextArea: React.FC<ITextAreaProps> = ({ id, value, onChange, placeholder, rows = 3, readOnly }) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
return (
<textarea
id={id}
className={styles.textarea}
value={value}
placeholder={placeholder}
rows={rows}
readOnly={effectiveReadOnly}
onChange={(e) => onChange(e.target.value)}
/>
);
};
@@ -1,19 +0,0 @@
@import '../../../../../styles/design-tokens';
.input {
padding: 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
@include readonly-field(':read-only');
&::placeholder {
color: $gray-400;
}
}
@@ -1,39 +0,0 @@
import * as React from 'react';
import styles from './TextInput.module.scss';
import { useFormReadOnly } from '../FormMode';
type InputType = 'text' | 'number' | 'date';
interface ITextInputProps {
id?: string;
type?: InputType;
value: string;
onChange: (value: string) => void;
placeholder?: string;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
/** Однострочный ввод. type='number'|'date' — для числовых полей и дат. */
export const TextInput: React.FC<ITextInputProps> = ({
id,
type = 'text',
value,
onChange,
placeholder,
readOnly
}) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
return (
<input
id={id}
className={styles.input}
type={type}
value={value}
placeholder={placeholder}
readOnly={effectiveReadOnly}
onChange={(e) => onChange(e.target.value)}
/>
);
};
@@ -1,65 +0,0 @@
@import '../../../../../styles/design-tokens';
.wrap {
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
padding: 6px 0;
}
.input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.track {
position: relative;
width: 40px;
height: 22px;
border-radius: 11px;
background: $gray-300;
transition: background .18s;
flex-shrink: 0;
}
.thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: $white;
box-shadow: $sh-1;
transition: transform .18s;
}
.input:checked + .track {
background: $gold-500;
}
.input:checked + .track .thumb {
transform: translateX(18px);
}
.input:focus-visible + .track {
box-shadow: 0 0 0 3px rgba(212, 160, 30, .25);
}
// Только чтение: приглушаем переключатель и подпись.
.input:disabled + .track {
opacity: .5;
cursor: default;
}
.input:disabled ~ .text {
color: $gray-400;
}
.text {
font-size: 13px;
color: $gray-700;
}
@@ -1,35 +0,0 @@
import * as React from 'react';
import styles from './Toggle.module.scss';
import { useFormReadOnly } from '../FormMode';
interface IToggleProps {
id?: string;
checked: boolean;
onChange: (checked: boolean) => void;
/** Подпись справа от переключателя (да/нет-поля). */
onLabel?: string;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
/** Переключатель да/нет (SharePoint Boolean). */
export const Toggle: React.FC<IToggleProps> = ({ id, checked, onChange, onLabel, readOnly }) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
return (
<label className={styles.wrap}>
<input
id={id}
type="checkbox"
className={styles.input}
checked={checked}
disabled={effectiveReadOnly}
onChange={(e) => onChange(e.target.checked)}
/>
<span className={styles.track}>
<span className={styles.thumb} />
</span>
{onLabel && <span className={styles.text}>{onLabel}</span>}
</label>
);
};
+32 -23
View File
@@ -1,20 +1,36 @@
// Единая точка импорта примитивов формы. // Единая точка импорта примитивов формы.
export { Button } from './Button/Button'; // Перенесённые компоненты берём из общего пакета @tnsu/ui-kit; остальные
export { Alert } from './Alert/Alert'; // (бизнес-логика/SharePoint/отложенные) — пока локальные. Все формы импортят
export { FormCard } from './FormCard/FormCard'; // отсюда, поэтому переключение источника не требует правок в самих формах.
export { FormGrid } from './FormGrid/FormGrid';
export { FormField } from './FormField/FormField'; // --- Из общего пакета @tnsu/ui-kit ---
export { TextInput } from './TextInput/TextInput'; export {
export { PhoneInput } from './PhoneInput/PhoneInput'; Button,
export { MoneyInput } from './MoneyInput/MoneyInput'; Alert,
export { TextArea } from './TextArea/TextArea'; FormCard,
export { Select } from './Select/Select'; FormGrid,
export type { ISelectOption } from './Select/Select'; FormField,
export { Toggle } from './Toggle/Toggle'; TextInput,
export { CollapseSection } from './CollapseSection/CollapseSection'; PhoneInput,
export { FormActions } from './FormActions/FormActions'; MoneyInput,
export { FileDropzone } from './FileDropzone/FileDropzone'; TextArea,
export { AttachmentList } from './AttachmentList/AttachmentList'; Select,
Toggle,
CollapseSection,
FormActions,
FileDropzone,
AttachmentList,
FormModeProvider,
useFormReadOnly,
Icon,
ConfirmHost,
confirmModal,
RequestNumber,
PrintButton
} from '@tnsu/ui-kit';
export type { ISelectOption, IConfirmOptions } from '@tnsu/ui-kit';
// --- Пока локальные (не перенесены: SP/бизнес/отложенные) ---
export { ItemsTable } from './ItemsTable/ItemsTable'; export { ItemsTable } from './ItemsTable/ItemsTable';
export type { IItemsTableProps } from './ItemsTable/ItemsTable'; export type { IItemsTableProps } from './ItemsTable/ItemsTable';
export type { ILineItem } from './ItemsTable/ILineItem'; export type { ILineItem } from './ItemsTable/ILineItem';
@@ -22,16 +38,9 @@ export { EMPTY_LINE_ITEM } from './ItemsTable/ILineItem';
export { parseExcelRows } from './ItemsTable/parseExcelRows'; export { parseExcelRows } from './ItemsTable/parseExcelRows';
export { PeopleField } from './PeopleField/PeopleField'; export { PeopleField } from './PeopleField/PeopleField';
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti'; export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
export { FormModeProvider, useFormReadOnly } from './FormMode';
export { Icon } from './Icon/Icon';
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay'; export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
export { ConfirmHost } from './ConfirmDialog/ConfirmDialog';
export { confirmModal } from './ConfirmDialog/confirmModal';
export type { IConfirmOptions } from './ConfirmDialog/confirmModal';
export { SubmitActions } from './SubmitActions/SubmitActions'; export { SubmitActions } from './SubmitActions/SubmitActions';
export { RequestNumber } from './RequestNumber/RequestNumber';
export { InitiatorLine } from './InitiatorLine/InitiatorLine'; export { InitiatorLine } from './InitiatorLine/InitiatorLine';
export type { IInitiatorLineProps } from './InitiatorLine/InitiatorLine'; export type { IInitiatorLineProps } from './InitiatorLine/InitiatorLine';
export { PrintButton } from './PrintButton/PrintButton';
export { useRequestSubmit } from './useRequestSubmit'; export { useRequestSubmit } from './useRequestSubmit';
export type { IRequestSubmit, SubmitKind } from './useRequestSubmit'; export type { IRequestSubmit, SubmitKind } from './useRequestSubmit';
@@ -1,4 +1,4 @@
import { confirmModal } from './ui/ConfirmDialog/confirmModal'; import { confirmModal } from '@tnsu/ui-kit';
// Есть ли в открытой форме «Новая заявка» правки, которые ещё не сохранены. // Есть ли в открытой форме «Новая заявка» правки, которые ещё не сохранены.
// //