refactor: миграция форм на ui-kit, удаление локальных дублей
Tests / frontend-test (push) Successful in 4m11s
Tests / frontend-test (push) Successful in 4m11s
This commit is contained in:
@@ -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 ""
|
||||
Generated
+4
-4
@@ -17,7 +17,7 @@
|
||||
"@microsoft/sp-webpart-base": "1.22.1",
|
||||
"@pnp/sp": "^4.20.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-dom": "17.0.1",
|
||||
"tslib": "2.3.1"
|
||||
@@ -9870,9 +9870,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@tnsu/ui-kit": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.0.1/ui-kit-0.0.1.tgz",
|
||||
"integrity": "sha512-fdw5KEsahQ10qT/vf/ZWvwMHorcfk7ketUkvgUa8Zlg8imVWTCq1qxxEkS439qXPZw/vTtYYpkSzgM1JOGOFNg==",
|
||||
"version": "0.2.0",
|
||||
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.2.0/ui-kit-0.2.0.tgz",
|
||||
"integrity": "sha512-8vQsA91lSbmtlqROlIYkgSH3cNEH5DCVI5vM4mks2RWwvSpYet0zfe2B3wfBcnb+cAWrbLGfCwe81KRDcJ/k+Q==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@fluentui/react": "^8.106.4",
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
"@microsoft/sp-webpart-base": "1.22.1",
|
||||
"@pnp/sp": "^4.20.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-dom": "17.0.1",
|
||||
"tslib": "2.3.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import styles from './CategoryTabs.module.scss';
|
||||
import { Icon } from '../../shared/ui/Icon/Icon';
|
||||
import { Icon } from '@tnsu/ui-kit';
|
||||
import { ICategory } from '../../models/types';
|
||||
|
||||
interface ICategoryTabsProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Drawer.module.scss';
|
||||
import { Icon } from '../../shared/ui/Icon/Icon';
|
||||
import { Icon } from '@tnsu/ui-kit';
|
||||
|
||||
interface IDrawerProps {
|
||||
title: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RequestGrid.module.scss';
|
||||
import { Icon } from '../../shared/ui/Icon/Icon';
|
||||
import { Icon } from '@tnsu/ui-kit';
|
||||
import { IRequestType } from '../../models/types';
|
||||
|
||||
interface IRequestGridProps {
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
import styles from './RequestPortal.module.scss';
|
||||
import '@tnsu/ui-kit/styles.css';
|
||||
import type { IRequestPortalProps } from './IRequestPortalProps';
|
||||
import { IconSprite } from '../shared/ui/Icon/IconSprite';
|
||||
import { IconSprite } from '@tnsu/ui-kit';
|
||||
import { ConfirmHost } from '../shared/ui';
|
||||
import {
|
||||
navigate,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import styles from './SideNav.module.scss';
|
||||
import { Icon } from '../../shared/ui/Icon/Icon';
|
||||
import { IconName } from '../../shared/ui/Icon/iconNames';
|
||||
import { Icon, IconName } from '@tnsu/ui-kit';
|
||||
import { NavSection } from '../../shared/routing';
|
||||
|
||||
interface INavItem {
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
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 { getRequestsByCategory, REQUEST_TYPES } from '../../data/requests';
|
||||
import { getFormComponent } from '../../forms/registry';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { IconName } from '../shared/ui/Icon/iconNames';
|
||||
import { IconName } from '@tnsu/ui-kit';
|
||||
|
||||
/** Идентификатор типа заявки — ключ в реестре форм (formRegistry). */
|
||||
export type RequestTypeId = string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './DepartmentPicker.module.scss';
|
||||
import { useFormReadOnly } from '../../ui/FormMode';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
import { getDepartments } from '../departments.service';
|
||||
import { IDepartment } from '../models/IDepartment';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './ProjectPicker.module.scss';
|
||||
import { useFormReadOnly } from '../../ui/FormMode';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
import { getProjects } from '../projects.service';
|
||||
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 styles from './ItemsTable.module.scss';
|
||||
import { Button } from '../Button/Button';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { useFormReadOnly } from '../FormMode';
|
||||
import { Button, Icon } from '@tnsu/ui-kit';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
import { ILineItem, EMPTY_LINE_ITEM } from './ILineItem';
|
||||
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 { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
|
||||
import { useFormReadOnly } from '../FormMode';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
|
||||
interface IPeopleFieldProps {
|
||||
context: WebPartContext;
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
|
||||
import { useFormReadOnly } from '../FormMode';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
|
||||
interface IPeopleFieldMultiProps {
|
||||
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 { Button } from '@tnsu/ui-kit';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { FormActions } from '../FormActions/FormActions';
|
||||
import { Button, Icon, FormActions } from '@tnsu/ui-kit';
|
||||
import { IRequestSubmit } from '../useRequestSubmit';
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,20 +1,36 @@
|
||||
// Единая точка импорта примитивов формы.
|
||||
export { Button } from './Button/Button';
|
||||
export { Alert } from './Alert/Alert';
|
||||
export { FormCard } from './FormCard/FormCard';
|
||||
export { FormGrid } from './FormGrid/FormGrid';
|
||||
export { FormField } from './FormField/FormField';
|
||||
export { TextInput } from './TextInput/TextInput';
|
||||
export { PhoneInput } from './PhoneInput/PhoneInput';
|
||||
export { MoneyInput } from './MoneyInput/MoneyInput';
|
||||
export { TextArea } from './TextArea/TextArea';
|
||||
export { Select } from './Select/Select';
|
||||
export type { ISelectOption } from './Select/Select';
|
||||
export { Toggle } from './Toggle/Toggle';
|
||||
export { CollapseSection } from './CollapseSection/CollapseSection';
|
||||
export { FormActions } from './FormActions/FormActions';
|
||||
export { FileDropzone } from './FileDropzone/FileDropzone';
|
||||
export { AttachmentList } from './AttachmentList/AttachmentList';
|
||||
// Перенесённые компоненты берём из общего пакета @tnsu/ui-kit; остальные
|
||||
// (бизнес-логика/SharePoint/отложенные) — пока локальные. Все формы импортят
|
||||
// отсюда, поэтому переключение источника не требует правок в самих формах.
|
||||
|
||||
// --- Из общего пакета @tnsu/ui-kit ---
|
||||
export {
|
||||
Button,
|
||||
Alert,
|
||||
FormCard,
|
||||
FormGrid,
|
||||
FormField,
|
||||
TextInput,
|
||||
PhoneInput,
|
||||
MoneyInput,
|
||||
TextArea,
|
||||
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 type { IItemsTableProps } from './ItemsTable/ItemsTable';
|
||||
export type { ILineItem } from './ItemsTable/ILineItem';
|
||||
@@ -22,16 +38,9 @@ export { EMPTY_LINE_ITEM } from './ItemsTable/ILineItem';
|
||||
export { parseExcelRows } from './ItemsTable/parseExcelRows';
|
||||
export { PeopleField } from './PeopleField/PeopleField';
|
||||
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
||||
export { FormModeProvider, useFormReadOnly } from './FormMode';
|
||||
export { Icon } from './Icon/Icon';
|
||||
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 { RequestNumber } from './RequestNumber/RequestNumber';
|
||||
export { InitiatorLine } from './InitiatorLine/InitiatorLine';
|
||||
export type { IInitiatorLineProps } from './InitiatorLine/InitiatorLine';
|
||||
export { PrintButton } from './PrintButton/PrintButton';
|
||||
export { useRequestSubmit } from './useRequestSubmit';
|
||||
export type { IRequestSubmit, SubmitKind } from './useRequestSubmit';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { confirmModal } from './ui/ConfirmDialog/confirmModal';
|
||||
import { confirmModal } from '@tnsu/ui-kit';
|
||||
|
||||
// Есть ли в открытой форме «Новая заявка» правки, которые ещё не сохранены.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user