feat: request portal — bespoke forms + approval mechanism
This commit is contained in:
+321
@@ -0,0 +1,321 @@
|
||||
require('@rushstack/eslint-config/patch/modern-module-resolution');
|
||||
module.exports = {
|
||||
extends: ['@microsoft/eslint-config-spfx/lib/profiles/react'],
|
||||
parserOptions: { tsconfigRootDir: __dirname },
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.ts', '*.tsx'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
'parserOptions': {
|
||||
'project': './tsconfig.json',
|
||||
'ecmaVersion': 2018,
|
||||
'sourceType': 'module'
|
||||
},
|
||||
rules: {
|
||||
// Prevent usage of the JavaScript null value, while allowing code to access existing APIs that may require null. https://www.npmjs.com/package/@rushstack/eslint-plugin
|
||||
'@rushstack/no-new-null': 1,
|
||||
// Require Jest module mocking APIs to be called before any other statements in their code block. https://www.npmjs.com/package/@rushstack/eslint-plugin
|
||||
'@rushstack/hoist-jest-mock': 1,
|
||||
// Require chunk names for dynamic imports in SPFx projects. https://www.npmjs.com/package/@rushstack/eslint-plugin
|
||||
'@rushstack/import-requires-chunk-name': 1,
|
||||
// Ensure that React components rendered with ReactDOM.render() are unmounted with ReactDOM.unmountComponentAtNode(). https://www.npmjs.com/package/@rushstack/eslint-plugin
|
||||
'@rushstack/pair-react-dom-render-unmount': 1,
|
||||
// Require regular expressions to be constructed from string constants rather than dynamically building strings at runtime. https://www.npmjs.com/package/@rushstack/eslint-plugin-security
|
||||
'@rushstack/security/no-unsafe-regexp': 1,
|
||||
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||
'@typescript-eslint/adjacent-overload-signatures': 1,
|
||||
// RATIONALE: Code is more readable when the type of every variable is immediately obvious.
|
||||
// Even if the compiler may be able to infer a type, this inference will be unavailable
|
||||
// to a person who is reviewing a GitHub diff. This rule makes writing code harder,
|
||||
// but writing code is a much less important activity than reading it.
|
||||
//
|
||||
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||
'@typescript-eslint/explicit-function-return-type': [
|
||||
1,
|
||||
{
|
||||
'allowExpressions': true,
|
||||
'allowTypedFunctionExpressions': true,
|
||||
'allowHigherOrderFunctions': false
|
||||
}
|
||||
],
|
||||
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||
// Rationale to disable: although this is a recommended rule, it is up to dev to select coding style.
|
||||
// Set to 1 (warning) or 2 (error) to enable.
|
||||
'@typescript-eslint/explicit-member-accessibility': 0,
|
||||
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||
'@typescript-eslint/no-array-constructor': 1,
|
||||
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||
//
|
||||
// RATIONALE: The "any" keyword disables static type checking, the main benefit of using TypeScript.
|
||||
// This rule should be suppressed only in very special cases such as JSON.stringify()
|
||||
// where the type really can be anything. Even if the type is flexible, another type
|
||||
// may be more appropriate such as "unknown", "{}", or "Record<k,V>".
|
||||
'@typescript-eslint/no-explicit-any': 1,
|
||||
// RATIONALE: The #1 rule of promises is that every promise chain must be terminated by a catch()
|
||||
// handler. Thus wherever a Promise arises, the code must either append a catch handler,
|
||||
// or else return the object to a caller (who assumes this responsibility). Unterminated
|
||||
// promise chains are a serious issue. Besides causing errors to be silently ignored,
|
||||
// they can also cause a NodeJS process to terminate unexpectedly.
|
||||
'@typescript-eslint/no-floating-promises': 2,
|
||||
// RATIONALE: Catches a common coding mistake.
|
||||
'@typescript-eslint/no-for-in-array': 2,
|
||||
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||
'@typescript-eslint/no-misused-new': 2,
|
||||
// RATIONALE: The "namespace" keyword is not recommended for organizing code because JavaScript lacks
|
||||
// a "using" statement to traverse namespaces. Nested namespaces prevent certain bundler
|
||||
// optimizations. If you are declaring loose functions/variables, it's better to make them
|
||||
// static members of a class, since classes support property getters and their private
|
||||
// members are accessible by unit tests. Also, the exercise of choosing a meaningful
|
||||
// class name tends to produce more discoverable APIs: for example, search+replacing
|
||||
// the function "reverse()" is likely to return many false matches, whereas if we always
|
||||
// write "Text.reverse()" is more unique. For large scale organization, it's recommended
|
||||
// to decompose your code into separate NPM packages, which ensures that component
|
||||
// dependencies are tracked more conscientiously.
|
||||
//
|
||||
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||
'@typescript-eslint/no-namespace': [
|
||||
1,
|
||||
{
|
||||
'allowDeclarations': false,
|
||||
'allowDefinitionFiles': false
|
||||
}
|
||||
],
|
||||
// RATIONALE: Parameter properties provide a shorthand such as "constructor(public title: string)"
|
||||
// that avoids the effort of declaring "title" as a field. This TypeScript feature makes
|
||||
// code easier to write, but arguably sacrifices readability: In the notes for
|
||||
// "@typescript-eslint/member-ordering" we pointed out that fields are central to
|
||||
// a class's design, so we wouldn't want to bury them in a constructor signature
|
||||
// just to save some typing.
|
||||
//
|
||||
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||
// Set to 1 (warning) or 2 (error) to enable the rule
|
||||
'@typescript-eslint/parameter-properties': 0,
|
||||
// RATIONALE: When left in shipping code, unused variables often indicate a mistake. Dead code
|
||||
// may impact performance.
|
||||
//
|
||||
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
1,
|
||||
{
|
||||
'vars': 'all',
|
||||
// Unused function arguments often indicate a mistake in JavaScript code. However in TypeScript code,
|
||||
// the compiler catches most of those mistakes, and unused arguments are fairly common for type signatures
|
||||
// that are overriding a base class method or implementing an interface.
|
||||
'args': 'none'
|
||||
}
|
||||
],
|
||||
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||
'@typescript-eslint/no-use-before-define': [
|
||||
2,
|
||||
{
|
||||
'functions': false,
|
||||
'classes': true,
|
||||
'variables': true,
|
||||
'enums': true,
|
||||
'typedefs': true
|
||||
}
|
||||
],
|
||||
// Disallows require statements except in import statements.
|
||||
// In other words, the use of forms such as var foo = require("foo") are banned. Instead use ES6 style imports or import foo = require("foo") imports.
|
||||
'@typescript-eslint/no-var-requires': 'error',
|
||||
// RATIONALE: The "module" keyword is deprecated except when describing legacy libraries.
|
||||
//
|
||||
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||
'@typescript-eslint/prefer-namespace-keyword': 1,
|
||||
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||
// Rationale to disable: it's up to developer to decide if he wants to add type annotations
|
||||
// Set to 1 (warning) or 2 (error) to enable the rule
|
||||
'@typescript-eslint/no-inferrable-types': 0,
|
||||
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||
// Rationale to disable: declaration of empty interfaces may be helpful for generic types scenarios
|
||||
'@typescript-eslint/no-empty-interface': 0,
|
||||
// RATIONALE: This rule warns if setters are defined without getters, which is probably a mistake.
|
||||
'accessor-pairs': 1,
|
||||
// RATIONALE: In TypeScript, if you write x["y"] instead of x.y, it disables type checking.
|
||||
'dot-notation': [
|
||||
1,
|
||||
{
|
||||
'allowPattern': '^_'
|
||||
}
|
||||
],
|
||||
// RATIONALE: Catches code that is likely to be incorrect
|
||||
'eqeqeq': 1,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'for-direction': 1,
|
||||
// RATIONALE: Catches a common coding mistake.
|
||||
'guard-for-in': 2,
|
||||
// RATIONALE: If you have more than 2,000 lines in a single source file, it's probably time
|
||||
// to split up your code.
|
||||
'max-lines': ['warn', { max: 2000 }],
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-async-promise-executor': 2,
|
||||
// RATIONALE: Deprecated language feature.
|
||||
'no-caller': 2,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-compare-neg-zero': 2,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-cond-assign': 2,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-constant-condition': 1,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-control-regex': 2,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-debugger': 1,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-delete-var': 2,
|
||||
// RATIONALE: Catches code that is likely to be incorrect
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-duplicate-case': 2,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-empty': 1,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-empty-character-class': 2,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-empty-pattern': 1,
|
||||
// RATIONALE: Eval is a security concern and a performance concern.
|
||||
'no-eval': 1,
|
||||
// RATIONALE: Catches code that is likely to be incorrect
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-ex-assign': 2,
|
||||
// RATIONALE: System types are global and should not be tampered with in a scalable code base.
|
||||
// If two different libraries (or two versions of the same library) both try to modify
|
||||
// a type, only one of them can win. Polyfills are acceptable because they implement
|
||||
// a standardized interoperable contract, but polyfills are generally coded in plain
|
||||
// JavaScript.
|
||||
'no-extend-native': 1,
|
||||
// Disallow unnecessary labels
|
||||
'no-extra-label': 1,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-fallthrough': 2,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-func-assign': 1,
|
||||
// RATIONALE: Catches a common coding mistake.
|
||||
'no-implied-eval': 2,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-invalid-regexp': 2,
|
||||
// RATIONALE: Catches a common coding mistake.
|
||||
'no-label-var': 2,
|
||||
// RATIONALE: Eliminates redundant code.
|
||||
'no-lone-blocks': 1,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-misleading-character-class': 2,
|
||||
// RATIONALE: Catches a common coding mistake.
|
||||
'no-multi-str': 2,
|
||||
// RATIONALE: It's generally a bad practice to call "new Thing()" without assigning the result to
|
||||
// a variable. Either it's part of an awkward expression like "(new Thing()).doSomething()",
|
||||
// or else implies that the constructor is doing nontrivial computations, which is often
|
||||
// a poor class design.
|
||||
'no-new': 1,
|
||||
// RATIONALE: Obsolete language feature that is deprecated.
|
||||
'no-new-func': 2,
|
||||
// RATIONALE: Obsolete language feature that is deprecated.
|
||||
'no-new-object': 2,
|
||||
// RATIONALE: Obsolete notation.
|
||||
'no-new-wrappers': 1,
|
||||
// RATIONALE: Catches code that is likely to be incorrect
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-octal': 2,
|
||||
// RATIONALE: Catches code that is likely to be incorrect
|
||||
'no-octal-escape': 2,
|
||||
// RATIONALE: Catches code that is likely to be incorrect
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-regex-spaces': 2,
|
||||
// RATIONALE: Catches a common coding mistake.
|
||||
'no-return-assign': 2,
|
||||
// RATIONALE: Security risk.
|
||||
'no-script-url': 1,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-self-assign': 2,
|
||||
// RATIONALE: Catches a common coding mistake.
|
||||
'no-self-compare': 2,
|
||||
// RATIONALE: This avoids statements such as "while (a = next(), a && a.length);" that use
|
||||
// commas to create compound expressions. In general code is more readable if each
|
||||
// step is split onto a separate line. This also makes it easier to set breakpoints
|
||||
// in the debugger.
|
||||
'no-sequences': 1,
|
||||
// RATIONALE: Catches code that is likely to be incorrect
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-shadow-restricted-names': 2,
|
||||
// RATIONALE: Obsolete language feature that is deprecated.
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-sparse-arrays': 2,
|
||||
// RATIONALE: Although in theory JavaScript allows any possible data type to be thrown as an exception,
|
||||
// such flexibility adds pointless complexity, by requiring every catch block to test
|
||||
// the type of the object that it receives. Whereas if catch blocks can always assume
|
||||
// that their object implements the "Error" contract, then the code is simpler, and
|
||||
// we generally get useful additional information like a call stack.
|
||||
'no-throw-literal': 2,
|
||||
// RATIONALE: Catches a common coding mistake.
|
||||
'no-unmodified-loop-condition': 1,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-unsafe-finally': 2,
|
||||
// RATIONALE: Catches a common coding mistake.
|
||||
'no-unused-expressions': 1,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-unused-labels': 1,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-useless-catch': 1,
|
||||
// RATIONALE: Avoids a potential performance problem.
|
||||
'no-useless-concat': 1,
|
||||
// RATIONALE: The "var" keyword is deprecated because of its confusing "hoisting" behavior.
|
||||
// Always use "let" or "const" instead.
|
||||
//
|
||||
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||
'no-var': 2,
|
||||
// RATIONALE: Generally not needed in modern code.
|
||||
'no-void': 1,
|
||||
// RATIONALE: Obsolete language feature that is deprecated.
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'no-with': 2,
|
||||
// RATIONALE: Makes logic easier to understand, since constants always have a known value
|
||||
// @typescript-eslint\eslint-plugin\dist\configs\eslint-recommended.js
|
||||
'prefer-const': 1,
|
||||
// RATIONALE: Catches a common coding mistake where "resolve" and "reject" are confused.
|
||||
'promise/param-names': 2,
|
||||
// RATIONALE: Catches code that is likely to be incorrect
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'require-atomic-updates': 2,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'require-yield': 1,
|
||||
// "Use strict" is redundant when using the TypeScript compiler.
|
||||
'strict': [
|
||||
2,
|
||||
'never'
|
||||
],
|
||||
// RATIONALE: Catches code that is likely to be incorrect
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
'use-isnan': 2,
|
||||
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||
// Set to 1 (warning) or 2 (error) to enable.
|
||||
// Rationale to disable: !!{}
|
||||
'no-extra-boolean-cast': 0,
|
||||
// ====================================================================
|
||||
// @microsoft/eslint-plugin-spfx
|
||||
// ====================================================================
|
||||
'@microsoft/spfx/no-require-ensure': 2,
|
||||
}
|
||||
},
|
||||
{
|
||||
// For unit tests, we can be a little bit less strict. The settings below revise the
|
||||
// defaults specified in the extended configurations, as well as above.
|
||||
files: [
|
||||
// Test files
|
||||
'*.test.ts',
|
||||
'*.test.tsx',
|
||||
'*.spec.ts',
|
||||
'*.spec.tsx',
|
||||
|
||||
// Facebook convention
|
||||
'**/__mocks__/*.ts',
|
||||
'**/__mocks__/*.tsx',
|
||||
'**/__tests__/*.ts',
|
||||
'**/__tests__/*.tsx',
|
||||
|
||||
// Microsoft convention
|
||||
'**/test/*.ts',
|
||||
'**/test/*.tsx'
|
||||
],
|
||||
rules: {}
|
||||
}
|
||||
]
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
lib/
|
||||
lib-commonjs/
|
||||
dist/
|
||||
release/
|
||||
temp/
|
||||
jest-output/
|
||||
*.sppkg
|
||||
.heft/
|
||||
sharepoint/solution/
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Ignore everything by default
|
||||
**
|
||||
|
||||
# Use negative patterns to bring back the specific things we want to publish
|
||||
!/bin/**
|
||||
!/dist/**
|
||||
!/EULA/**
|
||||
!/lib/**
|
||||
!ThirdPartyNotice.txt
|
||||
|
||||
# Ignore certain files in the above folders
|
||||
/dist/*.stats.*
|
||||
/dist/**/*.js.map
|
||||
/lib/**/*.js.map
|
||||
/lib/**/test/**
|
||||
|
||||
# NOTE: These don't need to be specified, because NPM includes them automatically.
|
||||
#
|
||||
# package.json
|
||||
# README (and its variants)
|
||||
# CHANGELOG (and its variants)
|
||||
# LICENSE / LICENCE
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Hosted workbench",
|
||||
"type": "msedge",
|
||||
"request": "launch",
|
||||
"url": "https://{tenantDomain}/_layouts/workbench.aspx",
|
||||
"webRoot": "${workspaceRoot}",
|
||||
"sourceMaps": true,
|
||||
"sourceMapPathOverrides": {
|
||||
"webpack:///.././src/*": "${webRoot}/src/*",
|
||||
"webpack:///../../../src/*": "${webRoot}/src/*",
|
||||
"webpack:///../../../../src/*": "${webRoot}/src/*",
|
||||
"webpack:///../../../../../src/*": "${webRoot}/src/*"
|
||||
},
|
||||
"runtimeArgs": [
|
||||
"--remote-debugging-port=9222",
|
||||
"-incognito"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
// Place your settings in this file to overwrite default and user settings.
|
||||
{
|
||||
// Configure glob patterns for excluding files and folders in the file explorer.
|
||||
"files.exclude": {
|
||||
"**/.git": true,
|
||||
"**/.DS_Store": true,
|
||||
"**/bower_components": true,
|
||||
"**/coverage": true,
|
||||
"**/jest-output": true,
|
||||
"**/lib-amd": true,
|
||||
"src/**/*.scss.ts": true
|
||||
},
|
||||
"typescript.tsdk": ".\\node_modules\\typescript\\lib"
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"@microsoft/generator-sharepoint": {
|
||||
"useGulp": false,
|
||||
"plusBeta": false,
|
||||
"isCreatingSolution": true,
|
||||
"nodeVersion": "22.22.2",
|
||||
"sdksVersions": {},
|
||||
"version": "1.22.2",
|
||||
"libraryName": "request-portal",
|
||||
"libraryId": "8c31266a-712c-4c72-ae04-bca5a6a071c3",
|
||||
"environment": "spo",
|
||||
"packageManager": "npm",
|
||||
"solutionName": "request-portal",
|
||||
"solutionShortDescription": "request-portal description",
|
||||
"skipFeatureDeployment": true,
|
||||
"isDomainIsolated": false,
|
||||
"componentType": "webpart"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
Ты — опытный Senior Frontend Engineer.
|
||||
Твоя специализация: React (TypeScript), корпоративные веб‑приложения, порталы и SPA.
|
||||
|
||||
ОБЩИЕ ПРИНЦИПЫ
|
||||
- Пиши код так, как если бы он шёл в production.
|
||||
- Предпочитай простые и читаемые решения.
|
||||
- Избегай overengineering.
|
||||
- Всегда думай о поддерживаемости кода через 1–2 года.
|
||||
- Не выдумывай API, props или окружение — используй только то, что явно указано.
|
||||
- Если информации недостаточно — делай разумные предположения и явно их помечай.
|
||||
|
||||
СТЕК И СИНТАКСИС
|
||||
- React (Function Components)
|
||||
- TypeScript (строгая типизация, без any)
|
||||
- Hooks вместо классов
|
||||
- JSX / TSX
|
||||
- CSS Modules или SCSS Modules
|
||||
- Без deprecated API
|
||||
|
||||
СТРУКТУРА КОДА
|
||||
- Один компонент — один файл
|
||||
- Явные интерфейсы props (`interface Props {}`)
|
||||
- Логика отделена от представления
|
||||
- Минимум вложенности
|
||||
- Ранние return’ы вместо глубоких if
|
||||
|
||||
РАЗМЕР И ДЕКОМПОЗИЦИЯ
|
||||
- Компонент — не больше 300 строк. Больше — сигнал, что пора разбивать.
|
||||
- Функция / хук — не больше ~50 строк, делает одну вещь.
|
||||
- Файл делает что-то одно: не смешивай несколько компонентов в одном файле.
|
||||
- Не больше 3–4 уровней вложенности JSX — глубже выноси в под-компонент.
|
||||
- Props: до ~7 штук. Больше — сгруппируй в объект или пересмотри разбивку.
|
||||
- Большой компонент дели по смыслу: под-компоненты (`Foo.tsx` → `FooHeader.tsx`, `FooRow.tsx`), кастомные хуки для логики (`useFooData.ts`), утилиты (`utils/`).
|
||||
- Повторяешь один и тот же блок 3-й раз — выноси в общий компонент/функцию.
|
||||
|
||||
ИМЕНА И СТИЛЬ
|
||||
- Компоненты: PascalCase
|
||||
- Хуки: useSomething
|
||||
- Переменные: camelCase
|
||||
- CSS‑классы: kebab-case или camelCase (последовательно)
|
||||
- Названия должны отражать смысл, а не реализацию
|
||||
|
||||
REACT‑ПРАКТИКИ
|
||||
- useEffect — только для сайд‑эффектов
|
||||
- Не клади бизнес‑логику в JSX
|
||||
- Используй useMemo / useCallback только при реальной необходимости
|
||||
- Контролируемые компоненты предпочтительнее неконтролируемых
|
||||
- Не мутируй props и state
|
||||
|
||||
УПРАВЛЕНИЕ СОСТОЯНИЕМ
|
||||
- Состояние живёт как можно ниже; поднимай наверх только то, что реально общее.
|
||||
- State многошаговой формы держи в одном родителе (например, `CtCreateForm`) — шаги получают срез данных и колбэки, а не тянут состояние сами.
|
||||
- Prop-drilling глубже 2–3 уровней — сигнал вынести общее в React Context.
|
||||
- Context — для по-настоящему сквозных данных (текущая СТ, пользователь, справочники), не для всего подряд.
|
||||
- Один источник истины: не дублируй одни и те же данные в нескольких useState.
|
||||
- Изменяй state иммутабельно (`setItems(prev => [...prev, x])`), без мутаций массивов/объектов.
|
||||
- Производные данные считай на лету (useMemo), не храни в отдельном state.
|
||||
|
||||
LAYOUT И ВЁРСТКА
|
||||
- Верстка адаптивная
|
||||
- Используй flex / grid
|
||||
- Контейнеры по умолчанию занимают 100% доступной ширины
|
||||
- Не используй magic‑numbers без комментариев
|
||||
- Padding и margin — консистентные
|
||||
|
||||
КОНСТАНТЫ И СТРОКИ
|
||||
- Никаких magic-strings в логике: статусы, ключи и имена SP-списков, роуты — в константах.
|
||||
- Повторяющиеся значения выноси в один модуль констант (`constants.ts`), а не хардкодь по месту.
|
||||
- Статусы и перечисления — через union-типы или enum, чтобы опечатка ловилась компилятором.
|
||||
- Числовые «магические» значения — именованные константы с понятным смыслом.
|
||||
- Пользовательские тексты собирай в одном месте — задел под локализацию, чтобы строки не были размазаны по JSX.
|
||||
- URL, id ресурсов и настройки окружения — не в коде компонентов, а в конфиге/константах.
|
||||
|
||||
КОММЕНТАРИИ
|
||||
- Комментируй ПОЧЕМУ, а не ЧТО
|
||||
- Не дублируй комментариями очевидный код
|
||||
- Для сложной логики — короткий поясняющий комментарий
|
||||
|
||||
ОШИБКИ И СОСТОЯНИЯ
|
||||
- Явно обрабатывай loading / error / empty state
|
||||
- Не скрывай ошибки
|
||||
- UI должен корректно вести себя при отсутствии данных
|
||||
|
||||
GIT И КОММИТЫ
|
||||
- Формат: `тип: краткое описание в настоящем времени` (`feat:`, `fix:`, `refactor:`, `chore:`, `docs:`, `test:`).
|
||||
- Один коммит — одно логическое изменение, без свалки несвязанных правок.
|
||||
- Сообщение объясняет ЧТО и ЗАЧЕМ, а не пересказывает диф.
|
||||
- Не коммить закомментированный/отладочный код и секреты.
|
||||
- Пиши так, чтобы по истории джун понял ход работы без расспросов.
|
||||
|
||||
ФОРМАТ ОТВЕТА
|
||||
- Если даёшь код — давай ПОЛНЫЙ пример, а не фрагмент
|
||||
- Если есть несколько вариантов — объясни, почему выбран именно этот
|
||||
- Не пиши лишний текст вокруг кода
|
||||
- Ответы краткие, структурированные, по делу
|
||||
|
||||
ПОДДЕРЖИВАЕМОСТЬ (ЧТОБЫ ДЖУН МОГ ПОДХВАТИТЬ)
|
||||
- Пиши так, будто проект дальше ведёт джун без твоего контекста.
|
||||
- Никакой «магии» и умных однострочников — понятный код важнее короткого.
|
||||
- Понятные имена вместо комментариев-костылей: `isLoading`, а не `flag`.
|
||||
- Предпочитай явное неявному: не полагайся на неочевидные побочные эффекты.
|
||||
- Структура папок предсказуемая и одинаковая по всему проекту.
|
||||
- Типы описывают данные явно — джун должен понять форму объекта, не запуская код.
|
||||
- Сложную бизнес-логику сопровождай коротким «почему» в комментарии.
|
||||
- Не оставляй мёртвый/закомментированный код — удаляй.
|
||||
|
||||
КОНТЕКСТ
|
||||
- Код пишется для корпоративного портала
|
||||
- Основной приоритет: читаемость, стабильность, предсказуемость
|
||||
@@ -0,0 +1,77 @@
|
||||
# request-portal
|
||||
|
||||
## Summary
|
||||
|
||||
Short summary on functionality and used technologies.
|
||||
|
||||
[picture of the solution in action, if possible]
|
||||
|
||||
## Used SharePoint Framework Version
|
||||
|
||||

|
||||
|
||||
## Applies to
|
||||
|
||||
- [SharePoint Framework](https://aka.ms/spfx)
|
||||
- [Microsoft 365 tenant](https://docs.microsoft.com/sharepoint/dev/spfx/set-up-your-developer-tenant)
|
||||
|
||||
> Get your own free development tenant by subscribing to [Microsoft 365 developer program](http://aka.ms/o365devprogram)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
> Any special pre-requisites?
|
||||
|
||||
## Solution
|
||||
|
||||
| Solution | Author(s) |
|
||||
| ----------- | ------------------------------------------------------- |
|
||||
| folder name | Author details (name, company, twitter alias with link) |
|
||||
|
||||
## Version history
|
||||
|
||||
| Version | Date | Comments |
|
||||
| ------- | ---------------- | --------------- |
|
||||
| 1.1 | March 10, 2021 | Update comment |
|
||||
| 1.0 | January 29, 2021 | Initial release |
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**THIS CODE IS PROVIDED _AS IS_ WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
|
||||
|
||||
---
|
||||
|
||||
## Minimal Path to Awesome
|
||||
|
||||
- Clone this repository
|
||||
- Ensure that you are at the solution folder
|
||||
- in the command-line run:
|
||||
- `npm install -g @rushstack/heft`
|
||||
- `npm install`
|
||||
- `heft start`
|
||||
|
||||
> Include any additional steps as needed.
|
||||
|
||||
Other build commands can be listed using `heft --help`.
|
||||
|
||||
## Features
|
||||
|
||||
Description of the extension that expands upon high-level summary above.
|
||||
|
||||
This extension illustrates the following concepts:
|
||||
|
||||
- topic 1
|
||||
- topic 2
|
||||
- topic 3
|
||||
|
||||
> Notice that better pictures and documentation will increase the sample usage and the value you are providing for others. Thanks for your submissions advance.
|
||||
|
||||
> Share your web part with others through Microsoft 365 Patterns and Practices program to get visibility and exposure. More details on the community, open-source projects and other activities from http://aka.ms/m365pnp.
|
||||
|
||||
## References
|
||||
|
||||
- [Getting started with SharePoint Framework](https://docs.microsoft.com/sharepoint/dev/spfx/set-up-your-developer-tenant)
|
||||
- [Building for Microsoft teams](https://docs.microsoft.com/sharepoint/dev/spfx/build-for-teams-overview)
|
||||
- [Use Microsoft Graph in your solution](https://docs.microsoft.com/sharepoint/dev/spfx/web-parts/get-started/using-microsoft-graph-apis)
|
||||
- [Publish SharePoint Framework applications to the Marketplace](https://docs.microsoft.com/sharepoint/dev/spfx/publish-to-marketplace-overview)
|
||||
- [Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) - Guidance, tooling, samples and open-source controls for your Microsoft 365 development
|
||||
- [Heft Documentation](https://heft.rushstack.io/)
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
|
||||
"version": "2.0",
|
||||
"bundles": {
|
||||
"request-portal-web-part": {
|
||||
"components": [
|
||||
{
|
||||
"entrypoint": "./lib/webparts/requestPortal/RequestPortalWebPart.js",
|
||||
"manifest": "./src/webparts/requestPortal/RequestPortalWebPart.manifest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"externals": {},
|
||||
"localizedResources": {
|
||||
"RequestPortalWebPartStrings": "lib/webparts/requestPortal/loc/{locale}.js",
|
||||
"ControlStrings": "node_modules/@pnp/spfx-controls-react/lib/loc/{locale}.js"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
|
||||
"workingDir": "./release/assets/",
|
||||
"account": "<!-- STORAGE ACCOUNT NAME -->",
|
||||
"container": "request-portal",
|
||||
"accessKey": "<!-- ACCESS KEY -->"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
|
||||
"solution": {
|
||||
"name": "request-portal-client-side-solution",
|
||||
"id": "8c31266a-712c-4c72-ae04-bca5a6a071c3",
|
||||
"version": "1.0.0.0",
|
||||
"includeClientSideAssets": true,
|
||||
"skipFeatureDeployment": true,
|
||||
"isDomainIsolated": false,
|
||||
"developer": {
|
||||
"name": "",
|
||||
"websiteUrl": "",
|
||||
"privacyUrl": "",
|
||||
"termsOfUseUrl": "",
|
||||
"mpnId": "Undefined-1.22.2"
|
||||
},
|
||||
"metadata": {
|
||||
"shortDescription": {
|
||||
"default": "request-portal description"
|
||||
},
|
||||
"longDescription": {
|
||||
"default": "request-portal description"
|
||||
},
|
||||
"screenshotPaths": [],
|
||||
"videoUrl": "",
|
||||
"categories": []
|
||||
},
|
||||
"webApiPermissionRequests": [
|
||||
{
|
||||
"resource": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||
"scope": "Reports.Read"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"title": "request-portal Feature",
|
||||
"description": "The feature that activates elements of the request-portal solution.",
|
||||
"id": "62ca9536-2532-4e3a-8bbb-9f83ae032708",
|
||||
"version": "1.0.0.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"paths": {
|
||||
"zippedPackage": "solution/request-portal.sppkg"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
|
||||
"rigPackageName": "@microsoft/spfx-web-build-rig"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/spfx-serve.schema.json",
|
||||
"port": 4321,
|
||||
"https": true,
|
||||
"initialPage": "https://tnsukz.sharepoint.com/_layouts/workbench.aspx"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
|
||||
"cdnBasePath": "<!-- PATH TO CDN -->"
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
# =============================================================
|
||||
# SPFx + Heft project bootstrap script (PowerShell)
|
||||
# Запустите из корня вашей ПУСТОЙ папки проекта:
|
||||
# .\setup-spfx-heft.ps1
|
||||
#
|
||||
# Если запуск скриптов заблокирован, выполните сначала:
|
||||
# Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
|
||||
# =============================================================
|
||||
|
||||
$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 "=================================================`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 "Все зависимости установлены."
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Готово
|
||||
# ---------------------------------------------------------
|
||||
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 " Следующий шаг: " -NoNewline -ForegroundColor Yellow
|
||||
Write-Host "npm run serve" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Generated
+25425
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "request-portal",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.14.0 < 23.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "heft test --clean --production && heft package-solution --production",
|
||||
"start": "heft start --clean",
|
||||
"clean": "heft clean",
|
||||
"eject-webpack": "heft eject-webpack"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluentui/react": "^8.106.4",
|
||||
"@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",
|
||||
"@pnp/sp": "^4.20.0",
|
||||
"@pnp/spfx-controls-react": "^3.25.0",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1",
|
||||
"tslib": "2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@microsoft/eslint-config-spfx": "1.22.2",
|
||||
"@microsoft/eslint-plugin-spfx": "1.22.2",
|
||||
"@microsoft/sp-module-interfaces": "1.22.2",
|
||||
"@microsoft/spfx-heft-plugins": "1.22.2",
|
||||
"@microsoft/spfx-web-build-rig": "1.22.2",
|
||||
"@rushstack/eslint-config": "4.5.2",
|
||||
"@rushstack/heft": "1.1.2",
|
||||
"@types/heft-jest": "1.0.2",
|
||||
"@types/react": "17.0.45",
|
||||
"@types/react-dom": "17.0.17",
|
||||
"@types/webpack-env": "~1.15.2",
|
||||
"@typescript-eslint/parser": "8.46.2",
|
||||
"css-loader": "~7.1.2",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-plugin-react-hooks": "4.3.0",
|
||||
"typescript": "~5.8.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@rushstack/heft": "1.1.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "17.0.45"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Идемпотентно создаёт списки данных форм портала заявок в SharePoint Online
|
||||
по манифестам provisioning.json (единый источник правды по колонкам).
|
||||
|
||||
.DESCRIPTION
|
||||
Для каждого файла forms/<Форма>/provisioning.json скрипт:
|
||||
1) создаёт список данных (если его ещё нет);
|
||||
2) при необходимости переименовывает колонку Title;
|
||||
3) добавляет недостающие колонки нужных типов.
|
||||
Повторный запуск безопасен: существующие список и колонки не трогаются
|
||||
(перед созданием колонки проверяется, что её ещё нет).
|
||||
|
||||
Вход — по сертификату «от имени приложения» (app-only, право Sites.Selected).
|
||||
Сертификат берётся из личного хранилища по отпечатку (Thumbprint).
|
||||
|
||||
Списки согласования пока НЕ создаются — только данные (по договорённости).
|
||||
|
||||
.PARAMETER SiteUrl
|
||||
URL сайта, например https://tnsukz.sharepoint.com/sites/requests
|
||||
|
||||
.PARAMETER ClientId
|
||||
Client Id приложения-провижинера (Sites.Selected).
|
||||
|
||||
.PARAMETER Tenant
|
||||
Тенант, например tnsukz.onmicrosoft.com
|
||||
|
||||
.PARAMETER Thumbprint
|
||||
Отпечаток сертификата в хранилище CurrentUser\My.
|
||||
|
||||
.PARAMETER FormsRoot
|
||||
Папка с формами. По умолчанию — src/webparts/requestPortal/forms.
|
||||
|
||||
.EXAMPLE
|
||||
./Provision-Lists.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/requests" `
|
||||
-ClientId "7459c965-5ed4-4673-8140-0ab8faf9db64" -Tenant "tnsukz.onmicrosoft.com" `
|
||||
-Thumbprint "AABBCC...."
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$SiteUrl,
|
||||
[Parameter(Mandatory = $true)][string]$ClientId,
|
||||
[Parameter(Mandatory = $true)][string]$Tenant,
|
||||
[Parameter(Mandatory = $true)][string]$Thumbprint,
|
||||
[string]$FormsRoot = "$PSScriptRoot/../src/webparts/requestPortal/forms"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Экранирование значений, попадающих в XML полей (Lookup/Number с точностью).
|
||||
function ConvertTo-XmlSafe {
|
||||
param([string]$Value)
|
||||
return $Value.Replace('&', '&').Replace('<', '<').Replace('>', '>').Replace("'", ''').Replace('"', '"')
|
||||
}
|
||||
|
||||
function Test-FieldExists {
|
||||
param([string]$List, [string]$InternalName)
|
||||
return $null -ne (Get-PnPField -List $List -Identity $InternalName -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
# Создаёт одну колонку по описанию из манифеста (идемпотентно).
|
||||
function Add-Column {
|
||||
param([string]$List, $Col)
|
||||
|
||||
if (Test-FieldExists -List $List -InternalName $Col.internalName) {
|
||||
Write-Host " [=] колонка есть: $($Col.internalName)" -ForegroundColor DarkGray
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($Col.type) {
|
||||
'Text' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Text -AddToDefaultView | Out-Null }
|
||||
'Note' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Note -AddToDefaultView | Out-Null }
|
||||
'DateTime' {
|
||||
Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type DateTime -AddToDefaultView | Out-Null
|
||||
if ($Col.dateOnly) {
|
||||
# 0 = только дата; 1 = отключить относительный формат («Завтра», «вчера»)
|
||||
Set-PnPField -List $List -Identity $Col.internalName -Values @{ DisplayFormat = 0; FriendlyDisplayFormat = 1 } | Out-Null
|
||||
}
|
||||
}
|
||||
'Boolean' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Boolean -AddToDefaultView | Out-Null }
|
||||
'User' {
|
||||
if ($Col.allowMultiple) {
|
||||
# Мульти-пользователь: только через XML (UserMulti нет в enum FieldType).
|
||||
$xml = "<Field Type='UserMulti' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' Mult='TRUE' />"
|
||||
Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null
|
||||
}
|
||||
else {
|
||||
Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type User -AddToDefaultView | Out-Null
|
||||
}
|
||||
if ($Col.userSelectionMode) {
|
||||
# 0 = только люди, 1 = люди и группы
|
||||
$mode = if ($Col.userSelectionMode -eq 'PeopleAndGroups') { 1 } else { 0 }
|
||||
Set-PnPField -List $List -Identity $Col.internalName -Values @{ SelectionMode = $mode } | Out-Null
|
||||
}
|
||||
}
|
||||
'Choice' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -AddToDefaultView | Out-Null }
|
||||
'Number' {
|
||||
if ($null -ne $Col.decimals) {
|
||||
# Точность задаётся только через XML.
|
||||
$xml = "<Field Type='Number' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' Decimals='$($Col.decimals)' />"
|
||||
Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null
|
||||
}
|
||||
else {
|
||||
Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Number -AddToDefaultView | Out-Null
|
||||
}
|
||||
}
|
||||
'Lookup' {
|
||||
# Список-источник должен уже существовать (см. про порядок в README).
|
||||
$src = Get-PnPList -Identity $Col.lookupList -ErrorAction Stop
|
||||
$guid = $src.Id.ToString()
|
||||
$show = if ($Col.lookupShowField) { $Col.lookupShowField } else { 'Title' }
|
||||
$xml = "<Field Type='Lookup' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' ShowField='$show' List='{$guid}' />"
|
||||
Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null
|
||||
}
|
||||
default { throw "Неизвестный тип колонки '$($Col.type)'" }
|
||||
}
|
||||
|
||||
if ($Col.required) {
|
||||
Set-PnPField -List $List -Identity $Col.internalName -Values @{ Required = $true } | Out-Null
|
||||
}
|
||||
|
||||
Write-Host " [+] $($Col.internalName) — «$($Col.title)» [$($Col.type)]" -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
Write-Warning " [!] $($Col.internalName): $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Подключение к $SiteUrl (app-only) ..." -ForegroundColor Cyan
|
||||
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
|
||||
|
||||
$manifests = Get-ChildItem -Path $FormsRoot -Recurse -Filter 'provisioning.json'
|
||||
if (-not $manifests) {
|
||||
Write-Warning "Манифесты provisioning.json не найдены в $FormsRoot"
|
||||
Disconnect-PnPOnline
|
||||
return
|
||||
}
|
||||
|
||||
foreach ($manifest in $manifests) {
|
||||
$def = Get-Content -Path $manifest.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$listTitle = $def.dataListTitle
|
||||
Write-Host "`n=== Форма: $($manifest.Directory.Name) → список «$listTitle» ===" -ForegroundColor Cyan
|
||||
|
||||
# 1) Список (идемпотентно)
|
||||
if (-not (Get-PnPList -Identity $listTitle -ErrorAction SilentlyContinue)) {
|
||||
New-PnPList -Title $listTitle -Template GenericList -EnableVersioning | Out-Null
|
||||
Write-Host " [+] создан список" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host " [=] список уже существует" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# 2) Переименование Title (если задано)
|
||||
if ($def.titleColumnName) {
|
||||
try {
|
||||
Set-PnPField -List $listTitle -Identity 'Title' -Values @{ Title = $def.titleColumnName } | Out-Null
|
||||
Write-Host " [~] Title → «$($def.titleColumnName)»" -ForegroundColor DarkGray
|
||||
}
|
||||
catch {
|
||||
Write-Warning " [!] не удалось переименовать Title: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# 3) Колонки (идемпотентно)
|
||||
foreach ($col in $def.columns) {
|
||||
Add-Column -List $listTitle -Col $col
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`nГотово." -ForegroundColor Cyan
|
||||
Disconnect-PnPOnline
|
||||
@@ -0,0 +1,84 @@
|
||||
# Провижининг списков SharePoint
|
||||
|
||||
Автоматическое создание списков **данных** для форм портала заявок из манифестов
|
||||
`provisioning.json` (по одному на форму, лежат в папке каждой формы).
|
||||
|
||||
> Списки **согласования** пока не создаются — только данные.
|
||||
|
||||
## Как это работает
|
||||
|
||||
- Каждая форма описывает свои колонки в `src/webparts/requestPortal/forms/<Форма>/provisioning.json`.
|
||||
- Скрипт `Provision-Lists.ps1` читает все такие манифесты и **идемпотентно** создаёт
|
||||
списки и колонки: перед созданием колонки проверяется, что её ещё нет; повторный
|
||||
запуск ничего не ломает и не дублирует.
|
||||
|
||||
Добавить колонку/форму = отредактировать/добавить `provisioning.json` и перезапустить скрипт.
|
||||
|
||||
## Поддерживаемые типы колонок (`type` в манифесте)
|
||||
|
||||
| type | Что это | Доп. поля |
|
||||
| ---------- | ------------------ | --------- |
|
||||
| `Text` | однострочный текст | — |
|
||||
| `Note` | многострочный текст| — |
|
||||
| `Number` | число | `decimals` (знаков после запятой) |
|
||||
| `Choice` | выбор из вариантов | `choices` (массив) |
|
||||
| `DateTime` | дата/время | — |
|
||||
| `Boolean` | да/нет | — |
|
||||
| `User` | пользователь | — |
|
||||
| `Lookup` | подстановка | `lookupList` (заголовок источника), `lookupShowField` (по умолч. `Title`) |
|
||||
|
||||
Плюс на уровне формы: `titleColumnName` — переименовать стандартную колонку `Title`.
|
||||
|
||||
> **Порядок для Lookup:** список-источник должен существовать раньше, чем колонка,
|
||||
> которая на него ссылается. Если источник в другой форме — сначала прогоните её манифест.
|
||||
|
||||
## Разовая подготовка
|
||||
|
||||
1. Установить модуль PnP PowerShell (нужен PowerShell 7+):
|
||||
```powershell
|
||||
Install-Module PnP.PowerShell -Scope CurrentUser
|
||||
```
|
||||
2. Зарегистрировать приложение с правом `Sites.Selected` и сертификатом в хранилище:
|
||||
```powershell
|
||||
Register-PnPEntraIDApp -ApplicationName "PnP-Provisioning-Scoped" -Tenant "<тенант>.onmicrosoft.com" -SharePointApplicationPermissions "Sites.Selected" -Store CurrentUser
|
||||
```
|
||||
Запомнить **Client Id** из вывода.
|
||||
3. Администратор один раз:
|
||||
- одобряет запрос на согласие приложения (Admin consent),
|
||||
- выдаёт приложению доступ **только к нужному сайту**:
|
||||
```powershell
|
||||
Grant-PnPAzureADAppSitePermission -AppId "<client-id>" -DisplayName "PnP-Provisioning-Scoped" -Permissions FullControl -Site "https://<тенант>.sharepoint.com/sites/<сайт>"
|
||||
```
|
||||
|
||||
## Запуск
|
||||
|
||||
Узнать отпечаток сертификата (Thumbprint):
|
||||
```powershell
|
||||
Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.Subject -like "*PnP-Provisioning-Scoped*" } | Select-Object Subject, Thumbprint
|
||||
```
|
||||
|
||||
Запустить провижининг (вход по сертификату, без пароля в коде):
|
||||
```powershell
|
||||
cd provisioning
|
||||
./Provision-Lists.ps1 `
|
||||
-SiteUrl "https://<тенант>.sharepoint.com/sites/<сайт>" `
|
||||
-ClientId "<client-id>" `
|
||||
-Tenant "<тенант>.onmicrosoft.com" `
|
||||
-Thumbprint "<отпечаток>"
|
||||
```
|
||||
|
||||
Лог: `[+]` — создано, `[=]` — уже существовало, `[~]` — переименовано, `[!]` — предупреждение.
|
||||
|
||||
## Важно про имена колонок
|
||||
|
||||
В манифесте у колонки два имени:
|
||||
- `internalName` — латиницей, без пробелов, ≤32 символов (техническое имя в SharePoint);
|
||||
- `title` — отображаемое, любое (рус., с пробелами).
|
||||
|
||||
Так мы избегаем проблемы, когда SharePoint кодирует пробелы в `_x0020_` и обращения к
|
||||
полю ломаются. Не создавайте колонки с русским internalName.
|
||||
|
||||
## Проверка на тесте
|
||||
|
||||
Сначала прогоните на **тестовом** сайте, убедитесь, что списки и колонки создались
|
||||
правильно, и только потом — на боевом.
|
||||
@@ -0,0 +1,132 @@
|
||||
// ============================================
|
||||
// Tansu Construction — Design Tokens (V5: Executive Premium)
|
||||
// Партиал: только SCSS-переменные и миксины, БЕЗ вывода CSS.
|
||||
// Импортируется в каждый *.module.scss:
|
||||
// @import '<путь>/styles/design-tokens';
|
||||
// Здесь намеренно нет блока :root — чтобы импорт в десятки
|
||||
// модулей не дублировал CSS-переменные в бандле. Значения
|
||||
// используем напрямую через SCSS-переменные ($navy-800 и т.д.).
|
||||
// ============================================
|
||||
|
||||
// ---- Navy (Primary) ----
|
||||
$navy-950: #0A1B30;
|
||||
$navy-900: #0F2440;
|
||||
$navy-800: #1B3A5C;
|
||||
$navy-700: #244B73;
|
||||
$navy-600: #2D5C8A;
|
||||
$navy-100: #E8EEF4;
|
||||
$navy-50: #F4F7FA;
|
||||
|
||||
// ---- Gold (Accent) ----
|
||||
$gold-600: #B8881A;
|
||||
$gold-500: #D4A01E;
|
||||
$gold-400: #E5B635;
|
||||
$gold-300: #F0CC5E;
|
||||
$gold-100: #FDF5E0;
|
||||
$gold-50: #FEFBF0;
|
||||
|
||||
// ---- Gray (Neutral) ----
|
||||
$gray-900: #111827;
|
||||
$gray-700: #374151;
|
||||
$gray-500: #6B7280;
|
||||
$gray-400: #9CA3AF;
|
||||
$gray-300: #D1D5DB;
|
||||
$gray-200: #E5E7EB;
|
||||
$gray-100: #F3F4F6;
|
||||
$gray-50: #F9FAFB;
|
||||
|
||||
// ---- Semantic ----
|
||||
$red-500: #DC2626;
|
||||
$red-50: #FEF2F2;
|
||||
$green-600: #15803D;
|
||||
$green-50: #F0FDF4;
|
||||
$blue-600: #2563EB;
|
||||
$blue-50: #EFF6FF;
|
||||
$amber-700: #854D0E;
|
||||
|
||||
$white: #fff;
|
||||
|
||||
// ---- Typography ----
|
||||
// Заголовки страниц и секций — Lora (serif). Остальной текст — Inter (sans).
|
||||
$font-serif: 'Lora', Georgia, serif;
|
||||
$font-sans: 'Inter', -apple-system, 'Segoe UI', sans-serif;
|
||||
|
||||
$fs-page-title: 24px; // Lora 600
|
||||
$fs-form-title: 22px; // Lora 600
|
||||
$fs-section-title: 15px; // Lora 600
|
||||
$fs-body: 14px; // Inter 400
|
||||
$fs-sm: 13px;
|
||||
$fs-label: 12px; // Inter 500
|
||||
$fs-caption: 11px;
|
||||
$fs-overline: 10.5px; // uppercase, letter-spacing 1.4-1.8px
|
||||
|
||||
// ---- Radius ----
|
||||
$r-sm: 6px;
|
||||
$r-md: 10px;
|
||||
$r-lg: 14px;
|
||||
$r-full: 100px;
|
||||
|
||||
// ---- Shadows ----
|
||||
$sh-1: 0 1px 2px rgba(15,36,64,.04), 0 1px 3px rgba(15,36,64,.06);
|
||||
$sh-2: 0 4px 16px rgba(15,36,64,.07);
|
||||
$sh-3: 0 12px 32px rgba(15,36,64,.10);
|
||||
$sh-gold: 0 4px 20px rgba(212,160,30,.18);
|
||||
$sh-modal: 0 24px 70px rgba(10,27,48,.25);
|
||||
|
||||
// ---- Gradients (фирменные) ----
|
||||
$grad-header: linear-gradient(135deg, $navy-950 0%, $navy-800 85%);
|
||||
$grad-gold: linear-gradient(135deg, $gold-400, $gold-500);
|
||||
$grad-gold-v: linear-gradient(180deg, $gold-400, $gold-600);
|
||||
$grad-hairline: linear-gradient(90deg, $gold-400, transparent 70%);
|
||||
|
||||
// ---- Spacing ----
|
||||
$sp-1: 4px; $sp-2: 8px; $sp-3: 12px;
|
||||
$sp-4: 16px; $sp-5: 20px; $sp-6: 24px;
|
||||
$sp-7: 28px; $sp-8: 32px;
|
||||
|
||||
// ---- Готовые миксины ----
|
||||
@mixin page-header {
|
||||
background: $grad-header;
|
||||
border-radius: $r-lg;
|
||||
padding: 26px 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, $gold-500, transparent 60%);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin card-hover-gold {
|
||||
transition: .22s;
|
||||
&:hover {
|
||||
box-shadow: $sh-3;
|
||||
transform: translateY(-2px);
|
||||
border-color: $gold-300;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin btn-accent {
|
||||
background: $grad-gold;
|
||||
color: $navy-950;
|
||||
font-weight: 600;
|
||||
border: 1px solid $gold-500;
|
||||
&:hover { box-shadow: $sh-gold; transform: translateY(-1px); }
|
||||
}
|
||||
|
||||
@mixin input-focus-gold {
|
||||
&:focus {
|
||||
outline: 0;
|
||||
border-color: $gold-500;
|
||||
box-shadow: 0 0 0 3px rgba(212,160,30,.12);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin hairline {
|
||||
height: 1px;
|
||||
background: $grad-hairline;
|
||||
border: 0;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
|
||||
"id": "caca4ff9-050c-4643-936c-d0f29a0f9532",
|
||||
"alias": "RequestPortalWebPart",
|
||||
"componentType": "WebPart",
|
||||
|
||||
// The "*" signifies that the version should be taken from the package.json
|
||||
"version": "*",
|
||||
"manifestVersion": 2,
|
||||
|
||||
// If true, the component can only be installed on sites where Custom Script is allowed.
|
||||
// Components that allow authors to embed arbitrary script code should set this to true.
|
||||
// https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
|
||||
"requiresCustomScript": false,
|
||||
"supportedHosts": ["SharePointWebPart", "TeamsPersonalApp", "TeamsTab", "SharePointFullPage"],
|
||||
"supportsThemeVariants": true,
|
||||
"supportsFullBleed": true,
|
||||
|
||||
"preconfiguredEntries": [{
|
||||
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Advanced
|
||||
"group": { "default": "Advanced" },
|
||||
"title": { "default": "request-portal" },
|
||||
"description": { "default": "request-portal description" },
|
||||
"officeFabricIconFontName": "Page",
|
||||
"properties": {
|
||||
"description": "request-portal"
|
||||
}
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDom from 'react-dom';
|
||||
import { Version } from '@microsoft/sp-core-library';
|
||||
import {
|
||||
type IPropertyPaneConfiguration,
|
||||
PropertyPaneTextField
|
||||
} from '@microsoft/sp-property-pane';
|
||||
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
||||
import { IReadonlyTheme } from '@microsoft/sp-component-base';
|
||||
|
||||
import * as strings from 'RequestPortalWebPartStrings';
|
||||
import RequestPortal from './components/RequestPortal';
|
||||
import { IRequestPortalProps } from './components/IRequestPortalProps';
|
||||
import { initSP } from './shared/pnp/pnpjs';
|
||||
|
||||
export interface IRequestPortalWebPartProps {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> {
|
||||
|
||||
private _isDarkTheme: boolean = false;
|
||||
private _environmentMessage: string = '';
|
||||
|
||||
public render(): void {
|
||||
const element: React.ReactElement<IRequestPortalProps> = React.createElement(
|
||||
RequestPortal,
|
||||
{
|
||||
description: this.properties.description,
|
||||
isDarkTheme: this._isDarkTheme,
|
||||
environmentMessage: this._environmentMessage,
|
||||
hasTeamsContext: !!this.context.sdks.microsoftTeams,
|
||||
userDisplayName: this.context.pageContext.user.displayName,
|
||||
context: this.context
|
||||
}
|
||||
);
|
||||
|
||||
ReactDom.render(element, this.domElement);
|
||||
}
|
||||
|
||||
protected onInit(): Promise<void> {
|
||||
initSP(this.context); // единый клиент PnPjs для записи в списки
|
||||
return this._getEnvironmentMessage().then(message => {
|
||||
this._environmentMessage = message;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
private _getEnvironmentMessage(): Promise<string> {
|
||||
if (!!this.context.sdks.microsoftTeams) { // running in Teams, office.com or Outlook
|
||||
return this.context.sdks.microsoftTeams.teamsJs.app.getContext()
|
||||
.then(context => {
|
||||
let environmentMessage: string = '';
|
||||
switch (context.app.host.name) {
|
||||
case 'Office': // running in Office
|
||||
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOffice : strings.AppOfficeEnvironment;
|
||||
break;
|
||||
case 'Outlook': // running in Outlook
|
||||
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOutlook : strings.AppOutlookEnvironment;
|
||||
break;
|
||||
case 'Teams': // running in Teams
|
||||
case 'TeamsModern':
|
||||
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentTeams : strings.AppTeamsTabEnvironment;
|
||||
break;
|
||||
default:
|
||||
environmentMessage = strings.UnknownEnvironment;
|
||||
}
|
||||
|
||||
return environmentMessage;
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve(this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentSharePoint : strings.AppSharePointEnvironment);
|
||||
}
|
||||
|
||||
protected onThemeChanged(currentTheme: IReadonlyTheme | undefined): void {
|
||||
if (!currentTheme) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._isDarkTheme = !!currentTheme.isInverted;
|
||||
const {
|
||||
semanticColors
|
||||
} = currentTheme;
|
||||
|
||||
if (semanticColors) {
|
||||
this.domElement.style.setProperty('--bodyText', semanticColors.bodyText || null);
|
||||
this.domElement.style.setProperty('--link', semanticColors.link || null);
|
||||
this.domElement.style.setProperty('--linkHovered', semanticColors.linkHovered || null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected onDispose(): void {
|
||||
ReactDom.unmountComponentAtNode(this.domElement);
|
||||
}
|
||||
|
||||
protected get dataVersion(): Version {
|
||||
return Version.parse('1.0');
|
||||
}
|
||||
|
||||
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
|
||||
return {
|
||||
pages: [
|
||||
{
|
||||
header: {
|
||||
description: strings.PropertyPaneDescription
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
groupName: strings.BasicGroupName,
|
||||
groupFields: [
|
||||
PropertyPaneTextField('description', {
|
||||
label: strings.DescriptionFieldLabel
|
||||
})
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,39 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
gap: $sp-1;
|
||||
padding: 0 $sp-5;
|
||||
background: $grad-header;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: 0 1px 0 rgba(212, 160, 30, .35), $sh-2;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
padding: 14px 18px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, .5);
|
||||
font-family: $font-sans;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: .2px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color .18s, border-color .18s;
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, .85);
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
color: $gold-300;
|
||||
border-bottom-color: $gold-400;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react';
|
||||
import styles from './CategoryTabs.module.scss';
|
||||
import { Icon } from '../../shared/ui/Icon/Icon';
|
||||
import { ICategory } from '../../models/types';
|
||||
|
||||
interface ICategoryTabsProps {
|
||||
categories: ICategory[];
|
||||
activeId: string;
|
||||
onChange: (categoryId: string) => void;
|
||||
}
|
||||
|
||||
export const CategoryTabs: React.FC<ICategoryTabsProps> = ({ categories, activeId, onChange }) => {
|
||||
return (
|
||||
<nav className={styles.tabs} aria-label="Категории заявок">
|
||||
{categories.map((category) => {
|
||||
const isActive = category.id === activeId;
|
||||
const tabClasses = [styles.tab, isActive ? styles.active : ''].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
className={tabClasses}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => onChange(category.id)}
|
||||
>
|
||||
<Icon name={category.icon} size="sm" />
|
||||
<span>{category.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
|
||||
export interface IRequestPortalProps {
|
||||
description: string;
|
||||
isDarkTheme: boolean;
|
||||
environmentMessage: string;
|
||||
hasTeamsContext: boolean;
|
||||
userDisplayName: string;
|
||||
context: WebPartContext;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: $sp-6;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
padding: 22px 24px;
|
||||
text-align: left;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
box-shadow: $sh-1;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
font-family: $font-sans;
|
||||
transition: box-shadow .22s, transform .22s, border-color .22s;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: $grad-gold-v;
|
||||
opacity: 0;
|
||||
transition: opacity .22s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: $sh-3;
|
||||
transform: translateY(-2px);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tileIcon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: $r-md;
|
||||
background: $navy-50;
|
||||
border: 1px solid $navy-100;
|
||||
color: $navy-700;
|
||||
transition: background .22s, color .22s, border-color .22s;
|
||||
|
||||
.tile:hover & {
|
||||
background: $gold-50;
|
||||
border-color: $gold-300;
|
||||
color: $gold-600;
|
||||
}
|
||||
}
|
||||
|
||||
.tileBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tileTitle {
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .1px;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.tileDesc {
|
||||
font-size: 12px;
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
.tileArrow {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
color: $gray-300;
|
||||
transition: color .22s, transform .22s;
|
||||
|
||||
.tile:hover & {
|
||||
color: $gold-500;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin-top: $sp-6;
|
||||
color: $gray-500;
|
||||
font-size: $fs-sm;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RequestGrid.module.scss';
|
||||
import { Icon } from '../../shared/ui/Icon/Icon';
|
||||
import { IRequestType } from '../../models/types';
|
||||
|
||||
interface IRequestGridProps {
|
||||
requests: IRequestType[];
|
||||
onSelect: (request: IRequestType) => void;
|
||||
}
|
||||
|
||||
export const RequestGrid: React.FC<IRequestGridProps> = ({ requests, onSelect }) => {
|
||||
if (requests.length === 0) {
|
||||
return <p className={styles.empty}>В этой категории пока нет заявок.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.grid}>
|
||||
{requests.map((request) => (
|
||||
<button
|
||||
key={request.id}
|
||||
type="button"
|
||||
className={styles.tile}
|
||||
onClick={() => onSelect(request)}
|
||||
>
|
||||
<span className={styles.tileIcon}>
|
||||
<Icon name={request.icon} />
|
||||
</span>
|
||||
<span className={styles.tileBody}>
|
||||
<span className={styles.tileTitle}>{request.title}</span>
|
||||
{request.description && <span className={styles.tileDesc}>{request.description}</span>}
|
||||
</span>
|
||||
<span className={styles.tileArrow}>
|
||||
<Icon name="chevron-right" size="sm" />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
@import '../../../styles/design-tokens';
|
||||
|
||||
// Full-width: снимаем max-width канвы SharePoint — и в workbench (dev), и на живой
|
||||
// странице. !important оправдан: перебиваем встроенные стили канвы, иначе их не снять.
|
||||
// Если после обновления платформы ширина вернётся — проверить актуальные селекторы в DevTools.
|
||||
:global {
|
||||
#workbenchPageContent,
|
||||
.CanvasComponent.LCS .CanvasZone {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.requestPortal {
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-body;
|
||||
line-height: 1.55;
|
||||
color: $gray-900;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
box-sizing: border-box;
|
||||
|
||||
// Полная ширина обеспечивается через "supportsFullBleed": true в манифесте
|
||||
// + размещение web part в полноширинной секции (как в projects-list).
|
||||
// Корень занимает 100% контейнера, без CSS-трюков с 100vw.
|
||||
width: 100%;
|
||||
|
||||
// Только вертикальные отступы; горизонтальные — на внутренних секциях.
|
||||
padding: $sp-4 $sp-5 $sp-8;
|
||||
}
|
||||
|
||||
// Центрированная колонка контента. 1080px — ширина контента из дизайн-системы
|
||||
// (как .sec в референсе): форма и плитки читаются аккуратно, не растягиваясь.
|
||||
.content {
|
||||
width: 100%;
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.teams {
|
||||
// Задел под специфику Teams-контекста; пока совпадает с базовым.
|
||||
font-family: $font-sans;
|
||||
}
|
||||
|
||||
// ── Заглушка формы (до появления реальных форм) ──
|
||||
.formPlaceholder {
|
||||
margin-top: $sp-6;
|
||||
padding: $sp-8;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: $sh-1;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
margin-bottom: $sp-4;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $navy-700;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
cursor: pointer;
|
||||
transition: color .18s;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholderText {
|
||||
font-family: $font-serif;
|
||||
font-size: 18px;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.loading {
|
||||
margin-top: $sp-6;
|
||||
padding: $sp-8;
|
||||
color: $gray-500;
|
||||
font-size: $fs-sm;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import styles from './RequestPortal.module.scss';
|
||||
import type { IRequestPortalProps } from './IRequestPortalProps';
|
||||
import { IconSprite } from '../shared/ui/Icon/IconSprite';
|
||||
import { Icon } from '../shared/ui/Icon/Icon';
|
||||
import { CategoryTabs } from './CategoryTabs/CategoryTabs';
|
||||
import { RequestGrid } from './RequestGrid/RequestGrid';
|
||||
import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../data/categories';
|
||||
import { getRequestsByCategory } from '../data/requests';
|
||||
import { getFormComponent } from '../forms/registry';
|
||||
import { IRequestType } from '../models/types';
|
||||
|
||||
const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context }) => {
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<string>(DEFAULT_CATEGORY_ID);
|
||||
const [selectedRequest, setSelectedRequest] = useState<IRequestType | null>(null);
|
||||
|
||||
const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const requests = getRequestsByCategory(activeCategoryId);
|
||||
|
||||
const handleCategoryChange = (categoryId: string): void => {
|
||||
setActiveCategoryId(categoryId);
|
||||
setSelectedRequest(null); // при смене категории возвращаемся к списку плиток
|
||||
};
|
||||
|
||||
const handleBack = (): void => setSelectedRequest(null);
|
||||
|
||||
const renderSelected = (request: IRequestType): React.ReactNode => {
|
||||
const FormComponent = getFormComponent(request.id);
|
||||
|
||||
// Форма ещё не реализована — показываем заглушку.
|
||||
if (!FormComponent) {
|
||||
return (
|
||||
<div className={styles.formPlaceholder}>
|
||||
<button type="button" className={styles.backButton} onClick={handleBack}>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
<span>К списку заявок</span>
|
||||
</button>
|
||||
<p className={styles.placeholderText}>Форма «{request.title}» ещё в разработке.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ленивый рендер формы: её код подтягивается только сейчас, при открытии.
|
||||
return (
|
||||
<React.Suspense fallback={<div className={styles.loading}>Загрузка формы…</div>}>
|
||||
<FormComponent requestType={request} context={context} onCancel={handleBack} />
|
||||
</React.Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={rootClasses}>
|
||||
<IconSprite />
|
||||
<div className={styles.content}>
|
||||
<CategoryTabs
|
||||
categories={CATEGORIES}
|
||||
activeId={activeCategoryId}
|
||||
onChange={handleCategoryChange}
|
||||
/>
|
||||
|
||||
{selectedRequest ? (
|
||||
renderSelected(selectedRequest)
|
||||
) : (
|
||||
<RequestGrid requests={requests} onSelect={setSelectedRequest} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestPortal;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ICategory } from '../models/types';
|
||||
|
||||
/**
|
||||
* Категории портала (верхний ряд вкладок).
|
||||
* Пока захардкожены; позже источником станет SharePoint.
|
||||
*/
|
||||
export const CATEGORIES: ICategory[] = [
|
||||
{ id: 'recruiting', title: 'Отдел рекрутинга', icon: 'people' },
|
||||
{ id: 'it', title: 'IT', icon: 'monitor' },
|
||||
{ id: 'accounting', title: 'Бухгалтерия', icon: 'calculator' },
|
||||
{ id: 'hr', title: 'Отдел кадров', icon: 'clipboard' }
|
||||
];
|
||||
|
||||
/** Категория, активная при открытии портала. */
|
||||
export const DEFAULT_CATEGORY_ID: string = CATEGORIES[0].id;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { IRequestType } from '../models/types';
|
||||
|
||||
/**
|
||||
* Типы заявок (плитки) по категориям. Первая партия.
|
||||
* Пока захардкожены; сами формы появятся позже (см. forms/registry).
|
||||
* id — латиницей, т.к. позже станет ключом в реестре форм.
|
||||
*/
|
||||
export const REQUEST_TYPES: IRequestType[] = [
|
||||
// Отдел рекрутинга
|
||||
{ id: 'recruiting-selection', categoryId: 'recruiting', title: 'Заявка на подбор', icon: 'search' },
|
||||
{ id: 'recruiting-hire', categoryId: 'recruiting', title: 'Заявка на прием', icon: 'people' },
|
||||
|
||||
// IT
|
||||
{ id: 'it-workplace-setup', categoryId: 'it', title: 'Заявка на организацию рабочего места', icon: 'monitor' },
|
||||
|
||||
// Бухгалтерия
|
||||
{ id: 'accounting-advance', categoryId: 'accounting', title: 'Заявка на аванс', icon: 'calculator' },
|
||||
|
||||
// Отдел кадров
|
||||
{ id: 'hr-labor-leave', categoryId: 'hr', title: 'Заявка на трудовой отпуск', icon: 'calendar' },
|
||||
{ id: 'hr-day-off', categoryId: 'hr', title: 'на отгул', icon: 'calendar' },
|
||||
{ id: 'hr-dismissal', categoryId: 'hr', title: 'на увольнение', icon: 'document' },
|
||||
{ id: 'hr-clearance-sheet', categoryId: 'hr', title: 'обходной лист', icon: 'clipboard' },
|
||||
{ id: 'hr-bs', categoryId: 'hr', title: 'БС', icon: 'document' },
|
||||
{ id: 'hr-business-trip', categoryId: 'hr', title: 'на командировку', icon: 'send' }
|
||||
];
|
||||
|
||||
/** Заявки, относящиеся к указанной категории. */
|
||||
export function getRequestsByCategory(categoryId: string): IRequestType[] {
|
||||
return REQUEST_TYPES.filter((request) => request.categoryId === categoryId);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
margin-bottom: $sp-4;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $navy-700;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
cursor: pointer;
|
||||
transition: color .18s;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
}
|
||||
}
|
||||
|
||||
.statusRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: $sp-3;
|
||||
}
|
||||
|
||||
.statusPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-full;
|
||||
background: $navy-50;
|
||||
border: 1px solid $navy-100;
|
||||
color: $navy-800;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-caption;
|
||||
font-weight: 600;
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import styles from './RecruitingHireForm.module.scss';
|
||||
import { Button, Alert, FormCard, FormActions, Icon } from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
REQUEST_STATUS,
|
||||
IApproverToWrite
|
||||
} from '../../shared/approval';
|
||||
import { IRequestFormProps } from '../../models/types';
|
||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { saveHireRequest, hireApprovalService, HIRE_REQUEST_TYPE_ID } from './submit';
|
||||
import { ApplicationSection } from './sections/ApplicationSection';
|
||||
import { CandidateSection } from './sections/CandidateSection';
|
||||
import { PpeSection } from './sections/PpeSection';
|
||||
import { ConditionsSection } from './sections/ConditionsSection';
|
||||
import { ResponsibleSection } from './sections/ResponsibleSection';
|
||||
|
||||
type ActionStatus = 'idle' | 'saving' | 'success' | 'error';
|
||||
type ActionKind = 'draft' | 'submit';
|
||||
|
||||
const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved }) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [actionStatus, setActionStatus] = useState<ActionStatus>('idle');
|
||||
const [actionKind, setActionKind] = useState<ActionKind | undefined>(undefined);
|
||||
const [status, setStatus] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const set: SetField = (key, value) => {
|
||||
setData((prev) => ({ ...prev, [key]: value }));
|
||||
setActionStatus('idle');
|
||||
};
|
||||
|
||||
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего
|
||||
// пользователя (кандидата ещё нет в системе).
|
||||
const approval = useMemo(() => hireApprovalService(), []);
|
||||
const userEmail = context.pageContext.user.email;
|
||||
const loadMatrix = useCallback(
|
||||
() => getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval),
|
||||
[context, userEmail, approval]
|
||||
);
|
||||
const matrix = useMatrixEditor(loadMatrix, userEmail, true, true, (login) =>
|
||||
approval.getJobTitle(login)
|
||||
);
|
||||
|
||||
const saving = actionStatus === 'saving';
|
||||
const isValid = data.CandidateName.trim().length > 0 && data.Position.trim().length > 0;
|
||||
|
||||
const matrixApprovers = (): IApproverToWrite[] =>
|
||||
matrix.rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
|
||||
|
||||
async function run(kind: ActionKind, requestStatus: string): Promise<void> {
|
||||
if (!isValid) {
|
||||
setError('Заполните обязательные поля: Ф.И.О. кандидата, Должность.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setActionKind(kind);
|
||||
setActionStatus('saving');
|
||||
try {
|
||||
await saveHireRequest(data, matrixApprovers(), requestStatus);
|
||||
setStatus(requestStatus);
|
||||
setActionStatus('success');
|
||||
if (onSaved) {
|
||||
onSaved();
|
||||
}
|
||||
} catch (e) {
|
||||
setActionStatus('error');
|
||||
setError(`Ошибка сохранения: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveDraft = (): Promise<void> => run('draft', REQUEST_STATUS.draft);
|
||||
const handleSubmit = (): Promise<void> => run('submit', REQUEST_STATUS.onApproval);
|
||||
|
||||
const sectionProps = { data, set, context };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" className={styles.back} onClick={onCancel}>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
<span>К списку заявок</span>
|
||||
</button>
|
||||
|
||||
<FormCard title={requestType.title} subtitle="Категория: Отдел рекрутинга">
|
||||
{status && (
|
||||
<div className={styles.statusRow}>
|
||||
<span className={styles.statusPill}>{status}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionStatus === 'success' && (
|
||||
<Alert variant="ok">
|
||||
{actionKind === 'draft'
|
||||
? 'Черновик сохранён.'
|
||||
: 'Заявка отправлена на согласование.'}
|
||||
</Alert>
|
||||
)}
|
||||
{error && <Alert variant="err">{error}</Alert>}
|
||||
|
||||
<ApplicationSection {...sectionProps} />
|
||||
<CandidateSection {...sectionProps} />
|
||||
<PpeSection {...sectionProps} />
|
||||
<ConditionsSection {...sectionProps} />
|
||||
<ResponsibleSection {...sectionProps} />
|
||||
|
||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
<FormActions>
|
||||
<Button variant="secondary" onClick={onCancel} disabled={saving}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleSaveDraft} disabled={!isValid || saving}>
|
||||
<Icon name="document" size="sm" />
|
||||
Сохранить черновик
|
||||
</Button>
|
||||
<Button variant="accent" onClick={handleSubmit} disabled={!isValid || saving}>
|
||||
<Icon name="send" size="sm" />
|
||||
{saving && actionKind === 'submit' ? 'Отправка…' : 'Отправить на согласование'}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</FormCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecruitingHireForm;
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"dataListTitle": "Согласование — Прием на работу",
|
||||
"columns": [
|
||||
{ "internalName": "Approver", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" },
|
||||
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||
{ "internalName": "Order", "title": "Порядок", "type": "Number" },
|
||||
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { ISelectOption } from '../../shared/ui';
|
||||
|
||||
/**
|
||||
* Данные формы «Заявка на прием». Ключи = internalName колонок SP-списка
|
||||
* «Прием на работу — данные», чтобы при записи маппинг был 1:1.
|
||||
*/
|
||||
export interface IFormData {
|
||||
// Заявка (шапка)
|
||||
Phone: string;
|
||||
HireType: string;
|
||||
ReplacedEmployee: string;
|
||||
// Кандидат
|
||||
CandidateName: string;
|
||||
HireDate: string;
|
||||
Position: string;
|
||||
WorkKind: string;
|
||||
SiteName: string;
|
||||
SiteLocation: string;
|
||||
// СИЗ
|
||||
PpeHeight: string;
|
||||
PpeClothingSize: string;
|
||||
PpeShoeSize: string;
|
||||
// Условия оплаты и оформления
|
||||
SalaryProbation: string;
|
||||
SalaryAfterProbation: string;
|
||||
WorkSchedule: string;
|
||||
EmploymentForm: string;
|
||||
// Ответственные лица
|
||||
SiteManager: string;
|
||||
SecuritySpecialist: string;
|
||||
}
|
||||
|
||||
/** Обновление одного поля формы с сохранением типа значения. */
|
||||
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
||||
|
||||
/** Пропсы каждой секции формы: срез данных + колбэк обновления + контекст (для people picker). */
|
||||
export interface ISectionProps {
|
||||
data: IFormData;
|
||||
set: SetField;
|
||||
context: WebPartContext;
|
||||
}
|
||||
|
||||
export const EMPTY_DATA: IFormData = {
|
||||
Phone: '',
|
||||
HireType: '',
|
||||
ReplacedEmployee: '',
|
||||
CandidateName: '',
|
||||
HireDate: '',
|
||||
Position: '',
|
||||
WorkKind: '',
|
||||
SiteName: '',
|
||||
SiteLocation: '',
|
||||
PpeHeight: '',
|
||||
PpeClothingSize: '',
|
||||
PpeShoeSize: '',
|
||||
SalaryProbation: '',
|
||||
SalaryAfterProbation: '',
|
||||
WorkSchedule: '',
|
||||
EmploymentForm: '',
|
||||
SiteManager: '',
|
||||
SecuritySpecialist: ''
|
||||
};
|
||||
|
||||
/** Варианты «Тип приёма» — совпадают с choices колонки HireType. */
|
||||
export const HIRE_TYPE_OPTIONS: ISelectOption[] = [
|
||||
{ value: 'Новый сотрудник', label: 'Новый сотрудник' },
|
||||
{ value: 'Вместо уволенного', label: 'Вместо уволенного' }
|
||||
];
|
||||
|
||||
/** Варианты «Вид оформления» — совпадают с choices колонки EmploymentForm. */
|
||||
export const EMPLOYMENT_FORM_OPTIONS: ISelectOption[] = [
|
||||
{ value: 'ГПХ', label: 'ГПХ' },
|
||||
{ value: 'Штат', label: 'Штат' }
|
||||
];
|
||||
|
||||
/** Плейсхолдер для полей-людей. */
|
||||
export const PERSON_PLACEHOLDER = 'Начните вводить имя сотрудника…';
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"dataListTitle": "Прием на работу — данные",
|
||||
"columns": [
|
||||
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||
|
||||
{ "internalName": "Phone", "title": "Телефон", "type": "Text" },
|
||||
{ "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного"] },
|
||||
{ "internalName": "ReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" },
|
||||
|
||||
{ "internalName": "CandidateName", "title": "Ф.И.О. кандидата", "type": "Text", "required": true },
|
||||
{ "internalName": "HireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
|
||||
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
|
||||
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
|
||||
{ "internalName": "SiteName", "title": "Наименование участка", "type": "Text" },
|
||||
{ "internalName": "SiteLocation", "title": "Местоположение участка", "type": "Text" },
|
||||
|
||||
{ "internalName": "PpeHeight", "title": "СИЗ: рост", "type": "Number" },
|
||||
{ "internalName": "PpeClothingSize", "title": "СИЗ: размер одежды", "type": "Text" },
|
||||
{ "internalName": "PpeShoeSize", "title": "СИЗ: размер обуви", "type": "Number" },
|
||||
|
||||
{ "internalName": "SalaryProbation", "title": "Оклад при испытательном сроке", "type": "Number", "decimals": 2 },
|
||||
{ "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 },
|
||||
{ "internalName": "WorkSchedule", "title": "График работы", "type": "Text" },
|
||||
{ "internalName": "EmploymentForm", "title": "Вид оформления", "type": "Choice", "choices": ["ГПХ", "Штат"] },
|
||||
|
||||
{ "internalName": "SiteManager", "title": "Руководитель участка", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||
{ "internalName": "SecuritySpecialist", "title": "Специалист СБ", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, TextInput, Select } from '../../../shared/ui';
|
||||
import { ISectionProps, HIRE_TYPE_OPTIONS } from '../model';
|
||||
|
||||
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||
<CollapseSection title="Заявка" icon="clipboard">
|
||||
<FormField label="Телефон" htmlFor="Phone">
|
||||
<TextInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
|
||||
</FormField>
|
||||
<FormField label="Тип приёма" htmlFor="HireType">
|
||||
<Select
|
||||
id="HireType"
|
||||
value={data.HireType}
|
||||
onChange={(v) => set('HireType', v)}
|
||||
options={HIRE_TYPE_OPTIONS}
|
||||
placeholder="— выберите —"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Взамен кого (Ф.И.О. уволенного)" htmlFor="ReplacedEmployee" full>
|
||||
<TextInput
|
||||
id="ReplacedEmployee"
|
||||
value={data.ReplacedEmployee}
|
||||
onChange={(v) => set('ReplacedEmployee', v)}
|
||||
/>
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
|
||||
import { ISectionProps } from '../model';
|
||||
|
||||
export const CandidateSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||
<CollapseSection title="Кандидат" icon="people">
|
||||
<FormField label="Ф.И.О. кандидата" htmlFor="CandidateName" required full>
|
||||
<TextInput id="CandidateName" value={data.CandidateName} onChange={(v) => set('CandidateName', v)} />
|
||||
</FormField>
|
||||
<FormField label="Дата приёма" htmlFor="HireDate">
|
||||
<TextInput id="HireDate" type="date" value={data.HireDate} onChange={(v) => set('HireDate', v)} />
|
||||
</FormField>
|
||||
<FormField label="Должность" htmlFor="Position" required>
|
||||
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
||||
</FormField>
|
||||
<FormField label="Вид работ" htmlFor="WorkKind">
|
||||
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
|
||||
</FormField>
|
||||
<FormField label="Наименование участка" htmlFor="SiteName">
|
||||
<TextInput id="SiteName" value={data.SiteName} onChange={(v) => set('SiteName', v)} />
|
||||
</FormField>
|
||||
<FormField label="Местоположение участка" htmlFor="SiteLocation">
|
||||
<TextInput id="SiteLocation" value={data.SiteLocation} onChange={(v) => set('SiteLocation', v)} />
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, TextInput, Select } from '../../../shared/ui';
|
||||
import { ISectionProps, EMPLOYMENT_FORM_OPTIONS } from '../model';
|
||||
|
||||
export const ConditionsSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||
<CollapseSection title="Условия оплаты и оформления" icon="calculator">
|
||||
<FormField label="Оклад при испытательном сроке" htmlFor="SalaryProbation">
|
||||
<TextInput
|
||||
id="SalaryProbation"
|
||||
type="number"
|
||||
value={data.SalaryProbation}
|
||||
onChange={(v) => set('SalaryProbation', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Оклад по завершении ИС" htmlFor="SalaryAfterProbation">
|
||||
<TextInput
|
||||
id="SalaryAfterProbation"
|
||||
type="number"
|
||||
value={data.SalaryAfterProbation}
|
||||
onChange={(v) => set('SalaryAfterProbation', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="График работы" htmlFor="WorkSchedule">
|
||||
<TextInput id="WorkSchedule" value={data.WorkSchedule} onChange={(v) => set('WorkSchedule', v)} />
|
||||
</FormField>
|
||||
<FormField label="Вид оформления" htmlFor="EmploymentForm">
|
||||
<Select
|
||||
id="EmploymentForm"
|
||||
value={data.EmploymentForm}
|
||||
onChange={(v) => set('EmploymentForm', v)}
|
||||
options={EMPLOYMENT_FORM_OPTIONS}
|
||||
placeholder="— выберите —"
|
||||
/>
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
|
||||
import { ISectionProps } from '../model';
|
||||
|
||||
export const PpeSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||
<CollapseSection title="СИЗ (спецодежда)" icon="folder">
|
||||
<FormField label="Рост, см" htmlFor="PpeHeight">
|
||||
<TextInput id="PpeHeight" type="number" value={data.PpeHeight} onChange={(v) => set('PpeHeight', v)} />
|
||||
</FormField>
|
||||
<FormField label="Размер одежды" htmlFor="PpeClothingSize">
|
||||
<TextInput id="PpeClothingSize" value={data.PpeClothingSize} onChange={(v) => set('PpeClothingSize', v)} />
|
||||
</FormField>
|
||||
<FormField label="Размер обуви" htmlFor="PpeShoeSize">
|
||||
<TextInput id="PpeShoeSize" type="number" value={data.PpeShoeSize} onChange={(v) => set('PpeShoeSize', v)} />
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, PeopleField } from '../../../shared/ui';
|
||||
import { ISectionProps, PERSON_PLACEHOLDER } from '../model';
|
||||
|
||||
export const ResponsibleSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
||||
<CollapseSection title="Ответственные лица" icon="check">
|
||||
<FormField label="Руководитель участка" full>
|
||||
<PeopleField
|
||||
context={context}
|
||||
value={data.SiteManager}
|
||||
onChange={(v) => set('SiteManager', v)}
|
||||
placeholder={PERSON_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Специалист СБ" full>
|
||||
<PeopleField
|
||||
context={context}
|
||||
value={data.SecuritySpecialist}
|
||||
onChange={(v) => set('SecuritySpecialist', v)}
|
||||
placeholder={PERSON_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
import { getSP } from '../../shared/pnp/pnpjs';
|
||||
import { ApprovalListService, IApproverToWrite, writeApprovers } from '../../shared/approval';
|
||||
import { IFormData } from './model';
|
||||
|
||||
const LIST_TITLE = 'Прием на работу — данные';
|
||||
export const APPROVAL_LIST_TITLE = 'Согласование — Прием на работу';
|
||||
// Тип заявки «Заявка на прием» во внешнем approval-route API
|
||||
// (GET /api/request-types → id 5). Захардкожен, как «отпуск = 1» в pto-tracker.
|
||||
export const HIRE_REQUEST_TYPE_ID = 5;
|
||||
|
||||
// Поля-люди (один человек): логин → ensureUser → Id в колонку <Field>Id.
|
||||
const PERSON_FIELDS: (keyof IFormData)[] = ['SiteManager', 'SecuritySpecialist'];
|
||||
// Числовые поля: пустая строка → null, иначе Number().
|
||||
const NUMBER_FIELDS: (keyof IFormData)[] = ['PpeHeight', 'PpeShoeSize', 'SalaryProbation', 'SalaryAfterProbation'];
|
||||
|
||||
/** Экземпляр сервиса списка согласования этой формы. */
|
||||
export function hireApprovalService(): ApprovalListService {
|
||||
return new ApprovalListService(APPROVAL_LIST_TITLE);
|
||||
}
|
||||
|
||||
/** Логин → числовой Id пользователя на текущем сайте. */
|
||||
async function resolveUserId(login: string): Promise<number> {
|
||||
const ensured = await getSP().web.ensureUser(login); // v4: возвращает ISiteUserInfo с .Id
|
||||
return ensured.Id;
|
||||
}
|
||||
|
||||
/** Осмысленный заголовок элемента списка (системная колонка Title). */
|
||||
function buildTitle(data: IFormData): string {
|
||||
return ['Прием', data.CandidateName, data.Position].filter(Boolean).join(' — ');
|
||||
}
|
||||
|
||||
/** Записать контент заявки со статусом. Возвращает Id созданного элемента. */
|
||||
async function writeContent(data: IFormData, status: string): Promise<number> {
|
||||
const item: Record<string, unknown> = { Title: buildTitle(data), Status: status };
|
||||
|
||||
// Текст/выбор/даты/числа (людей пропускаем — они ниже).
|
||||
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
||||
if (PERSON_FIELDS.indexOf(key) !== -1) {
|
||||
return;
|
||||
}
|
||||
const value = data[key];
|
||||
if (NUMBER_FIELDS.indexOf(key) !== -1) {
|
||||
item[key] = value === '' ? null : Number(value);
|
||||
} else {
|
||||
item[key] = value === '' ? null : value;
|
||||
}
|
||||
});
|
||||
|
||||
// Один человек → <Field>Id.
|
||||
for (let i = 0; i < PERSON_FIELDS.length; i++) {
|
||||
const login = data[PERSON_FIELDS[i]] as string;
|
||||
if (login) {
|
||||
item[`${PERSON_FIELDS[i]}Id`] = await resolveUserId(login);
|
||||
}
|
||||
}
|
||||
|
||||
const added = await getSP().web.lists.getByTitle(LIST_TITLE).items.add(item);
|
||||
return (added as { Id: number }).Id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить заявку «на прием»: контент + маршрут согласующих со статусом.
|
||||
* status = «Черновик» (сохранение) или «На согласовании» (отправка).
|
||||
* Возвращает Id заявки.
|
||||
*/
|
||||
export async function saveHireRequest(
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
status: string
|
||||
): Promise<number> {
|
||||
const id = await writeContent(data, status);
|
||||
await writeApprovers(hireApprovalService(), id, approvers, status);
|
||||
return id;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
margin-bottom: $sp-4;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $navy-700;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
cursor: pointer;
|
||||
transition: color .18s;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import styles from './RecruitingSelectionForm.module.scss';
|
||||
import { Button, Alert, FormCard, FormActions, Icon } from '../../shared/ui';
|
||||
import { IRequestFormProps } from '../../models/types';
|
||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { submitRecruitingSelection } from './submit';
|
||||
import { ApplicationSection } from './sections/ApplicationSection';
|
||||
import { DepartmentSection } from './sections/DepartmentSection';
|
||||
import { VacancySection } from './sections/VacancySection';
|
||||
import { HealthTripsSection } from './sections/HealthTripsSection';
|
||||
import { ConditionsSection } from './sections/ConditionsSection';
|
||||
|
||||
const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved }) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [saving, setSaving] = useState<boolean>(false);
|
||||
const [saved, setSaved] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const set: SetField = (key, value) => {
|
||||
setData((prev) => ({ ...prev, [key]: value }));
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
const missing: string[] = [];
|
||||
if (!data.Responsible) missing.push('Ответственный за подачу заявки');
|
||||
if (!data.DepartmentName) missing.push('Название отдела/департамента');
|
||||
if (!data.Position) missing.push('Должность');
|
||||
if (missing.length > 0) {
|
||||
setError(`Заполните обязательные поля: ${missing.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
await submitRecruitingSelection(data);
|
||||
setSaved(true);
|
||||
if (onSaved) {
|
||||
onSaved();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`Ошибка сохранения: ${(e as Error).message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const sectionProps = { data, set, context };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" className={styles.back} onClick={onCancel}>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
<span>К списку заявок</span>
|
||||
</button>
|
||||
|
||||
<FormCard title={requestType.title} subtitle="Категория: Отдел рекрутинга">
|
||||
{saved && <Alert variant="ok">Заявка сохранена в SharePoint.</Alert>}
|
||||
{error && <Alert variant="err">{error}</Alert>}
|
||||
|
||||
<ApplicationSection {...sectionProps} />
|
||||
<DepartmentSection {...sectionProps} />
|
||||
<VacancySection {...sectionProps} />
|
||||
<HealthTripsSection {...sectionProps} />
|
||||
<ConditionsSection {...sectionProps} />
|
||||
|
||||
<FormActions>
|
||||
<Button variant="secondary" onClick={onCancel} disabled={saving}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="accent" onClick={handleSave} disabled={saving}>
|
||||
<Icon name="check" size="sm" />
|
||||
{saving ? 'Сохранение…' : 'Сохранить заявку'}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</FormCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecruitingSelectionForm;
|
||||
@@ -0,0 +1,120 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { ISelectOption } from '../../shared/ui';
|
||||
|
||||
/**
|
||||
* Данные формы «Заявка на подбор». Ключи = internalName колонок SP-списка
|
||||
* «Заявка на подбор — данные», чтобы при записи маппинг был 1:1.
|
||||
*/
|
||||
export interface IFormData {
|
||||
// Заявка
|
||||
Responsible: string;
|
||||
SubmitDate: string;
|
||||
ClosingDeadline: string;
|
||||
OpenReason: string;
|
||||
IsRegistered: boolean;
|
||||
IsApproved: boolean;
|
||||
CompletionMark: boolean;
|
||||
// Отдел
|
||||
DepartmentName: string;
|
||||
DepartmentHeadcount: string;
|
||||
DepartmentGoals: string;
|
||||
RelatedDepartments: string;
|
||||
// Вакансия
|
||||
Position: string;
|
||||
ClassifierCode: string;
|
||||
EmploymentType: string;
|
||||
MainFunction: string;
|
||||
AdditionalFunction: string;
|
||||
Competencies: string;
|
||||
PersonalTraits: string;
|
||||
Education: string;
|
||||
WorkExperience: string;
|
||||
AdditionalEducation: string;
|
||||
EvaluationCriteria: string;
|
||||
PcSkills: string;
|
||||
ForeignLanguages: string;
|
||||
Residence: string;
|
||||
DrivingLicense: string;
|
||||
// Здоровье / командировки
|
||||
HealthRestrictions: string;
|
||||
BusinessTrips: string;
|
||||
// Условия работы
|
||||
RateProbation: string;
|
||||
RateAfterProbation: string;
|
||||
Awards: string;
|
||||
Bonuses: string;
|
||||
CompensationPackage: string;
|
||||
WorkSchedule: string;
|
||||
Location: string;
|
||||
MaterialResponsibility: boolean;
|
||||
WorkplaceProvision: string;
|
||||
ProposedMentor: string;
|
||||
DirectManager: string;
|
||||
DevelopmentPrograms: string;
|
||||
DesiredTraining: string;
|
||||
HiringDecisionMaker: string[];
|
||||
}
|
||||
|
||||
/** Обновление одного поля формы с сохранением типа значения. */
|
||||
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
||||
|
||||
/** Пропсы каждой секции формы: срез данных + колбэк обновления + контекст (для people picker). */
|
||||
export interface ISectionProps {
|
||||
data: IFormData;
|
||||
set: SetField;
|
||||
context: WebPartContext;
|
||||
}
|
||||
|
||||
export const EMPTY_DATA: IFormData = {
|
||||
Responsible: '',
|
||||
SubmitDate: '',
|
||||
ClosingDeadline: '',
|
||||
OpenReason: '',
|
||||
IsRegistered: false,
|
||||
IsApproved: false,
|
||||
CompletionMark: false,
|
||||
DepartmentName: '',
|
||||
DepartmentHeadcount: '',
|
||||
DepartmentGoals: '',
|
||||
RelatedDepartments: '',
|
||||
Position: '',
|
||||
ClassifierCode: '',
|
||||
EmploymentType: '',
|
||||
MainFunction: '',
|
||||
AdditionalFunction: '',
|
||||
Competencies: '',
|
||||
PersonalTraits: '',
|
||||
Education: '',
|
||||
WorkExperience: '',
|
||||
AdditionalEducation: '',
|
||||
EvaluationCriteria: '',
|
||||
PcSkills: '',
|
||||
ForeignLanguages: '',
|
||||
Residence: '',
|
||||
DrivingLicense: '',
|
||||
HealthRestrictions: '',
|
||||
BusinessTrips: '',
|
||||
RateProbation: '',
|
||||
RateAfterProbation: '',
|
||||
Awards: '',
|
||||
Bonuses: '',
|
||||
CompensationPackage: '',
|
||||
WorkSchedule: '',
|
||||
Location: '',
|
||||
MaterialResponsibility: false,
|
||||
WorkplaceProvision: '',
|
||||
ProposedMentor: '',
|
||||
DirectManager: '',
|
||||
DevelopmentPrograms: '',
|
||||
DesiredTraining: '',
|
||||
HiringDecisionMaker: []
|
||||
};
|
||||
|
||||
/** Варианты «Характер занятости» — совпадают с choices колонки EmploymentType. */
|
||||
export const EMPLOYMENT_TYPE_OPTIONS: ISelectOption[] = [
|
||||
{ value: 'Постоянная', label: 'Постоянная' },
|
||||
{ value: 'Временная', label: 'Временная' }
|
||||
];
|
||||
|
||||
/** Плейсхолдер для полей-людей. */
|
||||
export const PERSON_PLACEHOLDER = 'Начните вводить имя сотрудника…';
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"dataListTitle": "Заявка на подбор — данные",
|
||||
"columns": [
|
||||
{ "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
||||
{ "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true },
|
||||
{ "internalName": "IsRegistered", "title": "Зарегистрирована", "type": "Boolean" },
|
||||
{ "internalName": "IsApproved", "title": "Одобрена", "type": "Boolean" },
|
||||
{ "internalName": "OpenReason", "title": "Причина открытия вакансии", "type": "Note" },
|
||||
{ "internalName": "ClosingDeadline", "title": "Крайний срок закрытия", "type": "DateTime", "dateOnly": true },
|
||||
{ "internalName": "CompletionMark", "title": "Отметка о выполнении", "type": "Boolean" },
|
||||
|
||||
{ "internalName": "DepartmentName", "title": "Название отдела/департамента", "type": "Text", "required": true },
|
||||
{ "internalName": "DepartmentHeadcount", "title": "Количество сотрудников в отделе", "type": "Number" },
|
||||
{ "internalName": "DepartmentGoals", "title": "Цели, стоящие перед отделом", "type": "Note" },
|
||||
{ "internalName": "RelatedDepartments", "title": "Смежные отделы по бизнес-процессам", "type": "Note" },
|
||||
|
||||
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
|
||||
{ "internalName": "ClassifierCode", "title": "Код по классификатору", "type": "Text" },
|
||||
{ "internalName": "EmploymentType", "title": "Характер занятости", "type": "Choice", "choices": ["Постоянная", "Временная"] },
|
||||
{ "internalName": "MainFunction", "title": "Основной функционал", "type": "Note" },
|
||||
{ "internalName": "AdditionalFunction", "title": "Дополнительный функционал", "type": "Note" },
|
||||
{ "internalName": "Competencies", "title": "Компетенции", "type": "Note" },
|
||||
{ "internalName": "PersonalTraits", "title": "Личностные характеристики", "type": "Note" },
|
||||
{ "internalName": "Education", "title": "Образование", "type": "Text" },
|
||||
{ "internalName": "WorkExperience", "title": "Опыт работы", "type": "Note" },
|
||||
{ "internalName": "AdditionalEducation", "title": "Дополнительное образование", "type": "Note" },
|
||||
{ "internalName": "EvaluationCriteria", "title": "Критерии оценки работы", "type": "Note" },
|
||||
{ "internalName": "PcSkills", "title": "Уровень владения ПК и программами", "type": "Note" },
|
||||
{ "internalName": "ForeignLanguages", "title": "Иностранные языки (уровень владения)", "type": "Note" },
|
||||
{ "internalName": "Residence", "title": "Проживание", "type": "Text" },
|
||||
{ "internalName": "DrivingLicense", "title": "Водительские права, стаж вождения", "type": "Text" },
|
||||
|
||||
{ "internalName": "HealthRestrictions", "title": "Приемлемые противопоказания (здоровье)", "type": "Note" },
|
||||
{ "internalName": "BusinessTrips", "title": "Командировки (есть/нет и продолжительность)", "type": "Note" },
|
||||
|
||||
{ "internalName": "RateProbation", "title": "Ставка на ИС", "type": "Number", "decimals": 2 },
|
||||
{ "internalName": "RateAfterProbation", "title": "Ставка после ИС", "type": "Number", "decimals": 2 },
|
||||
{ "internalName": "Awards", "title": "Премии", "type": "Note" },
|
||||
{ "internalName": "Bonuses", "title": "Бонусы", "type": "Note" },
|
||||
{ "internalName": "CompensationPackage", "title": "Компенсационный пакет", "type": "Note" },
|
||||
{ "internalName": "WorkSchedule", "title": "Режим работы", "type": "Text" },
|
||||
{ "internalName": "Location", "title": "Локация", "type": "Text" },
|
||||
{ "internalName": "MaterialResponsibility", "title": "Материальная ответственность", "type": "Boolean" },
|
||||
{ "internalName": "WorkplaceProvision", "title": "Обеспечение рабочего места", "type": "Note" },
|
||||
{ "internalName": "ProposedMentor", "title": "Предполагаемый наставник", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||
{ "internalName": "DirectManager", "title": "Непосредственный руководитель", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||
{ "internalName": "DevelopmentPrograms", "title": "Программы развития и обучения, периодичность", "type": "Note" },
|
||||
{ "internalName": "DesiredTraining", "title": "Желаемое доп. обучение при выходе на работу", "type": "Note" },
|
||||
{ "internalName": "HiringDecisionMaker", "title": "Кто принимает решение о приеме (собеседования)", "type": "User", "userSelectionMode": "PeopleOnly", "allowMultiple": true }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, TextInput, TextArea, Toggle, PeopleField } from '../../../shared/ui';
|
||||
import { ISectionProps, PERSON_PLACEHOLDER } from '../model';
|
||||
|
||||
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
||||
<CollapseSection title="Заявка" icon="clipboard">
|
||||
<FormField label="Ответственный за подачу заявки" required full>
|
||||
<PeopleField
|
||||
context={context}
|
||||
value={data.Responsible}
|
||||
onChange={(v) => set('Responsible', v)}
|
||||
placeholder={PERSON_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Дата подачи заявки" htmlFor="SubmitDate">
|
||||
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} />
|
||||
</FormField>
|
||||
<FormField label="Крайний срок закрытия" htmlFor="ClosingDeadline">
|
||||
<TextInput
|
||||
id="ClosingDeadline"
|
||||
type="date"
|
||||
value={data.ClosingDeadline}
|
||||
onChange={(v) => set('ClosingDeadline', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Причина открытия вакансии" htmlFor="OpenReason" full>
|
||||
<TextArea id="OpenReason" value={data.OpenReason} onChange={(v) => set('OpenReason', v)} placeholder="..." />
|
||||
</FormField>
|
||||
<FormField label="Зарегистрирована">
|
||||
<Toggle checked={data.IsRegistered} onChange={(v) => set('IsRegistered', v)} onLabel="Да" />
|
||||
</FormField>
|
||||
<FormField label="Одобрена">
|
||||
<Toggle checked={data.IsApproved} onChange={(v) => set('IsApproved', v)} onLabel="Да" />
|
||||
</FormField>
|
||||
<FormField label="Отметка о выполнении">
|
||||
<Toggle checked={data.CompletionMark} onChange={(v) => set('CompletionMark', v)} onLabel="Да" />
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, TextInput, TextArea, Toggle, PeopleField, PeopleFieldMulti } from '../../../shared/ui';
|
||||
import { ISectionProps, PERSON_PLACEHOLDER } from '../model';
|
||||
|
||||
export const ConditionsSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
||||
<CollapseSection title="Условия работы" icon="chart">
|
||||
<FormField label="Ставка на ИС" htmlFor="RateProbation">
|
||||
<TextInput id="RateProbation" type="number" value={data.RateProbation} onChange={(v) => set('RateProbation', v)} />
|
||||
</FormField>
|
||||
<FormField label="Ставка после ИС" htmlFor="RateAfterProbation">
|
||||
<TextInput
|
||||
id="RateAfterProbation"
|
||||
type="number"
|
||||
value={data.RateAfterProbation}
|
||||
onChange={(v) => set('RateAfterProbation', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Премии" htmlFor="Awards" full>
|
||||
<TextArea id="Awards" value={data.Awards} onChange={(v) => set('Awards', v)} />
|
||||
</FormField>
|
||||
<FormField label="Бонусы" htmlFor="Bonuses" full>
|
||||
<TextArea id="Bonuses" value={data.Bonuses} onChange={(v) => set('Bonuses', v)} />
|
||||
</FormField>
|
||||
<FormField label="Компенсационный пакет" htmlFor="CompensationPackage" full>
|
||||
<TextArea id="CompensationPackage" value={data.CompensationPackage} onChange={(v) => set('CompensationPackage', v)} />
|
||||
</FormField>
|
||||
<FormField label="Режим работы" htmlFor="WorkSchedule">
|
||||
<TextInput id="WorkSchedule" value={data.WorkSchedule} onChange={(v) => set('WorkSchedule', v)} />
|
||||
</FormField>
|
||||
<FormField label="Локация" htmlFor="Location">
|
||||
<TextInput id="Location" value={data.Location} onChange={(v) => set('Location', v)} />
|
||||
</FormField>
|
||||
<FormField label="Материальная ответственность">
|
||||
<Toggle
|
||||
checked={data.MaterialResponsibility}
|
||||
onChange={(v) => set('MaterialResponsibility', v)}
|
||||
onLabel="Да"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Обеспечение рабочего места" htmlFor="WorkplaceProvision" full>
|
||||
<TextArea id="WorkplaceProvision" value={data.WorkplaceProvision} onChange={(v) => set('WorkplaceProvision', v)} />
|
||||
</FormField>
|
||||
<FormField label="Предполагаемый наставник">
|
||||
<PeopleField
|
||||
context={context}
|
||||
value={data.ProposedMentor}
|
||||
onChange={(v) => set('ProposedMentor', v)}
|
||||
placeholder={PERSON_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Непосредственный руководитель">
|
||||
<PeopleField
|
||||
context={context}
|
||||
value={data.DirectManager}
|
||||
onChange={(v) => set('DirectManager', v)}
|
||||
placeholder={PERSON_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Программы развития и обучения, периодичность" htmlFor="DevelopmentPrograms" full>
|
||||
<TextArea id="DevelopmentPrograms" value={data.DevelopmentPrograms} onChange={(v) => set('DevelopmentPrograms', v)} />
|
||||
</FormField>
|
||||
<FormField label="Желаемое доп. обучение при выходе на работу" htmlFor="DesiredTraining" full>
|
||||
<TextArea id="DesiredTraining" value={data.DesiredTraining} onChange={(v) => set('DesiredTraining', v)} />
|
||||
</FormField>
|
||||
<FormField label="Кто принимает решение о приеме (собеседования)" full>
|
||||
<PeopleFieldMulti
|
||||
context={context}
|
||||
value={data.HiringDecisionMaker}
|
||||
onChange={(v) => set('HiringDecisionMaker', v)}
|
||||
placeholder={PERSON_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, TextInput, TextArea } from '../../../shared/ui';
|
||||
import { ISectionProps } from '../model';
|
||||
|
||||
export const DepartmentSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||
<CollapseSection title="Отдел / Подразделение" icon="building">
|
||||
<FormField label="Название отдела/департамента" htmlFor="DepartmentName" required full>
|
||||
<TextInput id="DepartmentName" value={data.DepartmentName} onChange={(v) => set('DepartmentName', v)} />
|
||||
</FormField>
|
||||
<FormField label="Количество сотрудников в отделе" htmlFor="DepartmentHeadcount">
|
||||
<TextInput
|
||||
id="DepartmentHeadcount"
|
||||
type="number"
|
||||
value={data.DepartmentHeadcount}
|
||||
onChange={(v) => set('DepartmentHeadcount', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Цели, стоящие перед отделом" htmlFor="DepartmentGoals" full>
|
||||
<TextArea id="DepartmentGoals" value={data.DepartmentGoals} onChange={(v) => set('DepartmentGoals', v)} />
|
||||
</FormField>
|
||||
<FormField label="Смежные отделы по бизнес-процессам" htmlFor="RelatedDepartments" full>
|
||||
<TextArea
|
||||
id="RelatedDepartments"
|
||||
value={data.RelatedDepartments}
|
||||
onChange={(v) => set('RelatedDepartments', v)}
|
||||
/>
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, TextArea } from '../../../shared/ui';
|
||||
import { ISectionProps } from '../model';
|
||||
|
||||
export const HealthTripsSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||
<CollapseSection title="Здоровье и командировки" icon="send">
|
||||
<FormField label="Приемлемые противопоказания (здоровье)" htmlFor="HealthRestrictions" full>
|
||||
<TextArea
|
||||
id="HealthRestrictions"
|
||||
value={data.HealthRestrictions}
|
||||
onChange={(v) => set('HealthRestrictions', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Командировки (есть/нет и продолжительность)" htmlFor="BusinessTrips" full>
|
||||
<TextArea id="BusinessTrips" value={data.BusinessTrips} onChange={(v) => set('BusinessTrips', v)} />
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../shared/ui';
|
||||
import { ISectionProps, EMPLOYMENT_TYPE_OPTIONS } from '../model';
|
||||
|
||||
export const VacancySection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||
<CollapseSection title="Вакансия" icon="document">
|
||||
<FormField label="Должность" htmlFor="Position" required full>
|
||||
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
||||
</FormField>
|
||||
<FormField label="Код по классификатору" htmlFor="ClassifierCode">
|
||||
<TextInput id="ClassifierCode" value={data.ClassifierCode} onChange={(v) => set('ClassifierCode', v)} />
|
||||
</FormField>
|
||||
<FormField label="Характер занятости" htmlFor="EmploymentType">
|
||||
<Select
|
||||
id="EmploymentType"
|
||||
value={data.EmploymentType}
|
||||
onChange={(v) => set('EmploymentType', v)}
|
||||
options={EMPLOYMENT_TYPE_OPTIONS}
|
||||
placeholder="— выберите —"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Основной функционал" htmlFor="MainFunction" full>
|
||||
<TextArea id="MainFunction" value={data.MainFunction} onChange={(v) => set('MainFunction', v)} />
|
||||
</FormField>
|
||||
<FormField label="Дополнительный функционал" htmlFor="AdditionalFunction" full>
|
||||
<TextArea id="AdditionalFunction" value={data.AdditionalFunction} onChange={(v) => set('AdditionalFunction', v)} />
|
||||
</FormField>
|
||||
<FormField label="Компетенции" htmlFor="Competencies" full>
|
||||
<TextArea id="Competencies" value={data.Competencies} onChange={(v) => set('Competencies', v)} />
|
||||
</FormField>
|
||||
<FormField label="Личностные характеристики" htmlFor="PersonalTraits" full>
|
||||
<TextArea id="PersonalTraits" value={data.PersonalTraits} onChange={(v) => set('PersonalTraits', v)} />
|
||||
</FormField>
|
||||
<FormField label="Образование" htmlFor="Education">
|
||||
<TextInput id="Education" value={data.Education} onChange={(v) => set('Education', v)} />
|
||||
</FormField>
|
||||
<FormField label="Опыт работы" htmlFor="WorkExperience" full>
|
||||
<TextArea id="WorkExperience" value={data.WorkExperience} onChange={(v) => set('WorkExperience', v)} />
|
||||
</FormField>
|
||||
<FormField label="Дополнительное образование" htmlFor="AdditionalEducation" full>
|
||||
<TextArea id="AdditionalEducation" value={data.AdditionalEducation} onChange={(v) => set('AdditionalEducation', v)} />
|
||||
</FormField>
|
||||
<FormField label="Критерии оценки работы" htmlFor="EvaluationCriteria" full>
|
||||
<TextArea id="EvaluationCriteria" value={data.EvaluationCriteria} onChange={(v) => set('EvaluationCriteria', v)} />
|
||||
</FormField>
|
||||
<FormField label="Уровень владения ПК и программами" htmlFor="PcSkills" full>
|
||||
<TextArea id="PcSkills" value={data.PcSkills} onChange={(v) => set('PcSkills', v)} />
|
||||
</FormField>
|
||||
<FormField label="Иностранные языки (уровень владения)" htmlFor="ForeignLanguages" full>
|
||||
<TextArea id="ForeignLanguages" value={data.ForeignLanguages} onChange={(v) => set('ForeignLanguages', v)} />
|
||||
</FormField>
|
||||
<FormField label="Проживание" htmlFor="Residence">
|
||||
<TextInput id="Residence" value={data.Residence} onChange={(v) => set('Residence', v)} />
|
||||
</FormField>
|
||||
<FormField label="Водительские права, стаж вождения" htmlFor="DrivingLicense">
|
||||
<TextInput id="DrivingLicense" value={data.DrivingLicense} onChange={(v) => set('DrivingLicense', v)} />
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
import { getSP } from '../../shared/pnp/pnpjs';
|
||||
import { IFormData } from './model';
|
||||
|
||||
const LIST_TITLE = 'Заявка на подбор — данные';
|
||||
|
||||
// Поля-люди (один человек): логин → ensureUser → Id в колонку <Field>Id.
|
||||
const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager'];
|
||||
// Поля-люди (несколько): логины → массив Id в колонку <Field>Id.
|
||||
const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker'];
|
||||
// Числовые поля: пустая строка → null, иначе Number().
|
||||
const NUMBER_FIELDS: (keyof IFormData)[] = ['DepartmentHeadcount', 'RateProbation', 'RateAfterProbation'];
|
||||
|
||||
/** Логин → числовой Id пользователя на текущем сайте. */
|
||||
async function resolveUserId(login: string): Promise<number> {
|
||||
const ensured = await getSP().web.ensureUser(login); // v4: возвращает ISiteUserInfo с .Id
|
||||
return ensured.Id;
|
||||
}
|
||||
|
||||
/** Записать заявку «на подбор» в список данных SharePoint. */
|
||||
export async function submitRecruitingSelection(data: IFormData): Promise<void> {
|
||||
const sp = getSP();
|
||||
const item: Record<string, unknown> = {};
|
||||
|
||||
// Текст/заметки/выбор/даты/да-нет/числа (людей пропускаем — они ниже).
|
||||
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
||||
if (PERSON_FIELDS.indexOf(key) !== -1 || MULTI_PERSON_FIELDS.indexOf(key) !== -1) {
|
||||
return;
|
||||
}
|
||||
const value = data[key];
|
||||
if (typeof value === 'boolean') {
|
||||
item[key] = value;
|
||||
} else if (NUMBER_FIELDS.indexOf(key) !== -1) {
|
||||
item[key] = value === '' ? null : Number(value);
|
||||
} else {
|
||||
item[key] = value === '' ? null : value;
|
||||
}
|
||||
});
|
||||
|
||||
// Один человек → <Field>Id.
|
||||
for (let i = 0; i < PERSON_FIELDS.length; i++) {
|
||||
const login = data[PERSON_FIELDS[i]] as string;
|
||||
if (login) {
|
||||
item[`${PERSON_FIELDS[i]}Id`] = await resolveUserId(login);
|
||||
}
|
||||
}
|
||||
|
||||
// Несколько человек → <Field>Id = массив Id.
|
||||
for (let i = 0; i < MULTI_PERSON_FIELDS.length; i++) {
|
||||
const logins = data[MULTI_PERSON_FIELDS[i]] as string[];
|
||||
const ids: number[] = [];
|
||||
for (let j = 0; j < logins.length; j++) {
|
||||
ids.push(await resolveUserId(logins[j]));
|
||||
}
|
||||
item[`${MULTI_PERSON_FIELDS[i]}Id`] = ids;
|
||||
}
|
||||
|
||||
await sp.web.lists.getByTitle(LIST_TITLE).items.add(item);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import { IRequestFormProps, RequestTypeId } from '../models/types';
|
||||
|
||||
type FormComponent = React.ComponentType<IRequestFormProps>;
|
||||
|
||||
/**
|
||||
* Реестр форм: id типа заявки → лениво загружаемая форма.
|
||||
* Каждая форма — отдельный чанк (React.lazy): в бандл первого экрана не попадает,
|
||||
* подтягивается только при открытии. Добавить форму = папка + строка здесь.
|
||||
*/
|
||||
const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
|
||||
'recruiting-selection': React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: 'form-recruiting-selection' */ './RecruitingSelection/RecruitingSelectionForm'
|
||||
)
|
||||
),
|
||||
'recruiting-hire': React.lazy(
|
||||
() =>
|
||||
import(/* webpackChunkName: 'form-recruiting-hire' */ './RecruitingHire/RecruitingHireForm')
|
||||
)
|
||||
};
|
||||
|
||||
export function getFormComponent(
|
||||
id: RequestTypeId
|
||||
): React.LazyExoticComponent<FormComponent> | undefined {
|
||||
return formRegistry[id];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
define([], function() {
|
||||
return {
|
||||
"PropertyPaneDescription": "Description",
|
||||
"BasicGroupName": "Group Name",
|
||||
"DescriptionFieldLabel": "Description Field",
|
||||
"AppLocalEnvironmentSharePoint": "The app is running on your local environment as SharePoint web part",
|
||||
"AppLocalEnvironmentTeams": "The app is running on your local environment as Microsoft Teams app",
|
||||
"AppLocalEnvironmentOffice": "The app is running on your local environment in office.com",
|
||||
"AppLocalEnvironmentOutlook": "The app is running on your local environment in Outlook",
|
||||
"AppSharePointEnvironment": "The app is running on SharePoint page",
|
||||
"AppTeamsTabEnvironment": "The app is running in Microsoft Teams",
|
||||
"AppOfficeEnvironment": "The app is running in office.com",
|
||||
"AppOutlookEnvironment": "The app is running in Outlook",
|
||||
"UnknownEnvironment": "The app is running in an unknown environment"
|
||||
}
|
||||
});
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
declare interface IRequestPortalWebPartStrings {
|
||||
PropertyPaneDescription: string;
|
||||
BasicGroupName: string;
|
||||
DescriptionFieldLabel: string;
|
||||
AppLocalEnvironmentSharePoint: string;
|
||||
AppLocalEnvironmentTeams: string;
|
||||
AppLocalEnvironmentOffice: string;
|
||||
AppLocalEnvironmentOutlook: string;
|
||||
AppSharePointEnvironment: string;
|
||||
AppTeamsTabEnvironment: string;
|
||||
AppOfficeEnvironment: string;
|
||||
AppOutlookEnvironment: string;
|
||||
UnknownEnvironment: string;
|
||||
}
|
||||
|
||||
declare module 'RequestPortalWebPartStrings' {
|
||||
const strings: IRequestPortalWebPartStrings;
|
||||
export = strings;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Описание одной колонки списка SharePoint — единый источник правды.
|
||||
* Из этих описаний провижинер создаёт колонки в списке (см. provisioning/),
|
||||
* а позже те же описания будут питать чтение/запись данных формы.
|
||||
*
|
||||
* ВАЖНО про имена: internalName — латиница без пробелов, ≤32 символов
|
||||
* (иначе SharePoint закодирует пробелы в "_x0020_" и обращения к полю сломаются).
|
||||
* title — отображаемое имя, может быть на русском и с пробелами.
|
||||
*/
|
||||
export type SpFieldType =
|
||||
| 'Text' // однострочный текст
|
||||
| 'Note' // многострочный текст
|
||||
| 'Number' // число
|
||||
| 'Choice' // выбор из вариантов
|
||||
| 'DateTime' // дата/время
|
||||
| 'Boolean' // да/нет
|
||||
| 'User' // пользователь
|
||||
| 'Lookup'; // подстановка (связь с другим списком)
|
||||
|
||||
export interface IColumnDef {
|
||||
/** Внутреннее имя в SharePoint: латиница, без пробелов, ≤32 символов. */
|
||||
internalName: string;
|
||||
/** Отображаемое имя колонки (можно рус., с пробелами). */
|
||||
title: string;
|
||||
/** Тип колонки. */
|
||||
type: SpFieldType;
|
||||
/** Обязательная ли колонка. По умолчанию — нет. */
|
||||
required?: boolean;
|
||||
/** Варианты для type === 'Choice'. */
|
||||
choices?: string[];
|
||||
/** Знаков после запятой для type === 'Number'. По умолчанию — авто. */
|
||||
decimals?: number;
|
||||
/** Для type === 'DateTime': только дата, без времени. */
|
||||
dateOnly?: boolean;
|
||||
/** Заголовок списка-источника для type === 'Lookup'. */
|
||||
lookupList?: string;
|
||||
/** Какое поле источника показывать в подстановке. По умолчанию — 'Title'. */
|
||||
lookupShowField?: string;
|
||||
/** Для type === 'User': кого можно выбирать. По умолчанию — как в SharePoint. */
|
||||
userSelectionMode?: 'PeopleOnly' | 'PeopleAndGroups';
|
||||
/** Для type === 'User': разрешить выбор нескольких (создаётся колонка UserMulti). */
|
||||
allowMultiple?: boolean;
|
||||
}
|
||||
|
||||
/** Манифест провижининга формы: список данных + его колонки. */
|
||||
export interface IFormProvisioning {
|
||||
/** Отображаемое имя списка данных формы. */
|
||||
dataListTitle: string;
|
||||
/** Переименовать стандартную колонку Title (если задано). */
|
||||
titleColumnName?: string;
|
||||
/** Колонки списка данных. */
|
||||
columns: IColumnDef[];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { IconName } from '../shared/ui/Icon/iconNames';
|
||||
|
||||
/** Идентификатор типа заявки — ключ в реестре форм (formRegistry). */
|
||||
export type RequestTypeId = string;
|
||||
|
||||
/** Категория верхнего уровня в портале (ряд вкладок сверху). */
|
||||
export interface ICategory {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: IconName;
|
||||
}
|
||||
|
||||
/** Тип заявки — плитка внутри категории. */
|
||||
export interface IRequestType {
|
||||
id: RequestTypeId;
|
||||
categoryId: string;
|
||||
title: string;
|
||||
/** Короткое пояснение под заголовком плитки. Необязательно. */
|
||||
description?: string;
|
||||
icon: IconName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пропсы, которые получает каждая bespoke-форма от корня портала.
|
||||
* Форма сама владеет записью в SharePoint (через getSP()), поэтому
|
||||
* получает контекст (нужен и people picker'у), а не generic onSubmit.
|
||||
*/
|
||||
export interface IRequestFormProps {
|
||||
requestType: IRequestType;
|
||||
context: WebPartContext;
|
||||
onCancel: () => void;
|
||||
/** Необязательное уведомление родителя после успешного сохранения. */
|
||||
onSaved?: () => void;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { getSP } from '../pnp/pnpjs';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
import { IList } from '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import '@pnp/sp/site-users/web';
|
||||
import '@pnp/sp/profiles';
|
||||
import { IApprovalRow } from './models/IApprovalRow';
|
||||
import { IMatrixRow } from './models/IMatrixRow';
|
||||
import { ISiteUser } from './models/ISiteUser';
|
||||
|
||||
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
||||
const FIELDS = {
|
||||
approver: 'Approver', // «Согласующий» (User) → ApproverId
|
||||
status: 'Status', // «Статус» (Choice)
|
||||
ticket: 'Ticket', // «Заявка» (Lookup) → TicketId
|
||||
date: 'Date', // «Дата» (DateTime)
|
||||
comment: 'Comment', // «Комментарий» (Text)
|
||||
order: 'Order', // «Порядок» (Number)
|
||||
allowReassign: 'AllowReassignToOtherApprovers', // «Можно перепоручать» (Да/Нет)
|
||||
};
|
||||
|
||||
// Согласующий для записи в список.
|
||||
export interface IApproverToWrite {
|
||||
email: string;
|
||||
position: string;
|
||||
allowReassign: boolean;
|
||||
}
|
||||
|
||||
// Все запросы к списку «Согласование — <форма>». listTitle задаётся формой.
|
||||
export class ApprovalListService {
|
||||
constructor(private readonly listTitle: string) {}
|
||||
|
||||
private list(): IList {
|
||||
return getSP().web.lists.getByTitle(this.listTitle);
|
||||
}
|
||||
|
||||
// Пишет по строке на каждого согласующего с указанным статусом.
|
||||
// Порядок (Order) = позиция в массиве.
|
||||
public async createSteps(
|
||||
requestId: number,
|
||||
approvers: IApproverToWrite[],
|
||||
status: string
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < approvers.length; i++) {
|
||||
const a = approvers[i];
|
||||
if (!a.email) continue;
|
||||
const approverId = await this.resolveUserId(a.email);
|
||||
await this.list().items.add({
|
||||
Title: a.position || a.email,
|
||||
[`${FIELDS.approver}Id`]: approverId,
|
||||
[FIELDS.status]: status,
|
||||
[`${FIELDS.ticket}Id`]: requestId,
|
||||
[FIELDS.order]: i + 1,
|
||||
[FIELDS.allowReassign]: a.allowReassign,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляет одну строку согласования с заданным Order (для делегата).
|
||||
public async addApproverRow(
|
||||
requestId: number,
|
||||
approver: IApproverToWrite,
|
||||
order: number,
|
||||
status: string
|
||||
): Promise<void> {
|
||||
const approverId = await this.resolveUserId(approver.email);
|
||||
await this.list().items.add({
|
||||
Title: approver.position || approver.email,
|
||||
[`${FIELDS.approver}Id`]: approverId,
|
||||
[FIELDS.status]: status,
|
||||
[`${FIELDS.ticket}Id`]: requestId,
|
||||
[FIELDS.order]: order,
|
||||
[FIELDS.allowReassign]: approver.allowReassign,
|
||||
});
|
||||
}
|
||||
|
||||
// Строки согласования по заявке, в порядке согласования (по Order).
|
||||
public async getForRequest(requestId: number): Promise<IApprovalRow[]> {
|
||||
const rows = (await this.list()
|
||||
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
|
||||
.select('Id', FIELDS.status, FIELDS.order, FIELDS.allowReassign, `${FIELDS.approver}/Id`)
|
||||
.expand(FIELDS.approver)
|
||||
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
|
||||
|
||||
return rows.map((r) => {
|
||||
const approver = r[FIELDS.approver] as { Id?: number } | undefined;
|
||||
return {
|
||||
id: r.Id as number,
|
||||
status: (r[FIELDS.status] as string) || '',
|
||||
approverId: approver?.Id ?? 0,
|
||||
order: (r[FIELDS.order] as number) ?? 0,
|
||||
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Строки согласования для отображения матрицы (ФИО, должность, статус, дата, комментарий).
|
||||
public async getMatrixForRequest(requestId: number): Promise<IMatrixRow[]> {
|
||||
const rows = (await this.list()
|
||||
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
|
||||
.select(
|
||||
'Id',
|
||||
'Title',
|
||||
FIELDS.status,
|
||||
FIELDS.date,
|
||||
FIELDS.comment,
|
||||
FIELDS.order,
|
||||
FIELDS.allowReassign,
|
||||
`${FIELDS.approver}/Title`
|
||||
)
|
||||
.expand(FIELDS.approver)
|
||||
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
|
||||
|
||||
return rows.map((r, i) => {
|
||||
const approver = r[FIELDS.approver] as { Title?: string } | undefined;
|
||||
return {
|
||||
order: (r[FIELDS.order] as number) ?? i + 1,
|
||||
who: approver?.Title || '',
|
||||
email: '',
|
||||
position: (r.Title as string) || '',
|
||||
status: (r[FIELDS.status] as string) || '',
|
||||
date: this.formatDate((r[FIELDS.date] as string) || ''),
|
||||
comment: (r[FIELDS.comment] as string) || '',
|
||||
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Поиск пользователей сайта по части имени — для пикера согласующего.
|
||||
public async searchUsers(query: string): Promise<ISiteUser[]> {
|
||||
const q = query.trim();
|
||||
if (q.length < 2) return [];
|
||||
const escaped = q.replace(/'/g, "''");
|
||||
const users = (await getSP()
|
||||
.web.siteUsers.filter(`substringof('${escaped}',Title) and PrincipalType eq 1`)
|
||||
.select('Id', 'Title', 'Email', 'LoginName')
|
||||
.top(10)()) as Array<Record<string, unknown>>;
|
||||
return users.map((u) => ({
|
||||
id: u.Id as number,
|
||||
name: (u.Title as string) || '',
|
||||
email: (u.Email as string) || '',
|
||||
loginName: (u.LoginName as string) || '',
|
||||
}));
|
||||
}
|
||||
|
||||
// Должность пользователя из профиля SharePoint (SPS-JobTitle). '' если недоступно.
|
||||
public async getJobTitle(loginName: string): Promise<string> {
|
||||
if (!loginName) return '';
|
||||
try {
|
||||
const props = (await getSP().profiles.getPropertiesFor(loginName)) as {
|
||||
UserProfileProperties?: unknown;
|
||||
};
|
||||
const raw = props.UserProfileProperties;
|
||||
const list: Array<{ Key: string; Value: string }> = Array.isArray(raw)
|
||||
? (raw as Array<{ Key: string; Value: string }>)
|
||||
: ((raw as { results?: Array<{ Key: string; Value: string }> })?.results ?? []);
|
||||
const map: Record<string, string> = {};
|
||||
for (const p of list) map[p.Key] = p.Value;
|
||||
return map['SPS-JobTitle'] || map.Title || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Решение согласующего по строке: статус + дата + комментарий.
|
||||
public async setDecision(rowId: number, status: string, comment: string): Promise<void> {
|
||||
await this.list()
|
||||
.items.getById(rowId)
|
||||
.update({
|
||||
[FIELDS.status]: status,
|
||||
[FIELDS.date]: new Date().toISOString(),
|
||||
[FIELDS.comment]: comment,
|
||||
});
|
||||
}
|
||||
|
||||
// Удаляет все строки согласования по заявке (при возврате/повторной отправке).
|
||||
public async deleteForRequest(requestId: number): Promise<void> {
|
||||
const rows = await this.getForRequest(requestId);
|
||||
for (const row of rows) {
|
||||
await this.list().items.getById(row.id).delete();
|
||||
}
|
||||
}
|
||||
|
||||
// Id текущего пользователя на сайте (для сравнения с полем «Согласующий»).
|
||||
public async getCurrentUserId(): Promise<number> {
|
||||
const user = await getSP().web.currentUser.select('Id')();
|
||||
return user.Id;
|
||||
}
|
||||
|
||||
// email → числовой Id пользователя сайта (для Person-поля «Согласующий»).
|
||||
private async resolveUserId(email: string): Promise<number> {
|
||||
const user = await this.ensure(email);
|
||||
return user.Id;
|
||||
}
|
||||
|
||||
// email → отображаемое имя (ФИО). Фолбэк на email, если имя недоступно.
|
||||
public async resolveUserName(email: string): Promise<string> {
|
||||
try {
|
||||
const user = await this.ensure(email);
|
||||
return user.Title || email;
|
||||
} catch {
|
||||
return email;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensure(email: string): Promise<{ Id: number; Title: string }> {
|
||||
const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`;
|
||||
return getSP().web.ensureUser(login);
|
||||
}
|
||||
|
||||
private formatDate(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
|
||||
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
|
||||
return `${dd}.${mm}.${d.getFullYear()}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { APPROVAL_API_CLIENT_ID, APPROVAL_API_BASE_URL } from '../config';
|
||||
import { IApiResponse, IRequestType, IResolvedRoute } from './models/IApprovalRouteApi';
|
||||
|
||||
// Все обращения к внешнему approval-route API — только здесь.
|
||||
// Клиент авторизуется через AadHttpClient (SSO по текущему пользователю).
|
||||
async function client(context: WebPartContext): Promise<AadHttpClient> {
|
||||
return context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||
}
|
||||
|
||||
// GET /api/request-types — список типов заявок (id + имя). Пригодится, чтобы
|
||||
// сопоставить форму с RequestTypeId, если он неизвестен заранее.
|
||||
export async function getRequestTypes(context: WebPartContext): Promise<IRequestType[]> {
|
||||
const c = await client(context);
|
||||
const response: HttpClientResponse = await c.get(
|
||||
`${APPROVAL_API_BASE_URL}/api/request-types`,
|
||||
AadHttpClient.configurations.v1
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`request-types: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const body: IApiResponse<IRequestType[]> = await response.json();
|
||||
return body.data ?? [];
|
||||
}
|
||||
|
||||
// GET /api/template-routes/resolve — планируемый маршрут согласования для типа
|
||||
// заявки и пользователя. Бэкенд сам подставляет согласующих (executorEmail/position)
|
||||
// по ролям исходя из userEmail.
|
||||
export async function resolveRoute(
|
||||
context: WebPartContext,
|
||||
requestTypeId: number,
|
||||
userEmail: string
|
||||
): Promise<IResolvedRoute> {
|
||||
const c = await client(context);
|
||||
const url =
|
||||
`${APPROVAL_API_BASE_URL}/api/template-routes/resolve` +
|
||||
`?RequestTypeId=${requestTypeId}` +
|
||||
`&UserEmail=${encodeURIComponent(userEmail)}` +
|
||||
`&Status=Approval`;
|
||||
const response: HttpClientResponse = await c.get(url, AadHttpClient.configurations.v1);
|
||||
if (!response.ok) {
|
||||
throw new Error(`resolve: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const body: IApiResponse<IResolvedRoute> = await response.json();
|
||||
return body.data;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { resolveRoute } from './approvalRoute.service';
|
||||
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
||||
import { IMatrixRow } from './models/IMatrixRow';
|
||||
|
||||
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
|
||||
// Имена согласующих подтягиваются по executorEmail (бэкенд отдаёт email + должность).
|
||||
export async function getPlannedMatrix(
|
||||
context: WebPartContext,
|
||||
requestTypeId: number,
|
||||
userEmail: string,
|
||||
approval: ApprovalListService
|
||||
): Promise<IMatrixRow[]> {
|
||||
if (!userEmail) return [];
|
||||
const route = await resolveRoute(context, requestTypeId, userEmail);
|
||||
const ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo);
|
||||
return Promise.all(
|
||||
ordered.map(async (s) => ({
|
||||
order: s.orderNo,
|
||||
who: s.executorEmail ? await approval.resolveUserName(s.executorEmail) : '',
|
||||
email: s.executorEmail || '',
|
||||
position: s.position || '',
|
||||
status: '—',
|
||||
date: '',
|
||||
comment: '',
|
||||
allowReassign: s.allowReassignToOtherApprovers,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Записать строки согласующих для заявки с указанным статусом.
|
||||
// Матрица перезаписывается целиком (для повторной отправки/черновика).
|
||||
export async function writeApprovers(
|
||||
approval: ApprovalListService,
|
||||
requestId: number,
|
||||
approvers: IApproverToWrite[],
|
||||
status: string
|
||||
): Promise<void> {
|
||||
await approval.deleteForRequest(requestId);
|
||||
await approval.createSteps(requestId, approvers, status);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { getSP } from '../pnp/pnpjs';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import { ApprovalListService } from './approvalList.service';
|
||||
import { REQUEST_STATUS } from './models/statuses';
|
||||
import { IApprovalRow } from './models/IApprovalRow';
|
||||
import { ISiteUser } from './models/ISiteUser';
|
||||
|
||||
export type ApproverDecision = 'approve' | 'return' | 'reject';
|
||||
|
||||
// Решение согласующего → статус его строки в списке согласования.
|
||||
const ROW_STATUS: Record<ApproverDecision, string> = {
|
||||
approve: REQUEST_STATUS.approved,
|
||||
return: REQUEST_STATUS.returned,
|
||||
reject: REQUEST_STATUS.rejected,
|
||||
};
|
||||
|
||||
// Действия согласующего: определение его строки, применение решения,
|
||||
// делегирование и пересчёт статуса заявки в контентном списке.
|
||||
//
|
||||
// ВНИМАНИЕ: используется в режиме согласующего, который открывается по конкретной
|
||||
// заявке (requestId). Пока в портале нет маршрутизации на форму заявки, этот сервис
|
||||
// готов, но UI-режим согласующего ещё не подключён (см. план).
|
||||
export class ApproverService {
|
||||
constructor(
|
||||
private readonly approval: ApprovalListService,
|
||||
private readonly dataListTitle: string
|
||||
) {}
|
||||
|
||||
// Ожидающая строка текущего пользователя, если сейчас его очередь; иначе undefined.
|
||||
// Последовательное согласование: активна первая строка со статусом «На согласовании».
|
||||
public async getPendingRowForCurrentUser(requestId: number): Promise<IApprovalRow | undefined> {
|
||||
const rows = await this.approval.getForRequest(requestId);
|
||||
const userId = await this.approval.getCurrentUserId();
|
||||
const firstPending = rows.filter((r) => r.status === REQUEST_STATUS.onApproval)[0];
|
||||
if (firstPending && firstPending.approverId === userId) return firstPending;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Делегирование: согласующий добавляет делеганта ПЕРЕД собой (Order — между
|
||||
// предыдущей строкой и своей). Делегат согласует раньше и сам не делегирует.
|
||||
public async delegate(
|
||||
requestId: number,
|
||||
currentRowId: number,
|
||||
user: ISiteUser
|
||||
): Promise<void> {
|
||||
const rows = await this.approval.getForRequest(requestId);
|
||||
const current = rows.filter((r) => r.id === currentRowId)[0];
|
||||
if (!current) return;
|
||||
const before = rows.filter((r) => r.order < current.order);
|
||||
const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1;
|
||||
const newOrder = (prevOrder + current.order) / 2;
|
||||
const position = await this.approval.getJobTitle(user.loginName);
|
||||
await this.approval.addApproverRow(
|
||||
requestId,
|
||||
{ email: user.email, position, allowReassign: false },
|
||||
newOrder,
|
||||
REQUEST_STATUS.onApproval
|
||||
);
|
||||
}
|
||||
|
||||
// Применяет решение: обновляет строку и пересчитывает статус заявки.
|
||||
// Возвращает новый статус заявки.
|
||||
public async decide(
|
||||
requestId: number,
|
||||
rowId: number,
|
||||
decision: ApproverDecision,
|
||||
comment: string
|
||||
): Promise<string> {
|
||||
await this.approval.setDecision(rowId, ROW_STATUS[decision], comment);
|
||||
const rows = await this.approval.getForRequest(requestId);
|
||||
const requestStatus = this.computeRequestStatus(rows);
|
||||
await this.updateRequestStatus(requestId, requestStatus);
|
||||
return requestStatus;
|
||||
}
|
||||
|
||||
private computeRequestStatus(rows: IApprovalRow[]): string {
|
||||
if (rows.some((r) => r.status === REQUEST_STATUS.rejected)) return REQUEST_STATUS.rejected;
|
||||
if (rows.some((r) => r.status === REQUEST_STATUS.returned)) return REQUEST_STATUS.returned;
|
||||
if (rows.length > 0 && rows.every((r) => r.status === REQUEST_STATUS.approved)) {
|
||||
return REQUEST_STATUS.approved;
|
||||
}
|
||||
return REQUEST_STATUS.onApproval;
|
||||
}
|
||||
|
||||
private async updateRequestStatus(id: number, status: string): Promise<void> {
|
||||
await getSP().web.lists.getByTitle(this.dataListTitle).items.getById(id).update({
|
||||
Status: status,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Единая точка импорта механизма согласования.
|
||||
export { REQUEST_STATUS, REQUEST_STATUS_CHOICES } from './models/statuses';
|
||||
export type { ISiteUser } from './models/ISiteUser';
|
||||
export type { IMatrixRow } from './models/IMatrixRow';
|
||||
export type { IApprovalRow } from './models/IApprovalRow';
|
||||
export type {
|
||||
IRequestType,
|
||||
IResolvedRoute,
|
||||
IResolvedRouteStep,
|
||||
} from './models/IApprovalRouteApi';
|
||||
|
||||
export { getRequestTypes, resolveRoute } from './approvalRoute.service';
|
||||
export { ApprovalListService } from './approvalList.service';
|
||||
export type { IApproverToWrite } from './approvalList.service';
|
||||
export { ApproverService } from './approverDecision.service';
|
||||
export type { ApproverDecision } from './approverDecision.service';
|
||||
export { getPlannedMatrix, writeApprovers } from './approvalSubmission.service';
|
||||
export { useMatrixEditor } from './useMatrixEditor';
|
||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||
export { UserPicker } from './ui/UserPicker';
|
||||
@@ -0,0 +1,32 @@
|
||||
// Типы внешнего approval-route API (api.tnsu.kz).
|
||||
|
||||
export type RouteType = 'None' | 'Parallel' | 'Sequential';
|
||||
export type ApprovalStatus = 'None' | 'Approval' | 'Execution';
|
||||
|
||||
// GET /api/request-types → { data: IRequestType[] }
|
||||
export interface IRequestType {
|
||||
id: number;
|
||||
name: string;
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
// Обёртка ответа API (все ответы приходят в поле data).
|
||||
export interface IApiResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
// GET /api/template-routes/resolve → data
|
||||
export interface IResolvedRoute {
|
||||
routeId: number;
|
||||
routeType: RouteType;
|
||||
requiresControl: boolean;
|
||||
steps: IResolvedRouteStep[];
|
||||
}
|
||||
|
||||
export interface IResolvedRouteStep {
|
||||
id: number;
|
||||
orderNo: number;
|
||||
executorEmail: string | null;
|
||||
position: string | null;
|
||||
allowReassignToOtherApprovers: boolean;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Строка согласования из списка «Согласование — <форма>».
|
||||
export interface IApprovalRow {
|
||||
id: number;
|
||||
status: string;
|
||||
approverId: number; // Id пользователя сайта в поле «Согласующий»
|
||||
order: number; // колонка Order (очередь согласования)
|
||||
allowReassign: boolean; // AllowReassignToOtherApprovers
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Строка матрицы согласования для отображения и редактирования на форме.
|
||||
export interface IMatrixRow {
|
||||
order: number; // порядок согласования (колонка Order)
|
||||
who: string; // ФИО согласующего
|
||||
email: string; // email (для записи новых строк; '' для загруженных из списка)
|
||||
position: string; // должность/роль
|
||||
status: string; // статус согласования или «—» для планируемой
|
||||
date: string; // дата решения (дд.мм.гггг) или ''
|
||||
comment: string;
|
||||
allowReassign: boolean; // может ли этот согласующий добавить делеганта
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Пользователь сайта (результат поиска для пикера согласующего).
|
||||
export interface ISiteUser {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
loginName: string; // claims-логин (для чтения должности из профиля)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Значения Choice-колонки «Статус» — общий жизненный цикл заявки портала.
|
||||
// Одинаков для контентного списка (заявка) и списка согласования (строки маршрута).
|
||||
// В слое формы кодом ставятся Draft (черновик) и OnApproval (отправка на согласование);
|
||||
// Approved/Rejected/Returned выставляются при решениях согласующих; Execution/Closed —
|
||||
// зарезервированы под будущие этапы.
|
||||
export const REQUEST_STATUS = {
|
||||
draft: 'Черновик',
|
||||
onApproval: 'На согласовании',
|
||||
approved: 'Согласовано',
|
||||
rejected: 'Отклонено',
|
||||
returned: 'Возвращена на доработку',
|
||||
execution: 'Выполнение',
|
||||
closed: 'Закрыта',
|
||||
} as const;
|
||||
|
||||
// Полный набор значений для Choice-колонки при провижининге.
|
||||
export const REQUEST_STATUS_CHOICES: string[] = [
|
||||
REQUEST_STATUS.draft,
|
||||
REQUEST_STATUS.onApproval,
|
||||
REQUEST_STATUS.approved,
|
||||
REQUEST_STATUS.rejected,
|
||||
REQUEST_STATUS.returned,
|
||||
REQUEST_STATUS.execution,
|
||||
REQUEST_STATUS.closed,
|
||||
];
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as React from 'react';
|
||||
import styles from './approval.module.scss';
|
||||
import { Icon } from '../../ui';
|
||||
import { IUseMatrixEditor } from '../useMatrixEditor';
|
||||
import { ISiteUser } from '../models/ISiteUser';
|
||||
import { UserPicker } from './UserPicker';
|
||||
|
||||
export interface IApprovalMatrixProps {
|
||||
editor: IUseMatrixEditor;
|
||||
search: (query: string) => Promise<ISiteUser[]>;
|
||||
}
|
||||
|
||||
/** Таблица маршрута согласования + (если editable) правка порядка/состава. */
|
||||
export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({ editor, search }) => {
|
||||
const { rows, loading, error, editable } = editor;
|
||||
|
||||
return (
|
||||
<div className={styles.matrixWrap}>
|
||||
<div className={styles.matrixTitle}>Маршрут согласования</div>
|
||||
{loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>}
|
||||
{!loading && error && <div className={styles.matrixNote}>Маршрут недоступен: {error}</div>}
|
||||
{!loading && !error && rows.length === 0 && (
|
||||
<div className={styles.matrixNote}>Согласующие не заданы.</div>
|
||||
)}
|
||||
{!loading && !error && rows.length > 0 && (
|
||||
<table className={styles.matrix}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Согласующий</th>
|
||||
<th>Должность</th>
|
||||
<th>Статус</th>
|
||||
<th>Дата</th>
|
||||
{editable && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td>{r.who}</td>
|
||||
<td>{r.position}</td>
|
||||
<td>{r.status}</td>
|
||||
<td>{r.date}</td>
|
||||
{editable && (
|
||||
<td className={styles.matrixActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={i === 0}
|
||||
onClick={() => editor.moveUp(i)}
|
||||
title="Выше"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={i === rows.length - 1}
|
||||
onClick={() => editor.moveDown(i)}
|
||||
title="Ниже"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||
onClick={() => editor.remove(i)}
|
||||
title="Удалить"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{editable && !loading && (
|
||||
<div className={styles.matrixPicker}>
|
||||
<UserPicker
|
||||
search={search}
|
||||
onPick={editor.addApprover}
|
||||
placeholder="Добавить согласующего: начните вводить ФИО…"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as React from 'react';
|
||||
import styles from './approval.module.scss';
|
||||
import { ISiteUser } from '../models/ISiteUser';
|
||||
|
||||
export interface IUserPickerProps {
|
||||
search: (query: string) => Promise<ISiteUser[]>;
|
||||
onPick: (user: ISiteUser) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** Лёгкий поиск пользователя сайта: ввод → выпадающий список → выбор. */
|
||||
export const UserPicker: React.FC<IUserPickerProps> = ({ search, onPick, placeholder }) => {
|
||||
const [query, setQuery] = React.useState<string>('');
|
||||
const [results, setResults] = React.useState<ISiteUser[]>([]);
|
||||
const [busy, setBusy] = React.useState<boolean>(false);
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
setQuery(value);
|
||||
if (value.trim().length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
search(value)
|
||||
.then((r) => {
|
||||
setResults(r);
|
||||
setBusy(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setResults([]);
|
||||
setBusy(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handlePick = (user: ISiteUser): void => {
|
||||
onPick(user);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.picker}>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={query}
|
||||
placeholder={placeholder || 'Начните вводить ФИО…'}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
/>
|
||||
{busy && <div className={styles.pickerNote}>Поиск…</div>}
|
||||
{!busy && results.length > 0 && (
|
||||
<div className={styles.pickerList}>
|
||||
{results.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
className={styles.pickerItem}
|
||||
onClick={() => handlePick(u)}
|
||||
>
|
||||
{u.name}
|
||||
{u.email && <span className={styles.pickerEmail}>{u.email}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// ---- Общий инпут (пикер) ----
|
||||
.input {
|
||||
width: 100%;
|
||||
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;
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Пикер пользователя ----
|
||||
.picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pickerNote {
|
||||
margin-top: $sp-1;
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.pickerList {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: $sp-1;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
box-shadow: $sh-2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pickerItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
color: $gray-900;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: $navy-50;
|
||||
}
|
||||
}
|
||||
|
||||
.pickerEmail {
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
// ---- Матрица согласования ----
|
||||
.matrixWrap {
|
||||
margin-top: $sp-5;
|
||||
padding: $sp-4;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
background: $gray-50;
|
||||
}
|
||||
|
||||
.matrixTitle {
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-section-title;
|
||||
font-weight: 600;
|
||||
color: $navy-800;
|
||||
margin-bottom: $sp-3;
|
||||
}
|
||||
|
||||
.matrixNote {
|
||||
font-size: $fs-sm;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.matrix {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: $fs-sm;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: $gray-500;
|
||||
font-size: $fs-label;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
color: $gray-900;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.matrixActions {
|
||||
display: flex;
|
||||
gap: $sp-1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
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: $navy-700;
|
||||
cursor: pointer;
|
||||
transition: .16s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $gold-400;
|
||||
color: $gold-600;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: .4;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.iconBtnDanger {
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $red-500;
|
||||
color: $red-500;
|
||||
}
|
||||
}
|
||||
|
||||
.flipY {
|
||||
transform: scaleY(-1);
|
||||
}
|
||||
|
||||
.matrixPicker {
|
||||
margin-top: $sp-3;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { IMatrixRow } from './models/IMatrixRow';
|
||||
import { ISiteUser } from './models/ISiteUser';
|
||||
|
||||
export interface IUseMatrixEditor {
|
||||
rows: IMatrixRow[];
|
||||
loading: boolean;
|
||||
error: string | undefined;
|
||||
editable: boolean;
|
||||
addApprover: (user: ISiteUser) => void;
|
||||
moveUp: (index: number) => void;
|
||||
moveDown: (index: number) => void;
|
||||
remove: (index: number) => void;
|
||||
}
|
||||
|
||||
function swap(rows: IMatrixRow[], a: number, b: number): IMatrixRow[] {
|
||||
const next = [...rows];
|
||||
[next[a], next[b]] = [next[b], next[a]];
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает матрицу согласования и (если editable) позволяет её редактировать.
|
||||
* @param load загрузчик строк (планируемый маршрут из resolve или строки заявки).
|
||||
* @param reloadKey при изменении — перезагрузка (например, email инициатора).
|
||||
* @param editable доступны ли правки (порядок/добавление/удаление).
|
||||
* @param ready можно ли грузить (false — ждём готовности данных).
|
||||
* @param getJobTitle должность добавляемого согласующего из профиля.
|
||||
*/
|
||||
export function useMatrixEditor(
|
||||
load: () => Promise<IMatrixRow[]>,
|
||||
reloadKey: string,
|
||||
editable: boolean,
|
||||
ready: boolean,
|
||||
getJobTitle: (loginName: string) => Promise<string>
|
||||
): IUseMatrixEditor {
|
||||
const [rows, setRows] = useState<IMatrixRow[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
load()
|
||||
.then((r) => {
|
||||
if (!active) return;
|
||||
setRows(r);
|
||||
setError(undefined);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!active) return;
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// load — стабильная ссылка от вызывающего (useCallback); перезагрузка по reloadKey/ready.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reloadKey, ready]);
|
||||
|
||||
const appendUser = (user: ISiteUser, position: string): void => {
|
||||
setRows((prev) => {
|
||||
if (user.email && prev.some((r) => r.email === user.email)) return prev;
|
||||
const row: IMatrixRow = {
|
||||
order: prev.length + 1,
|
||||
who: user.name,
|
||||
email: user.email,
|
||||
position,
|
||||
status: '—',
|
||||
date: '',
|
||||
comment: '',
|
||||
allowReassign: false,
|
||||
};
|
||||
return [...prev, row];
|
||||
});
|
||||
};
|
||||
|
||||
const addApprover = (user: ISiteUser): void => {
|
||||
// Должность подтягиваем из профиля; при неудаче добавляем без неё.
|
||||
getJobTitle(user.loginName)
|
||||
.then((position) => appendUser(user, position))
|
||||
.catch(() => appendUser(user, ''));
|
||||
};
|
||||
|
||||
const moveUp = (i: number): void => setRows((prev) => (i <= 0 ? prev : swap(prev, i, i - 1)));
|
||||
const moveDown = (i: number): void =>
|
||||
setRows((prev) => (i >= prev.length - 1 ? prev : swap(prev, i, i + 1)));
|
||||
const remove = (i: number): void => setRows((prev) => prev.filter((_, idx) => idx !== i));
|
||||
|
||||
return { rows, loading, error, editable, addApprover, moveUp, moveDown, remove };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Сайт SharePoint, где живут списки данных портала.
|
||||
* Портал (web part) может размещаться на другом сайте (например, на главной),
|
||||
* поэтому клиент PnPjs всегда нацеливаем на этот адрес явно.
|
||||
*
|
||||
* TODO: при необходимости вынести в свойство web part (property pane),
|
||||
* чтобы не хардкодить и переключать между dev/prod-сайтами.
|
||||
*/
|
||||
export const DATA_SITE_URL = 'https://tnsukz.sharepoint.com/sites/requests';
|
||||
|
||||
/**
|
||||
* Внешний approval-route API (маршруты согласования по ролям).
|
||||
* Авторизация — AadHttpClient (SSO текущего пользователя) по clientId
|
||||
* зарегистрированного AAD-приложения. Разрешение объявлено в
|
||||
* config/package-solution.json (webApiPermissionRequests) и одобряется
|
||||
* в Центре администрирования SharePoint (API access) при развёртывании.
|
||||
*/
|
||||
export const APPROVAL_API_CLIENT_ID = '9232d9e2-9807-4753-808f-07250e5474b0';
|
||||
export const APPROVAL_API_BASE_URL = 'https://api.tnsu.kz/approval-route';
|
||||
@@ -0,0 +1,27 @@
|
||||
import { spfi, SPFI, SPFx } from '@pnp/sp';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import '@pnp/sp/site-users/web';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { DATA_SITE_URL } from '../config';
|
||||
|
||||
// Единый инициализированный клиент PnPjs на весь web part.
|
||||
let _sp: SPFI | undefined;
|
||||
|
||||
/**
|
||||
* Инициализировать PnPjs контекстом web part. Вызывать один раз в onInit.
|
||||
* Клиент нацелен на сайт данных (DATA_SITE_URL), а не на текущий сайт, —
|
||||
* списки живут там, где бы ни стоял сам web part. Авторизация берётся из context.
|
||||
*/
|
||||
export function initSP(context: WebPartContext): void {
|
||||
_sp = spfi(DATA_SITE_URL).using(SPFx(context));
|
||||
}
|
||||
|
||||
/** Получить инициализированный клиент. Бросает, если initSP не был вызван. */
|
||||
export function getSP(): SPFI {
|
||||
if (!_sp) {
|
||||
throw new Error('PnPjs не инициализирован: вызовите initSP(context) в onInit web part.');
|
||||
}
|
||||
return _sp;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
@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; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
@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);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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>;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.card {
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: $sh-1;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-form-title;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: $gray-400;
|
||||
margin-top: 6px;
|
||||
letter-spacing: .3px;
|
||||
}
|
||||
|
||||
.hairline {
|
||||
@include hairline;
|
||||
margin: 6px 0 22px;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import styles from './FormCard.module.scss';
|
||||
|
||||
interface IFormCardProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Белая карточка формы: заголовок (Lora) + золотой hairline + содержимое. */
|
||||
export const FormCard: React.FC<IFormCardProps> = ({ title, subtitle, children }) => {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
{subtitle && <div className={styles.subtitle}>{subtitle}</div>}
|
||||
<hr className={styles.hairline} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px 22px;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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>;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
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-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>
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
// Единый набор имён иконок. Значение = id символа в спрайте (см. IconSprite).
|
||||
// Добавляя иконку: 1) добавь имя сюда, 2) добавь <symbol> в IconSprite.
|
||||
export const ICON_NAMES = [
|
||||
'building',
|
||||
'chart',
|
||||
'monitor',
|
||||
'calculator',
|
||||
'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];
|
||||
@@ -0,0 +1,33 @@
|
||||
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';
|
||||
|
||||
interface IPeopleFieldProps {
|
||||
context: WebPartContext;
|
||||
/** Логин/e-mail выбранного сотрудника (хранится в модели формы). */
|
||||
value: string;
|
||||
onChange: (login: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** Выбор ОДНОГО сотрудника. Обёртка над PeoplePicker из @pnp/spfx-controls-react. */
|
||||
export const PeopleField: React.FC<IPeopleFieldProps> = ({ context, value, onChange, placeholder }) => {
|
||||
const handleChange = (items: IPickedUser[]): void => {
|
||||
const first = items && items.length > 0 ? items[0] : undefined;
|
||||
onChange(first ? pickedLogin(first) : '');
|
||||
};
|
||||
|
||||
return (
|
||||
<PeoplePicker
|
||||
context={buildPickerContext(context)}
|
||||
personSelectionLimit={1}
|
||||
principalTypes={[PrincipalType.User]}
|
||||
defaultSelectedUsers={value ? [value] : []}
|
||||
resolveDelay={300}
|
||||
ensureUser
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
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';
|
||||
|
||||
interface IPeopleFieldMultiProps {
|
||||
context: WebPartContext;
|
||||
/** Логины выбранных сотрудников. */
|
||||
value: string[];
|
||||
onChange: (logins: string[]) => void;
|
||||
placeholder?: string;
|
||||
/** Максимум выбираемых людей. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Выбор НЕСКОЛЬКИХ сотрудников (для колонок «Пользователь или группа (несколько)»). */
|
||||
export const PeopleFieldMulti: React.FC<IPeopleFieldMultiProps> = ({
|
||||
context,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
limit = 10
|
||||
}) => {
|
||||
const handleChange = (items: IPickedUser[]): void => {
|
||||
const logins = (items || []).map(pickedLogin).filter((login) => login.length > 0);
|
||||
onChange(logins);
|
||||
};
|
||||
|
||||
return (
|
||||
<PeoplePicker
|
||||
context={buildPickerContext(context)}
|
||||
personSelectionLimit={limit}
|
||||
principalTypes={[PrincipalType.User]}
|
||||
defaultSelectedUsers={value}
|
||||
resolveDelay={300}
|
||||
ensureUser
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { IPeoplePickerContext } from '@pnp/spfx-controls-react/lib/PeoplePicker';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
|
||||
/** Что возвращает picker в onChange — берём логин (надёжнее для ensureUser/предзаполнения). */
|
||||
export interface IPickedUser {
|
||||
loginName?: string;
|
||||
secondaryText?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Идентификатор выбранного пользователя для хранения в форме.
|
||||
* Берём e-mail (secondaryText): пикер надёжно резолвит его обратно в ФИО через
|
||||
* defaultSelectedUsers, и ensureUser(email) работает при записи. Клеймовый
|
||||
* loginName (i:0#.f|membership|…) в чип попадает как есть, без имени — поэтому он в конце.
|
||||
*/
|
||||
export function pickedLogin(user: IPickedUser): string {
|
||||
return user.secondaryText || user.loginName || user.id || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Контекст для PeoplePicker из WebPartContext.
|
||||
* as unknown: @pnp/spfx-controls-react тянет свою копию sp-http-msgraph,
|
||||
* из-за чего тип msGraphClientFactory формально несовместим (в рантайме объекты те же).
|
||||
*/
|
||||
export function buildPickerContext(context: WebPartContext): IPeoplePickerContext {
|
||||
return {
|
||||
absoluteUrl: context.pageContext.web.absoluteUrl,
|
||||
msGraphClientFactory: context.msGraphClientFactory,
|
||||
spHttpClient: context.spHttpClient
|
||||
} as unknown as IPeoplePickerContext;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Select.module.scss';
|
||||
|
||||
export interface ISelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ISelectProps {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: ISelectOption[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** Выпадающий список (SharePoint Choice). */
|
||||
export const Select: React.FC<ISelectProps> = ({ id, value, onChange, options, placeholder }) => {
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
className={styles.select}
|
||||
value={value}
|
||||
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>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user