Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ed399becc | |||
| 8ef7acd93f | |||
| ae940a9240 | |||
| 43d533b075 | |||
| 91361c439b | |||
| 2bbdcc4ef7 | |||
| 8e08eef9b4 | |||
| 04f04133f2 | |||
| ad04d9bda4 | |||
| 537bc17b33 | |||
| 33befd5d67 | |||
| f26ba40d22 | |||
| 96612d2181 | |||
| 486080b268 | |||
| 9cb5080bf9 | |||
| b0280281fb | |||
| 67a732f7b7 | |||
| 655fe281a6 | |||
| 031c28e164 |
+1
-1
@@ -10,7 +10,7 @@ sharepoint/solution/
|
|||||||
.env.local
|
.env.local
|
||||||
.env.example
|
.env.example
|
||||||
*.user
|
*.user
|
||||||
|
workspace.xml
|
||||||
**/bin/
|
**/bin/
|
||||||
**/obj/
|
**/obj/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"Entra": {
|
||||||
|
"TenantId": "",
|
||||||
|
"Audience": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -164,7 +164,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
x.ExecutorEmail == executorEmail &&
|
x.ExecutorEmail == executorEmail &&
|
||||||
x.Position == position &&
|
x.Position == position &&
|
||||||
x.ProjectRole == projectRole &&
|
x.ProjectRole == projectRole &&
|
||||||
x.HeadType == headType &&
|
x. HeadType == headType &&
|
||||||
x.Status == status,
|
x.Status == status,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -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: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
lib/
|
||||||
|
lib-commonjs/
|
||||||
|
dist/
|
||||||
|
temp/
|
||||||
|
release/
|
||||||
|
jest-output/
|
||||||
|
*.sppkg
|
||||||
|
.heft/
|
||||||
|
sharepoint/solution/
|
||||||
|
.idea/
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"@microsoft/generator-sharepoint": {
|
||||||
|
"useGulp": false,
|
||||||
|
"plusBeta": false,
|
||||||
|
"isCreatingSolution": true,
|
||||||
|
"nodeVersion": "22.23.1",
|
||||||
|
"sdksVersions": {},
|
||||||
|
"version": "1.22.1",
|
||||||
|
"libraryName": "approval-route-admin",
|
||||||
|
"libraryId": "ce823ddf-e35c-460f-a92d-ddb4ef5fc494",
|
||||||
|
"environment": "spo",
|
||||||
|
"packageManager": "npm",
|
||||||
|
"solutionName": "approval-route-admin",
|
||||||
|
"solutionShortDescription": "approval-route-admin description",
|
||||||
|
"skipFeatureDeployment": true,
|
||||||
|
"isDomainIsolated": false,
|
||||||
|
"componentType": "webpart"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# approval-route-admin
|
||||||
|
|
||||||
|
## 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,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
|
||||||
|
"version": "2.0",
|
||||||
|
"bundles": {
|
||||||
|
"approval-admin-web-part": {
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"entrypoint": "./lib/webparts/approvalAdmin/ApprovalAdminWebPart.js",
|
||||||
|
"manifest": "./src/webparts/approvalAdmin/ApprovalAdminWebPart.manifest.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"externals": {},
|
||||||
|
"localizedResources": {
|
||||||
|
"ApprovalAdminWebPartStrings": "lib/webparts/approvalAdmin/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": "approval-route-admin",
|
||||||
|
"accessKey": "<!-- ACCESS KEY -->"
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
|
||||||
|
"solution": {
|
||||||
|
"name": "approval-route-admin-client-side-solution",
|
||||||
|
"id": "ce823ddf-e35c-460f-a92d-ddb4ef5fc494",
|
||||||
|
"version": "1.0.0.0",
|
||||||
|
"includeClientSideAssets": true,
|
||||||
|
"skipFeatureDeployment": true,
|
||||||
|
"isDomainIsolated": false,
|
||||||
|
"developer": {
|
||||||
|
"name": "",
|
||||||
|
"websiteUrl": "",
|
||||||
|
"privacyUrl": "",
|
||||||
|
"termsOfUseUrl": "",
|
||||||
|
"mpnId": "Undefined-1.22.1"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"shortDescription": {
|
||||||
|
"default": "approval-route-admin description"
|
||||||
|
},
|
||||||
|
"longDescription": {
|
||||||
|
"default": "approval-route-admin description"
|
||||||
|
},
|
||||||
|
"screenshotPaths": [],
|
||||||
|
"videoUrl": "",
|
||||||
|
"categories": []
|
||||||
|
},
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"title": "approval-route-admin Feature",
|
||||||
|
"description": "The feature that activates elements of the approval-route-admin solution.",
|
||||||
|
"id": "be919a96-acbc-43a8-9a43-2dba926e6cd2",
|
||||||
|
"version": "1.0.0.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"webApiPermissionRequests": [
|
||||||
|
{
|
||||||
|
"resource": "api://9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
|
"scope": "user_impersonation"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"zippedPackage": "solution/approval-route-admin.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 -->"
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
# =============================================================
|
||||||
|
# SPFx + Heft project bootstrap script (PowerShell)
|
||||||
|
# Запустите из корня вашей ПУСТОЙ папки проекта:
|
||||||
|
# .\setup-spfx-heft.ps1
|
||||||
|
#
|
||||||
|
# Если запуск скриптов заблокирован, выполните сначала:
|
||||||
|
# Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Версия SPFx. Генератор ОБЯЗАН совпадать с версией пакетов ниже:
|
||||||
|
# старый генератор создаёт проект на gulp + node-sass, который не собирается
|
||||||
|
# на современных Node (node-gyp 3.8 требует Python 2).
|
||||||
|
$SpfxVersion = "1.22.1"
|
||||||
|
|
||||||
|
# ---- Запись файлов ----
|
||||||
|
# ВАЖНО: "Set-Content -Encoding UTF8" в Windows PowerShell 5.1 пишет UTF-8 С BOM.
|
||||||
|
# Heft, tsc и node на BOM в .json/.js спотыкаются:
|
||||||
|
# Unexpected token '<U+FEFF>', "<U+FEFF>{ ..." is not valid JSON
|
||||||
|
# Поэтому все конфиги пишем строго UTF-8 без BOM.
|
||||||
|
function Write-Utf8NoBom {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Path,
|
||||||
|
[Parameter(Mandatory = $true)][string]$Content
|
||||||
|
)
|
||||||
|
$full = [System.IO.Path]::Combine((Get-Location).ProviderPath, $Path)
|
||||||
|
$dir = [System.IO.Path]::GetDirectoryName($full)
|
||||||
|
if ($dir -and -not (Test-Path $dir)) {
|
||||||
|
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||||
|
}
|
||||||
|
[System.IO.File]::WriteAllText($full, ($Content -replace "`r?`n", "`r`n"), (New-Object System.Text.UTF8Encoding($false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Цвета вывода ----
|
||||||
|
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 --version).Trim()
|
||||||
|
} catch {
|
||||||
|
Warn "Node.js не найден. Установите Node 18 LTS: https://nodejs.org"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$nodeMajor = [int]($nodeVersion.TrimStart('v').Split('.')[0])
|
||||||
|
# SPFx 1.22 объявляет engines: node >=22.14.0 <23.0.0 — на других мажорах
|
||||||
|
# генератор и rig просто не установятся.
|
||||||
|
if ($nodeMajor -ne 22) {
|
||||||
|
Warn "Обнаружен Node.js $nodeVersion. SPFx $SpfxVersion требует Node 22.x (>=22.14.0 <23)."
|
||||||
|
Warn "Переключитесь через nvm-windows: nvm install 22 && nvm use 22"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Success "Node.js $nodeVersion — подходит."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 1a. Папка должна быть пустой
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
$leftovers = @("package.json", ".yo-rc.json", "node_modules", "gulpfile.js", "tslint.json") |
|
||||||
|
Where-Object { Test-Path $_ }
|
||||||
|
|
||||||
|
if ($leftovers.Count -gt 0) {
|
||||||
|
Warn "В текущей папке уже есть следы предыдущей генерации: $($leftovers -join ', ')"
|
||||||
|
Warn "Скрипт рассчитан на ПУСТУЮ папку — иначе Yeoman смешает старый и новый проект."
|
||||||
|
Warn "Уберите служебные файлы и запустите заново:"
|
||||||
|
Warn " Remove-Item -Recurse -Force node_modules, config, package.json, package-lock.json, .yo-rc.json, gulpfile.js, tslint.json, tsconfig.json -ErrorAction SilentlyContinue"
|
||||||
|
Warn "ВНИМАНИЕ: src\ и teams\ здесь НЕ перечислены намеренно — в src может лежать ваш код."
|
||||||
|
Warn "Проверьте их содержимое вручную перед удалением."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 2. Вспомогательная функция: установка глобального пакета
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# Проверяем именно ПАКЕТ через "npm ls -g", а не наличие команды:
|
||||||
|
# у @microsoft/generator-sharepoint своего бинарника нет, и проверка по "yo"
|
||||||
|
# считала его установленным, оставляя старый генератор (1.11.0), который
|
||||||
|
# генерирует проект на @microsoft/sp-build-web + node-sass → node-gyp 3 → python2.
|
||||||
|
function Get-GlobalPackageVersion {
|
||||||
|
param([string]$Package)
|
||||||
|
|
||||||
|
# Локально глушим Stop: "npm ls" отдаёт код 1 и пишет в stderr, когда пакета нет,
|
||||||
|
# а в Windows PowerShell 5.1 stderr нативного exe при Stop роняет скрипт.
|
||||||
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
|
|
||||||
|
$json = npm ls -g $Package --depth=0 --json 2>$null | Out-String
|
||||||
|
if ([string]::IsNullOrWhiteSpace($json)) { return $null }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$parsed = $json | ConvertFrom-Json
|
||||||
|
} catch {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$dep = $parsed.dependencies.$Package
|
||||||
|
if ($null -eq $dep -or [string]::IsNullOrWhiteSpace($dep.version)) { return $null }
|
||||||
|
return $dep.version
|
||||||
|
}
|
||||||
|
|
||||||
|
function Install-GlobalPackage {
|
||||||
|
param(
|
||||||
|
[string]$Package,
|
||||||
|
[string]$Version = "" # пустая строка = любая установленная версия подходит
|
||||||
|
)
|
||||||
|
|
||||||
|
$installed = Get-GlobalPackageVersion $Package
|
||||||
|
|
||||||
|
if ($Version -ne "") {
|
||||||
|
if ($installed -eq $Version) {
|
||||||
|
Success "$Package@$Version уже установлен."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ($installed) {
|
||||||
|
Warn "$Package установлен версии $installed, требуется $Version — переустанавливаем."
|
||||||
|
} else {
|
||||||
|
Info "Устанавливаем $Package@$Version глобально..."
|
||||||
|
}
|
||||||
|
npm install -g "$Package@$Version"
|
||||||
|
} else {
|
||||||
|
if ($installed) {
|
||||||
|
Success "$Package уже установлен (версия $installed)."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Info "Устанавливаем $Package глобально..."
|
||||||
|
npm install -g $Package
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Warn "Не удалось установить $Package. Прерываем."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Success "$Package установлен."
|
||||||
|
}
|
||||||
|
|
||||||
|
Install-GlobalPackage "yo"
|
||||||
|
Install-GlobalPackage "@microsoft/generator-sharepoint" $SpfxVersion
|
||||||
|
Install-GlobalPackage "@rushstack/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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
|
||||||
|
Write-Utf8NoBom "package.json" $packageJson
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
# --skip-install: не даём генератору поставить свой gulp-стек до того,
|
||||||
|
# как мы его выпилим. Иначе npm полезет собирать node-sass.
|
||||||
|
yo "@microsoft/sharepoint" --skip-install
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Warn "Генератор Yeoman завершился с ошибкой. Прерываем."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Контроль: старый генератор мог протащить node-sass (нативная сборка,
|
||||||
|
# node-gyp 3.8, требует Python 2 — на текущих Node не собирается вообще).
|
||||||
|
if ((Test-Path "package.json") -and (Select-String -Path "package.json" -Pattern '"node-sass"' -Quiet)) {
|
||||||
|
Warn "В package.json попал node-sass — значит, отработал устаревший генератор SPFx."
|
||||||
|
Warn "Проверьте: npm ls -g @microsoft/generator-sharepoint (нужна $SpfxVersion)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Info "Убираем gulp-стек, оставшийся от генератора..."
|
||||||
|
# Одним вызовом: каждый npm uninstall тянет за собой полный install остального.
|
||||||
|
npm uninstall @microsoft/sp-build-web ajv gulp node-sass @microsoft/rush-stack-compiler-5.3
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 6. Установка Heft и Rush Stack зависимостей
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
Info "Устанавливаем Heft и плагины..."
|
||||||
|
|
||||||
|
$devDeps = @(
|
||||||
|
"@microsoft/spfx-web-build-rig@$SpfxVersion",
|
||||||
|
"@microsoft/spfx-heft-plugins@$SpfxVersion",
|
||||||
|
"@microsoft/eslint-config-spfx@$SpfxVersion",
|
||||||
|
"@microsoft/eslint-plugin-spfx@$SpfxVersion",
|
||||||
|
"@microsoft/sp-module-interfaces@$SpfxVersion",
|
||||||
|
"@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
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Warn "npm install завершился с ошибкой (код $LASTEXITCODE). Прерываем, чтобы не создавать конфиги поверх битого дерева."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
npm install typescript@~5.8.0 --save-dev
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Warn "Не удалось установить typescript. Прерываем."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
|
||||||
|
Write-Utf8NoBom "config\rig.json" $rigJson
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
|
||||||
|
Write-Utf8NoBom "config\sass.json" $sassJson
|
||||||
|
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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
|
||||||
|
Write-Utf8NoBom "config\typescript.json" $typescriptJson
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
|
||||||
|
Write-Utf8NoBom "tsconfig.json" $tsConfig
|
||||||
|
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 }
|
||||||
|
};
|
||||||
|
'@
|
||||||
|
|
||||||
|
Write-Utf8NoBom ".eslintrc.js" $eslintConfig
|
||||||
|
Success ".eslintrc.js создан."
|
||||||
|
} else {
|
||||||
|
Success ".eslintrc.js уже существует, пропускаем."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 10. .gitignore
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
Info "Создаём .gitignore..."
|
||||||
|
|
||||||
|
$gitignore = @'
|
||||||
|
node_modules/
|
||||||
|
lib/
|
||||||
|
dist/
|
||||||
|
temp/
|
||||||
|
*.sppkg
|
||||||
|
.heft/
|
||||||
|
sharepoint/solution/
|
||||||
|
'@
|
||||||
|
|
||||||
|
Write-Utf8NoBom ".gitignore" $gitignore
|
||||||
|
Success ".gitignore создан."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 11. Финальный npm install
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
$devDeps = @(
|
||||||
|
"@microsoft/sp-component-base@$SpfxVersion",
|
||||||
|
"@microsoft/sp-core-library@$SpfxVersion",
|
||||||
|
"@microsoft/sp-lodash-subset@$SpfxVersion",
|
||||||
|
"@microsoft/sp-office-ui-fabric-core@$SpfxVersion",
|
||||||
|
"@microsoft/sp-property-pane@$SpfxVersion",
|
||||||
|
"@microsoft/sp-webpart-base@$SpfxVersion"
|
||||||
|
)
|
||||||
|
|
||||||
|
Info "Финальный npm install..."
|
||||||
|
npm install @($devDeps) --save-exact --legacy-peer-deps
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Warn "Финальный npm install завершился с ошибкой (код $LASTEXITCODE)."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
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
+20154
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "approval-route-admin",
|
||||||
|
"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-http": "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",
|
||||||
|
"react": "17.0.1",
|
||||||
|
"react-dom": "17.0.1",
|
||||||
|
"tslib": "2.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@microsoft/eslint-config-spfx": "1.22.1",
|
||||||
|
"@microsoft/eslint-plugin-spfx": "1.22.1",
|
||||||
|
"@microsoft/sp-module-interfaces": "1.22.1",
|
||||||
|
"@microsoft/spfx-heft-plugins": "1.22.1",
|
||||||
|
"@microsoft/spfx-web-build-rig": "1.22.1",
|
||||||
|
"@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,153 @@
|
|||||||
|
// ============================================
|
||||||
|
// Tansu Construction — Design Tokens
|
||||||
|
// Дизайн-система V5: Executive Premium
|
||||||
|
// Общий файл для всех SPFx-проектов Tnsu.
|
||||||
|
// Импорт в .module.scss: @import '../../../../styles/design-tokens';
|
||||||
|
// (глубина '../' зависит от расположения файла относительно src/)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// ---- 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;
|
||||||
|
|
||||||
|
// ---- 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;
|
||||||
|
|
||||||
|
// ---- CSS Custom Properties ----
|
||||||
|
:root {
|
||||||
|
--navy-950: #{$navy-950}; --navy-900: #{$navy-900}; --navy-800: #{$navy-800};
|
||||||
|
--navy-700: #{$navy-700}; --navy-600: #{$navy-600};
|
||||||
|
--navy-100: #{$navy-100}; --navy-50: #{$navy-50};
|
||||||
|
|
||||||
|
--gold-600: #{$gold-600}; --gold-500: #{$gold-500}; --gold-400: #{$gold-400};
|
||||||
|
--gold-300: #{$gold-300}; --gold-100: #{$gold-100}; --gold-50: #{$gold-50};
|
||||||
|
|
||||||
|
--gray-900: #{$gray-900}; --gray-700: #{$gray-700}; --gray-500: #{$gray-500};
|
||||||
|
--gray-400: #{$gray-400}; --gray-300: #{$gray-300}; --gray-200: #{$gray-200};
|
||||||
|
--gray-100: #{$gray-100}; --gray-50: #{$gray-50};
|
||||||
|
|
||||||
|
--red-500: #{$red-500}; --red-50: #{$red-50};
|
||||||
|
--green-600: #{$green-600}; --green-50: #{$green-50};
|
||||||
|
--blue-600: #{$blue-600}; --blue-50: #{$blue-50};
|
||||||
|
--amber-700: #{$amber-700};
|
||||||
|
|
||||||
|
--sh-1: #{$sh-1}; --sh-2: #{$sh-2}; --sh-3: #{$sh-3};
|
||||||
|
--sh-gold: #{$sh-gold}; --sh-modal: #{$sh-modal};
|
||||||
|
|
||||||
|
--r-sm: #{$r-sm}; --r-md: #{$r-md}; --r-lg: #{$r-lg};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Готовые миксины ----
|
||||||
|
@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,32 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
|
||||||
|
"id": "4825e72c-4ff3-479e-917e-f6b04e003a82",
|
||||||
|
"alias": "ApprovalAdminWebPart",
|
||||||
|
"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,
|
||||||
|
|
||||||
|
"preconfiguredEntries": [{
|
||||||
|
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Advanced
|
||||||
|
"group": { "default": "Advanced" },
|
||||||
|
"title": { "default": "approval-admin" },
|
||||||
|
"description": { "default": "approval-admin description" },
|
||||||
|
"officeFabricIconFontName": "Page",
|
||||||
|
"properties": {
|
||||||
|
"apiBaseUrl": "http://localhost:5080",
|
||||||
|
"useDevAuth": true,
|
||||||
|
"devUserEmail": "dev@tnsu.kz",
|
||||||
|
"aadResourceId": "api://9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
|
"portalBaseUrl": "https://api.tnsu.kz"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as ReactDom from 'react-dom';
|
||||||
|
import { Version } from '@microsoft/sp-core-library';
|
||||||
|
import {
|
||||||
|
type IPropertyPaneConfiguration,
|
||||||
|
PropertyPaneTextField,
|
||||||
|
PropertyPaneToggle
|
||||||
|
} from '@microsoft/sp-property-pane';
|
||||||
|
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
||||||
|
|
||||||
|
import ApprovalAdmin from './components/ApprovalAdmin';
|
||||||
|
import { IApprovalAdminProps } from './components/IApprovalAdminProps';
|
||||||
|
|
||||||
|
export interface IApprovalAdminWebPartProps {
|
||||||
|
apiBaseUrl: string;
|
||||||
|
useDevAuth: boolean;
|
||||||
|
devUserEmail: string;
|
||||||
|
aadResourceId: string;
|
||||||
|
portalBaseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprovalAdminWebPartProps> {
|
||||||
|
|
||||||
|
public render(): void {
|
||||||
|
const element: React.ReactElement<IApprovalAdminProps> = React.createElement(ApprovalAdmin, {
|
||||||
|
context: this.context,
|
||||||
|
apiBaseUrl: this.properties.apiBaseUrl,
|
||||||
|
useDevAuth: this.properties.useDevAuth,
|
||||||
|
devUserEmail: this.properties.devUserEmail,
|
||||||
|
aadResourceId: this.properties.aadResourceId,
|
||||||
|
portalBaseUrl: this.properties.portalBaseUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDom.render(element, this.domElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onInit(): Promise<void> {
|
||||||
|
this.properties.apiBaseUrl = this.properties.apiBaseUrl || 'http://localhost:5080';
|
||||||
|
this.properties.useDevAuth = this.properties.useDevAuth ?? true;
|
||||||
|
this.properties.devUserEmail = this.properties.devUserEmail || 'dev@tnsu.kz';
|
||||||
|
this.properties.aadResourceId = this.properties.aadResourceId || 'api://9232d9e2-9807-4753-808f-07250e5474b0';
|
||||||
|
this.properties.portalBaseUrl = this.properties.portalBaseUrl || 'https://api.tnsu.kz';
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDispose(): void {
|
||||||
|
ReactDom.unmountComponentAtNode(this.domElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get dataVersion(): Version {
|
||||||
|
return Version.parse('1.0');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
|
||||||
|
return {
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
header: {
|
||||||
|
description: 'Подключение к Tnsu.ApprovalRoute.Api'
|
||||||
|
},
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
groupName: 'API',
|
||||||
|
groupFields: [
|
||||||
|
PropertyPaneTextField('apiBaseUrl', {
|
||||||
|
label: 'Api Base Url',
|
||||||
|
description: 'Например, https://localhost:5443 локально или прод-адрес API.'
|
||||||
|
}),
|
||||||
|
PropertyPaneToggle('useDevAuth', {
|
||||||
|
label: 'Локальный Dev-режим (без Azure AD)',
|
||||||
|
onText: 'Вкл. (заголовок X-Dev-User-Email)',
|
||||||
|
offText: 'Выкл. (AAD-токен через SPFx)'
|
||||||
|
}),
|
||||||
|
PropertyPaneTextField('devUserEmail', {
|
||||||
|
label: 'Dev: email пользователя',
|
||||||
|
description: 'Используется только при включённом Dev-режиме.'
|
||||||
|
}),
|
||||||
|
PropertyPaneTextField('aadResourceId', {
|
||||||
|
label: 'AAD Resource Id (App ID URI)',
|
||||||
|
description: 'Должен совпадать с webApiPermissionRequests в package-solution.json. Этим же токеном берутся справочники api.tnsu.kz.'
|
||||||
|
}),
|
||||||
|
PropertyPaneTextField('portalBaseUrl', {
|
||||||
|
label: 'Портал справочников (api.tnsu.kz)',
|
||||||
|
description: 'Откуда берутся списки компаний и отделов. Ходим напрямую, тем же AAD-токеном.'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
@import '../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.approvalAdmin {
|
||||||
|
font-family: $font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stateBox {
|
||||||
|
padding: 40px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: 13.5px;
|
||||||
|
background: $gray-50;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './ApprovalAdmin.module.scss';
|
||||||
|
import type { IApprovalAdminProps } from './IApprovalAdminProps';
|
||||||
|
import { AdminApp } from './app/AdminApp';
|
||||||
|
import { ApprovalRouteApi } from '../services/api';
|
||||||
|
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
|
||||||
|
import { PortalDictionaryClient } from '../services/portalDictionary';
|
||||||
|
|
||||||
|
const FONTS_LINK_ID = 'tnsu-approval-admin-fonts';
|
||||||
|
|
||||||
|
function ensureFontsLoaded(): void {
|
||||||
|
if (typeof document === 'undefined' || document.getElementById(FONTS_LINK_ID)) return;
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.id = FONTS_LINK_ID;
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lora:wght@500;600;700&display=swap';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createHttpClient(props: IApprovalAdminProps): Promise<IHttpClient> {
|
||||||
|
if (props.useDevAuth) {
|
||||||
|
return new DevApiHttpClient(props.apiBaseUrl, props.devUserEmail || 'dev@tnsu.kz');
|
||||||
|
}
|
||||||
|
const aadClient = await props.context.aadHttpClientFactory.getClient(props.aadResourceId);
|
||||||
|
return new AadApiHttpClient(aadClient, props.apiBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
|
||||||
|
const [api, setApi] = React.useState<ApprovalRouteApi | undefined>();
|
||||||
|
const [dictionary, setDictionary] = React.useState<PortalDictionaryClient | undefined>();
|
||||||
|
const [error, setError] = React.useState<string | undefined>();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
ensureFontsLoaded();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setApi(undefined);
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
createHttpClient(props)
|
||||||
|
.then(client => { if (!cancelled) setApi(new ApprovalRouteApi(client)); })
|
||||||
|
.catch((e: Error) => { if (!cancelled) setError(e.message); });
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [props.useDevAuth, props.apiBaseUrl, props.devUserEmail, props.aadResourceId]);
|
||||||
|
|
||||||
|
// Справочники всегда идут через AAD, независимо от Dev-режима нашего API:
|
||||||
|
// api.tnsu.kz принимает только настоящий токен, заголовок X-Dev-User-Email
|
||||||
|
// ему ничего не говорит.
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setDictionary(undefined);
|
||||||
|
|
||||||
|
props.context.aadHttpClientFactory
|
||||||
|
.getClient(props.aadResourceId)
|
||||||
|
.then(client => {
|
||||||
|
if (!cancelled) setDictionary(new PortalDictionaryClient(client, props.portalBaseUrl));
|
||||||
|
})
|
||||||
|
.catch(() => { /* экран маршрутов покажет ошибку загрузки справочника */ });
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [props.aadResourceId, props.portalBaseUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.approvalAdmin}>
|
||||||
|
{error && (
|
||||||
|
<div className={styles.stateBox}>
|
||||||
|
Не удалось подключиться к API: {error}
|
||||||
|
<br />
|
||||||
|
Проверьте свойства веб-части (Api Base Url, режим авторизации) в панели настроек.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!error && !api && <div className={styles.stateBox}>Подключение к API...</div>}
|
||||||
|
{api && (
|
||||||
|
<AdminApp
|
||||||
|
api={api}
|
||||||
|
dictionary={dictionary}
|
||||||
|
currentUserEmail={props.context.pageContext.user.email}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApprovalAdmin;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
|
||||||
|
export interface IApprovalAdminProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
apiBaseUrl: string;
|
||||||
|
useDevAuth: boolean;
|
||||||
|
devUserEmail: string;
|
||||||
|
aadResourceId: string;
|
||||||
|
portalBaseUrl: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
background: linear-gradient(180deg, #F6F8FA 0%, #EEF2F6 100%);
|
||||||
|
border-radius: $r-lg;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: $sh-1;
|
||||||
|
color: $gray-900;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-body;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 28px 30px 36px;
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.centerState {
|
||||||
|
padding: 60px 30px;
|
||||||
|
text-align: center;
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './AdminApp.module.scss';
|
||||||
|
import { TopNav } from './TopNav';
|
||||||
|
import { ScreenKey } from './types';
|
||||||
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
|
import { TemplateRoute } from '../../models';
|
||||||
|
import { DashboardScreen } from '../screens/DashboardScreen';
|
||||||
|
import { RequestTypesScreen } from '../screens/RequestTypesScreen';
|
||||||
|
import { RoutesScreen } from '../screens/RoutesScreen';
|
||||||
|
import { RouteStepsScreen } from '../screens/RouteStepsScreen';
|
||||||
|
import { ResolveScreen } from '../screens/ResolveScreen';
|
||||||
|
|
||||||
|
export interface IAdminAppProps {
|
||||||
|
api: ApprovalRouteApi;
|
||||||
|
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||||
|
dictionary?: PortalDictionaryClient;
|
||||||
|
currentUserEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUserEmail }) => {
|
||||||
|
const [screen, setScreen] = React.useState<ScreenKey>('dashboard');
|
||||||
|
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
|
||||||
|
const [selectedRequestTypeName, setSelectedRequestTypeName] = React.useState('');
|
||||||
|
|
||||||
|
const navigate = (key: ScreenKey): void => {
|
||||||
|
setScreen(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSteps = (route: TemplateRoute): void => {
|
||||||
|
setSelectedRoute(route);
|
||||||
|
api.requestTypes.get(route.requestTypeId)
|
||||||
|
.then(rt => setSelectedRequestTypeName(rt.name))
|
||||||
|
.catch(() => setSelectedRequestTypeName(`#${route.requestTypeId}`));
|
||||||
|
setScreen('routeSteps');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.shell}>
|
||||||
|
<TopNav active={screen} onSelect={navigate} />
|
||||||
|
<div className={styles.content}>
|
||||||
|
{screen === 'dashboard' && <DashboardScreen api={api} onNavigate={navigate} />}
|
||||||
|
{screen === 'requestTypes' && <RequestTypesScreen api={api} />}
|
||||||
|
{screen === 'routes' && <RoutesScreen api={api} dictionary={dictionary} onOpenSteps={openSteps} />}
|
||||||
|
{screen === 'routeSteps' && selectedRoute && (
|
||||||
|
<RouteStepsScreen
|
||||||
|
api={api}
|
||||||
|
route={selectedRoute}
|
||||||
|
requestTypeName={selectedRequestTypeName}
|
||||||
|
onBack={() => setScreen('routes')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{screen === 'resolve' && <ResolveScreen api={api} currentUserEmail={currentUserEmail} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.topnav {
|
||||||
|
background: $grad-header;
|
||||||
|
padding: 0 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 54px;
|
||||||
|
box-shadow: 0 1px 0 rgba(212,160,30,.35);
|
||||||
|
border-radius: $r-md $r-md 0 0;
|
||||||
|
// Без переноса: высота шапки фиксирована (54px), поэтому перенесённая
|
||||||
|
// строка вкладок вылезала бы за её пределы вместе с подчёркиванием.
|
||||||
|
// Вместо переноса вкладки скроллятся по горизонтали.
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-right: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: $grad-gold-v;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $navy-950;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: $font-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandText {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: .4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandText em {
|
||||||
|
font-style: normal;
|
||||||
|
color: $gold-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
// min-width: 0 обязателен — иначе flex-элемент не сожмётся уже своего
|
||||||
|
// содержимого и overflow-x никогда не сработает.
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
// Делает контейнер offsetParent'ом для вкладок: автопрокрутка в TopNav.tsx
|
||||||
|
// считает offsetLeft относительно него. Без этого offsetLeft отсчитывался
|
||||||
|
// бы от body и прокрутка уезжала бы не туда.
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
// Полоса прокрутки на тёмной шапке выглядит инородно, скрываем её —
|
||||||
|
// вкладки остаются доступны свайпом/колесом.
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
&::-webkit-scrollbar { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: none;
|
||||||
|
background: 0;
|
||||||
|
color: rgba(255,255,255,.5);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: .18s;
|
||||||
|
height: 54px;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
|
||||||
|
&:hover { color: rgba(255,255,255,.85); }
|
||||||
|
&.on { color: $gold-300; border-bottom-color: $gold-400; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './TopNav.module.scss';
|
||||||
|
import { ScreenKey } from './types';
|
||||||
|
|
||||||
|
interface ITab {
|
||||||
|
key: ScreenKey;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS: ITab[] = [
|
||||||
|
{ key: 'dashboard', label: 'Дашборд' },
|
||||||
|
{ key: 'requestTypes', label: 'Типы заявок' },
|
||||||
|
{ key: 'routes', label: 'Маршруты' },
|
||||||
|
{ key: 'resolve', label: 'Проверка маршрута' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface ITopNavProps {
|
||||||
|
active: ScreenKey;
|
||||||
|
onSelect: (key: ScreenKey) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTabActive(active: ScreenKey, tabKey: ScreenKey): boolean {
|
||||||
|
// Экран шагов маршрута — вложенный, подсвечиваем вкладку «Маршруты».
|
||||||
|
return active === tabKey || (active === 'routeSteps' && tabKey === 'routes');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TopNav: React.FC<ITopNavProps> = ({ active, onSelect }) => {
|
||||||
|
const tabsRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const activeTabRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
// Вкладки скроллятся по горизонтали, поэтому активная может оказаться за
|
||||||
|
// краем. Подкручиваем контейнер вручную (не scrollIntoView — тот способен
|
||||||
|
// утащить за собой прокрутку всей страницы SharePoint).
|
||||||
|
React.useEffect(() => {
|
||||||
|
const reveal = (): void => {
|
||||||
|
const container = tabsRef.current;
|
||||||
|
const tab = activeTabRef.current;
|
||||||
|
if (!container || !tab) return;
|
||||||
|
|
||||||
|
const left = tab.offsetLeft;
|
||||||
|
const width = tab.offsetWidth;
|
||||||
|
|
||||||
|
if (width > container.clientWidth) {
|
||||||
|
// Вкладка шире видимой полосы — показываем её начало, чтобы подпись
|
||||||
|
// читалась с первого слова.
|
||||||
|
container.scrollLeft = left;
|
||||||
|
} else if (left < container.scrollLeft) {
|
||||||
|
container.scrollLeft = left;
|
||||||
|
} else if (left + width > container.scrollLeft + container.clientWidth) {
|
||||||
|
container.scrollLeft = left + width - container.clientWidth;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reveal();
|
||||||
|
|
||||||
|
// Шрифты (Lora/Inter) подключаются рантаймом: пока они не применились,
|
||||||
|
// ширины вкладок другие и позиция прокрутки получается устаревшей.
|
||||||
|
const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;
|
||||||
|
let cancelled = false;
|
||||||
|
if (fonts?.ready) {
|
||||||
|
fonts.ready.then(() => { if (!cancelled) reveal(); }).catch(() => { /* не критично */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', reveal);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.removeEventListener('resize', reveal);
|
||||||
|
};
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className={styles.topnav}>
|
||||||
|
<div className={styles.brand}>
|
||||||
|
<div className={styles.mark}>T</div>
|
||||||
|
<div className={styles.brandText}>TNSU <em>Approval Route</em></div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.tabs} ref={tabsRef}>
|
||||||
|
{TABS.map(tab => {
|
||||||
|
const on = isTabActive(active, tab.key);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
ref={on ? activeTabRef : undefined}
|
||||||
|
className={`${styles.tab} ${on ? styles.on : ''}`}
|
||||||
|
onClick={() => onSelect(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type ScreenKey = 'dashboard' | 'requestTypes' | 'routes' | 'routeSteps' | 'resolve';
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
|
import { ScreenKey } from '../app/types';
|
||||||
|
import { CardGrid, NavCard, StatCard, StatRow } from '../ui';
|
||||||
|
|
||||||
|
export interface IDashboardScreenProps {
|
||||||
|
api: ApprovalRouteApi;
|
||||||
|
onNavigate: (key: ScreenKey) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardScreen: React.FC<IDashboardScreenProps> = ({ api, onNavigate }) => {
|
||||||
|
const [requestTypesCount, setRequestTypesCount] = React.useState<number | undefined>();
|
||||||
|
const [routesCount, setRoutesCount] = React.useState<number | undefined>();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
api.requestTypes.list().then(items => setRequestTypesCount(items.length)).catch(() => setRequestTypesCount(undefined));
|
||||||
|
api.templateRoutes.list().then(items => setRoutesCount(items.length)).catch(() => setRoutesCount(undefined));
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<StatRow>
|
||||||
|
<StatCard label="Типов заявок" value={requestTypesCount ?? '—'} />
|
||||||
|
<StatCard label="Маршрутов согласования" value={routesCount ?? '—'} />
|
||||||
|
</StatRow>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 1.6, color: 'var(--gray-400)', fontWeight: 600, marginBottom: 12 }}>
|
||||||
|
Разделы
|
||||||
|
</div>
|
||||||
|
<hr style={{ height: 1, background: 'linear-gradient(90deg, var(--gold-400), transparent 70%)', border: 0, margin: '6px 0 22px' }} />
|
||||||
|
<CardGrid>
|
||||||
|
<NavCard
|
||||||
|
icon="clipboard"
|
||||||
|
title="Типы заявок"
|
||||||
|
description="Справочник типов заявок и их страниц"
|
||||||
|
onClick={() => onNavigate('requestTypes')}
|
||||||
|
/>
|
||||||
|
<NavCard
|
||||||
|
icon="route"
|
||||||
|
title="Маршруты согласования"
|
||||||
|
description="Маршруты, шаги и исполнители"
|
||||||
|
onClick={() => onNavigate('routes')}
|
||||||
|
/>
|
||||||
|
<NavCard
|
||||||
|
icon="search"
|
||||||
|
title="Проверка маршрута"
|
||||||
|
description="Как разрешится маршрут для конкретного пользователя"
|
||||||
|
onClick={() => onNavigate('resolve')}
|
||||||
|
/>
|
||||||
|
</CardGrid>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
|
import { ApiClientError } from '../../services/httpClient';
|
||||||
|
import { RequestType } from '../../models';
|
||||||
|
import {
|
||||||
|
ActionsCell,
|
||||||
|
Alert,
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
DataTable,
|
||||||
|
Field,
|
||||||
|
FormGrid,
|
||||||
|
IconButton,
|
||||||
|
Modal,
|
||||||
|
ConfirmDialog,
|
||||||
|
PageHeader,
|
||||||
|
PrimaryCell,
|
||||||
|
TextInput
|
||||||
|
} from '../ui';
|
||||||
|
|
||||||
|
export interface IRequestTypesScreenProps {
|
||||||
|
api: ApprovalRouteApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IFormState {
|
||||||
|
name: string;
|
||||||
|
pageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: IFormState = { name: '', pageUrl: '' };
|
||||||
|
|
||||||
|
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api }) => {
|
||||||
|
const [items, setItems] = React.useState<RequestType[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [loadError, setLoadError] = React.useState<string | undefined>();
|
||||||
|
|
||||||
|
const [editing, setEditing] = React.useState<RequestType | undefined>();
|
||||||
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
|
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
|
||||||
|
const [formError, setFormError] = React.useState<string | undefined>();
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
|
||||||
|
const [deleting, setDeleting] = React.useState<RequestType | undefined>();
|
||||||
|
const [deleteError, setDeleteError] = React.useState<string | undefined>();
|
||||||
|
const [deleteBusy, setDeleteBusy] = React.useState(false);
|
||||||
|
|
||||||
|
const load = React.useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setLoadError(undefined);
|
||||||
|
api.requestTypes
|
||||||
|
.list()
|
||||||
|
.then(setItems)
|
||||||
|
.catch((e: ApiClientError) => setLoadError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
React.useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const openCreate = (): void => {
|
||||||
|
setEditing(undefined);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setFormError(undefined);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (item: RequestType): void => {
|
||||||
|
setEditing(item);
|
||||||
|
setForm({ name: item.name, pageUrl: item.pageUrl });
|
||||||
|
setFormError(undefined);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
if (!form.name.trim() || !form.pageUrl.trim()) {
|
||||||
|
setFormError('Заполните имя и Url страницы.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setFormError(undefined);
|
||||||
|
const request = editing
|
||||||
|
? api.requestTypes.update(editing.id, { name: form.name.trim(), pageUrl: form.pageUrl.trim() })
|
||||||
|
: api.requestTypes.create({ name: form.name.trim(), pageUrl: form.pageUrl.trim() });
|
||||||
|
|
||||||
|
request
|
||||||
|
.then(() => {
|
||||||
|
setShowForm(false);
|
||||||
|
load();
|
||||||
|
})
|
||||||
|
.catch((e: ApiClientError) => setFormError(e.message))
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (): void => {
|
||||||
|
if (!deleting) return;
|
||||||
|
setDeleteBusy(true);
|
||||||
|
setDeleteError(undefined);
|
||||||
|
api.requestTypes
|
||||||
|
.remove(deleting.id)
|
||||||
|
.then(() => {
|
||||||
|
setDeleting(undefined);
|
||||||
|
load();
|
||||||
|
})
|
||||||
|
.catch((e: ApiClientError) => setDeleteError(e.message))
|
||||||
|
.finally(() => setDeleteBusy(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumbs items={[{ label: 'Главная' }, { label: 'Типы заявок' }]} />
|
||||||
|
<PageHeader
|
||||||
|
icon="clipboard"
|
||||||
|
subtitle="Справочник"
|
||||||
|
title="Типы заявок"
|
||||||
|
actions={
|
||||||
|
<Button variant="accent" icon="plus" onClick={openCreate}>Добавить тип</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loadError && <Alert kind="err">{loadError}</Alert>}
|
||||||
|
{deleteError && <Alert kind="err">{deleteError}</Alert>}
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{ key: 'name', header: 'Название', render: r => <PrimaryCell>{r.name}</PrimaryCell> },
|
||||||
|
{ key: 'url', header: 'Url страницы', render: r => r.pageUrl },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
width: '100px',
|
||||||
|
align: 'right',
|
||||||
|
sticky: true,
|
||||||
|
render: r => (
|
||||||
|
<ActionsCell>
|
||||||
|
<IconButton icon="edit" label="Редактировать" onClick={() => openEdit(r)} />
|
||||||
|
<IconButton icon="trash" label="Удалить" danger onClick={() => { setDeleteError(undefined); setDeleting(r); }} />
|
||||||
|
</ActionsCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
rows={items}
|
||||||
|
keyOf={r => r.id}
|
||||||
|
emptyMessage={loading ? 'Загрузка...' : 'Типов заявок пока нет.'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<Modal
|
||||||
|
title={editing ? 'Редактировать тип заявки' : 'Новый тип заявки'}
|
||||||
|
onClose={() => setShowForm(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={() => setShowForm(false)} disabled={saving}>Отмена</Button>
|
||||||
|
<Button variant="accent" icon="check" onClick={submit} disabled={saving}>
|
||||||
|
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formError && <Alert kind="err">{formError}</Alert>}
|
||||||
|
<FormGrid>
|
||||||
|
<Field label="Название" required full>
|
||||||
|
<TextInput
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="Например, Заявка на командировку"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Url страницы" required full hint="Страница формы заявки на портале.">
|
||||||
|
<TextInput
|
||||||
|
value={form.pageUrl}
|
||||||
|
onChange={e => setForm({ ...form, pageUrl: e.target.value })}
|
||||||
|
placeholder="/pages/business-trip"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FormGrid>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleting && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Удалить тип заявки?"
|
||||||
|
text={`Тип «${deleting.name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.`}
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
busy={deleteBusy}
|
||||||
|
onCancel={() => setDeleting(undefined)}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
|
import { ApiClientError } from '../../services/httpClient';
|
||||||
|
import {
|
||||||
|
RequestType,
|
||||||
|
ResolveTemplateRouteResult,
|
||||||
|
routeStepStatusLabels,
|
||||||
|
RouteStepStatus,
|
||||||
|
routeTypeLabels
|
||||||
|
} from '../../models';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
DataTable,
|
||||||
|
Field,
|
||||||
|
FormGrid,
|
||||||
|
PageHeader,
|
||||||
|
PrimaryCell,
|
||||||
|
Select,
|
||||||
|
TextInput
|
||||||
|
} from '../ui';
|
||||||
|
|
||||||
|
export interface IResolveScreenProps {
|
||||||
|
api: ApprovalRouteApi;
|
||||||
|
currentUserEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
||||||
|
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
|
||||||
|
|
||||||
|
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, currentUserEmail }) => {
|
||||||
|
const [requestTypes, setRequestTypes] = React.useState<RequestType[]>([]);
|
||||||
|
const [requestTypeId, setRequestTypeId] = React.useState('');
|
||||||
|
const [userEmail, setUserEmail] = React.useState('');
|
||||||
|
const [projectId, setProjectId] = React.useState('');
|
||||||
|
const [status, setStatus] = React.useState<RouteStepStatus>(RouteStepStatus.Approval);
|
||||||
|
|
||||||
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | undefined>();
|
||||||
|
const [result, setResult] = React.useState<ResolveTemplateRouteResult | undefined>();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
api.requestTypes.list().then(setRequestTypes).catch(() => { /* показывается при попытке проверки */ });
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const run = (): void => {
|
||||||
|
if (!requestTypeId) {
|
||||||
|
setError('Выберите тип заявки.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(undefined);
|
||||||
|
setResult(undefined);
|
||||||
|
api.templateRoutes
|
||||||
|
.resolve({
|
||||||
|
requestTypeId: Number(requestTypeId),
|
||||||
|
userEmail: userEmail.trim() || undefined,
|
||||||
|
projectId: projectId.trim() || undefined,
|
||||||
|
status
|
||||||
|
})
|
||||||
|
.then(setResult)
|
||||||
|
.catch((e: ApiClientError) => setError(e.message))
|
||||||
|
.finally(() => setBusy(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumbs items={[{ label: 'Главная' }, { label: 'Проверка маршрута' }]} />
|
||||||
|
<PageHeader
|
||||||
|
icon="search"
|
||||||
|
subtitle="Диагностика"
|
||||||
|
title="Проверка разрешения маршрута"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ background: '#fff', borderRadius: 14, padding: 26, boxShadow: 'var(--sh-1)', border: '1px solid var(--gray-200)', marginBottom: 24 }}>
|
||||||
|
<FormGrid>
|
||||||
|
<Field label="Тип заявки" required full>
|
||||||
|
<Select
|
||||||
|
options={requestTypes.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||||
|
placeholder="Выберите тип заявки"
|
||||||
|
value={requestTypeId}
|
||||||
|
onChange={e => setRequestTypeId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Email инициатора" hint={currentUserEmail ? `Пусто = текущий пользователь (${currentUserEmail}).` : 'Пусто = текущий пользователь из токена.'}>
|
||||||
|
<TextInput value={userEmail} onChange={e => setUserEmail(e.target.value)} placeholder="user@tnsu.kz" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Id проекта (Guid)" hint="Заполните, если маршрут зависит от проекта.">
|
||||||
|
<TextInput value={projectId} onChange={e => setProjectId(e.target.value)} placeholder="Необязательно" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Статус" required>
|
||||||
|
<Select options={STATUS_OPTIONS} value={status} onChange={e => setStatus(e.target.value as RouteStepStatus)} />
|
||||||
|
</Field>
|
||||||
|
</FormGrid>
|
||||||
|
<div style={{ marginTop: 18 }}>
|
||||||
|
<Button variant="accent" icon="search" onClick={run} disabled={busy}>
|
||||||
|
{busy ? 'Проверка...' : 'Проверить'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Alert kind="err">{error}</Alert>}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<>
|
||||||
|
<Alert kind="ok">
|
||||||
|
Маршрут #{result.routeId} · {routeTypeLabels[result.routeType]}
|
||||||
|
{result.requiresControl ? ' · требуется контроль исполнения' : ''}
|
||||||
|
</Alert>
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{ key: 'order', header: '№', width: '50px', render: r => r.orderNo },
|
||||||
|
{ key: 'executor', header: 'Исполнитель', render: r => <PrimaryCell>{r.executorEmail ?? '—'}</PrimaryCell> },
|
||||||
|
{ key: 'position', header: 'Должность', render: r => r.position ?? '—' },
|
||||||
|
{
|
||||||
|
key: 'reassign',
|
||||||
|
header: 'Переназначение',
|
||||||
|
align: 'center',
|
||||||
|
render: r => (r.allowReassignToOtherApprovers ? 'Разрешено' : 'Нет')
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
rows={result.steps}
|
||||||
|
keyOf={r => r.id}
|
||||||
|
emptyMessage="Маршрут разрешился без шагов для этого статуса."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
|
import { ApiClientError } from '../../services/httpClient';
|
||||||
|
import {
|
||||||
|
HeadType,
|
||||||
|
headTypeLabels,
|
||||||
|
ProjectRole,
|
||||||
|
projectRoleLabels,
|
||||||
|
RouteStepStatus,
|
||||||
|
routeStepStatusLabels,
|
||||||
|
routeTypeLabels,
|
||||||
|
StepExecutorKind,
|
||||||
|
TemplateRoute,
|
||||||
|
TemplateRouteStep,
|
||||||
|
detectExecutorKind
|
||||||
|
} from '../../models';
|
||||||
|
import {
|
||||||
|
ActionsCell,
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
ConfirmDialog,
|
||||||
|
DataTable,
|
||||||
|
Field,
|
||||||
|
FormGrid,
|
||||||
|
IconButton,
|
||||||
|
Modal,
|
||||||
|
PageHeader,
|
||||||
|
PrimaryCell,
|
||||||
|
RadioGroup,
|
||||||
|
Select,
|
||||||
|
TextInput
|
||||||
|
} from '../ui';
|
||||||
|
|
||||||
|
export interface IRouteStepsScreenProps {
|
||||||
|
api: ApprovalRouteApi;
|
||||||
|
route: TemplateRoute;
|
||||||
|
requestTypeName: string;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IFormState {
|
||||||
|
orderNo: string;
|
||||||
|
status: RouteStepStatus;
|
||||||
|
kind: StepExecutorKind;
|
||||||
|
executorEmail: string;
|
||||||
|
position: string;
|
||||||
|
projectRole: ProjectRole;
|
||||||
|
headType: HeadType;
|
||||||
|
allowReassignToOtherApprovers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyForm(nextOrderNo: number): IFormState {
|
||||||
|
return {
|
||||||
|
orderNo: String(nextOrderNo),
|
||||||
|
status: RouteStepStatus.Approval,
|
||||||
|
kind: 'email',
|
||||||
|
executorEmail: '',
|
||||||
|
position: '',
|
||||||
|
projectRole: ProjectRole.ProjectManager,
|
||||||
|
headType: HeadType.Chief,
|
||||||
|
allowReassignToOtherApprovers: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
||||||
|
.map(v => ({ value: v, label: routeStepStatusLabels[v] }));
|
||||||
|
|
||||||
|
const PROJECT_ROLE_OPTIONS = Object.values(ProjectRole)
|
||||||
|
.filter(v => v !== ProjectRole.None)
|
||||||
|
.map(v => ({ value: v, label: projectRoleLabels[v] }));
|
||||||
|
|
||||||
|
const HEAD_TYPE_OPTIONS = Object.values(HeadType)
|
||||||
|
.filter(v => v !== HeadType.None)
|
||||||
|
.map(v => ({ value: v, label: headTypeLabels[v] }));
|
||||||
|
|
||||||
|
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestTypeName, onBack }) => {
|
||||||
|
const [items, setItems] = React.useState<TemplateRouteStep[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [loadError, setLoadError] = React.useState<string | undefined>();
|
||||||
|
const [warning, setWarning] = React.useState<string | undefined>();
|
||||||
|
|
||||||
|
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
||||||
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
|
const [form, setForm] = React.useState<IFormState>(emptyForm(1));
|
||||||
|
const [formError, setFormError] = React.useState<string | undefined>();
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
|
||||||
|
const [deleting, setDeleting] = React.useState<TemplateRouteStep | undefined>();
|
||||||
|
const [deleteError, setDeleteError] = React.useState<string | undefined>();
|
||||||
|
const [deleteBusy, setDeleteBusy] = React.useState(false);
|
||||||
|
|
||||||
|
const load = React.useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setLoadError(undefined);
|
||||||
|
api.templateRouteSteps
|
||||||
|
.list(route.id)
|
||||||
|
.then(setItems)
|
||||||
|
.catch((e: ApiClientError) => setLoadError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [api, route.id]);
|
||||||
|
|
||||||
|
React.useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const openCreate = (): void => {
|
||||||
|
setEditing(undefined);
|
||||||
|
const nextOrder = items.length > 0 ? Math.max(...items.map(s => s.orderNo)) + 1 : 1;
|
||||||
|
setForm(emptyForm(nextOrder));
|
||||||
|
setFormError(undefined);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (item: TemplateRouteStep): void => {
|
||||||
|
setEditing(item);
|
||||||
|
setForm({
|
||||||
|
orderNo: String(item.orderNo),
|
||||||
|
status: item.status,
|
||||||
|
kind: detectExecutorKind(item),
|
||||||
|
executorEmail: item.executorEmail ?? '',
|
||||||
|
position: item.position ?? '',
|
||||||
|
projectRole: item.projectRole && item.projectRole !== ProjectRole.None ? item.projectRole : ProjectRole.ProjectManager,
|
||||||
|
headType: item.headType && item.headType !== HeadType.None ? item.headType : HeadType.Chief,
|
||||||
|
allowReassignToOtherApprovers: item.allowReassignToOtherApprovers
|
||||||
|
});
|
||||||
|
setFormError(undefined);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
const orderNo = Number(form.orderNo);
|
||||||
|
if (!orderNo || orderNo < 1) {
|
||||||
|
setFormError('Порядковый номер шага обязателен.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.kind === 'email' && !form.executorEmail.trim()) {
|
||||||
|
setFormError('Укажите email исполнителя.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.kind === 'position' && !form.position.trim()) {
|
||||||
|
setFormError('Укажите должность.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
orderNo,
|
||||||
|
status: form.status,
|
||||||
|
allowReassignToOtherApprovers: form.allowReassignToOtherApprovers,
|
||||||
|
executorEmail: form.kind === 'email' ? form.executorEmail.trim() : null,
|
||||||
|
position: form.kind === 'position' ? form.position.trim() : null,
|
||||||
|
projectRole: form.kind === 'projectRole' ? form.projectRole : null,
|
||||||
|
headType: form.kind === 'headType' ? form.headType : null
|
||||||
|
};
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setFormError(undefined);
|
||||||
|
const request = editing
|
||||||
|
? api.templateRouteSteps.update(route.id, editing.id, payload)
|
||||||
|
: api.templateRouteSteps.create(route.id, payload);
|
||||||
|
|
||||||
|
request
|
||||||
|
.then(step => {
|
||||||
|
setShowForm(false);
|
||||||
|
setWarning(step.warning ?? undefined);
|
||||||
|
load();
|
||||||
|
})
|
||||||
|
.catch((e: ApiClientError) => setFormError(e.message))
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (): void => {
|
||||||
|
if (!deleting) return;
|
||||||
|
setDeleteBusy(true);
|
||||||
|
setDeleteError(undefined);
|
||||||
|
api.templateRouteSteps
|
||||||
|
.remove(route.id, deleting.id)
|
||||||
|
.then(() => {
|
||||||
|
setDeleting(undefined);
|
||||||
|
load();
|
||||||
|
})
|
||||||
|
.catch((e: ApiClientError) => setDeleteError(e.message))
|
||||||
|
.finally(() => setDeleteBusy(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = [...items].sort((a, b) => a.orderNo - b.orderNo);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: 'Главная' },
|
||||||
|
{ label: 'Маршруты согласования', onClick: onBack },
|
||||||
|
{ label: `Шаги маршрута #${route.id}` }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<PageHeader
|
||||||
|
icon="flag-check"
|
||||||
|
subtitle={`${requestTypeName} · ${routeTypeLabels[route.routeType]}`}
|
||||||
|
title={`Маршрут #${route.id}`}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" icon="arrow-left" onClick={onBack}>К маршрутам</Button>
|
||||||
|
<Button variant="accent" icon="plus" onClick={openCreate}>Добавить шаг</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{warning && <Alert kind="warn">{warning}</Alert>}
|
||||||
|
{loadError && <Alert kind="err">{loadError}</Alert>}
|
||||||
|
{deleteError && <Alert kind="err">{deleteError}</Alert>}
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{ key: 'order', header: '№', width: '50px', render: r => r.orderNo },
|
||||||
|
{
|
||||||
|
key: 'executor',
|
||||||
|
header: 'Исполнитель',
|
||||||
|
render: r => <PrimaryCell>{describeExecutor(r)}</PrimaryCell>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Статус',
|
||||||
|
render: r => (
|
||||||
|
<Badge variant={r.status === RouteStepStatus.Approval ? 'gold' : 'blue'}>
|
||||||
|
{routeStepStatusLabels[r.status]}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reassign',
|
||||||
|
header: 'Переназначение',
|
||||||
|
align: 'center',
|
||||||
|
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">Разрешено</Badge> : <Badge variant="neutral">Нет</Badge>)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
width: '100px',
|
||||||
|
align: 'right',
|
||||||
|
sticky: true,
|
||||||
|
render: r => (
|
||||||
|
<ActionsCell>
|
||||||
|
<IconButton icon="edit" label="Редактировать" onClick={() => openEdit(r)} />
|
||||||
|
<IconButton icon="trash" label="Удалить" danger onClick={() => { setDeleteError(undefined); setDeleting(r); }} />
|
||||||
|
</ActionsCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
rows={sorted}
|
||||||
|
keyOf={r => r.id}
|
||||||
|
emptyMessage={loading ? 'Загрузка...' : 'Шагов пока нет — добавьте первый.'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<Modal
|
||||||
|
title={editing ? `Шаг #${editing.orderNo}` : 'Новый шаг маршрута'}
|
||||||
|
onClose={() => setShowForm(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={() => setShowForm(false)} disabled={saving}>Отмена</Button>
|
||||||
|
<Button variant="accent" icon="check" onClick={submit} disabled={saving}>
|
||||||
|
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formError && <Alert kind="err">{formError}</Alert>}
|
||||||
|
<FormGrid>
|
||||||
|
<Field label="Порядковый номер" required>
|
||||||
|
<TextInput
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={form.orderNo}
|
||||||
|
onChange={e => setForm({ ...form, orderNo: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Статус шага" required>
|
||||||
|
<Select
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
value={form.status}
|
||||||
|
onChange={e => setForm({ ...form, status: e.target.value as RouteStepStatus })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Кто исполнитель" required full hint="Должно быть заполнено ровно одно из полей.">
|
||||||
|
<RadioGroup
|
||||||
|
name="executorKind"
|
||||||
|
value={form.kind}
|
||||||
|
onChange={kind => setForm({ ...form, kind })}
|
||||||
|
options={[
|
||||||
|
{ value: 'email', label: 'Конкретный сотрудник (email)' },
|
||||||
|
{ value: 'position', label: 'По должности в компании инициатора' },
|
||||||
|
{
|
||||||
|
value: 'projectRole',
|
||||||
|
label: 'По роли в проекте',
|
||||||
|
disabled: !route.byProject,
|
||||||
|
disabledHint: 'Недоступно: маршрут не учитывает проект (по проекту = нет).'
|
||||||
|
},
|
||||||
|
{ value: 'headType', label: 'По типу руководителя' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{form.kind === 'email' && (
|
||||||
|
<Field label="Email исполнителя" required full>
|
||||||
|
<TextInput
|
||||||
|
value={form.executorEmail}
|
||||||
|
onChange={e => setForm({ ...form, executorEmail: e.target.value })}
|
||||||
|
placeholder="user@tnsu.kz"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{form.kind === 'position' && (
|
||||||
|
<Field label="Должность" required full hint="Ищется в справочнике api.tnsu.kz по компании инициатора.">
|
||||||
|
<TextInput
|
||||||
|
value={form.position}
|
||||||
|
onChange={e => setForm({ ...form, position: e.target.value })}
|
||||||
|
placeholder="Главный инженер"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{form.kind === 'projectRole' && (
|
||||||
|
<Field label="Роль в проекте" required full>
|
||||||
|
<Select
|
||||||
|
options={PROJECT_ROLE_OPTIONS}
|
||||||
|
value={form.projectRole}
|
||||||
|
onChange={e => setForm({ ...form, projectRole: e.target.value as ProjectRole })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{form.kind === 'headType' && (
|
||||||
|
<Field label="Тип руководителя" required full>
|
||||||
|
<Select
|
||||||
|
options={HEAD_TYPE_OPTIONS}
|
||||||
|
value={form.headType}
|
||||||
|
onChange={e => setForm({ ...form, headType: e.target.value as HeadType })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Переназначение" full>
|
||||||
|
<Checkbox
|
||||||
|
label="Разрешить переназначать другим согласующим"
|
||||||
|
checked={form.allowReassignToOtherApprovers}
|
||||||
|
onChange={v => setForm({ ...form, allowReassignToOtherApprovers: v })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FormGrid>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleting && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Удалить шаг?"
|
||||||
|
text={`Шаг #${deleting.orderNo} будет удалён безвозвратно.`}
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
busy={deleteBusy}
|
||||||
|
onCancel={() => setDeleting(undefined)}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function describeExecutor(step: TemplateRouteStep): string {
|
||||||
|
if (step.executorEmail) return step.executorEmail;
|
||||||
|
if (step.position) return step.position;
|
||||||
|
if (step.projectRole && step.projectRole !== ProjectRole.None) return `Роль: ${projectRoleLabels[step.projectRole]}`;
|
||||||
|
if (step.headType && step.headType !== HeadType.None) return `Руководитель: ${headTypeLabels[step.headType]}`;
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
|
import { ApiClientError } from '../../services/httpClient';
|
||||||
|
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||||
|
import { Company, Department, RequestType, routeTypeLabels, RouteType, TemplateRoute } from '../../models';
|
||||||
|
import {
|
||||||
|
ActionsCell,
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
ConfirmDialog,
|
||||||
|
DataTable,
|
||||||
|
Field,
|
||||||
|
FormGrid,
|
||||||
|
IconButton,
|
||||||
|
Modal,
|
||||||
|
PageHeader,
|
||||||
|
PrimaryCell,
|
||||||
|
SearchableSelect,
|
||||||
|
Select
|
||||||
|
} from '../ui';
|
||||||
|
|
||||||
|
export interface IRoutesScreenProps {
|
||||||
|
api: ApprovalRouteApi;
|
||||||
|
dictionary?: PortalDictionaryClient;
|
||||||
|
onOpenSteps: (route: TemplateRoute) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IFormState {
|
||||||
|
requestTypeId: string;
|
||||||
|
routeType: RouteType;
|
||||||
|
companyId: string;
|
||||||
|
departmentId: string;
|
||||||
|
byProject: boolean;
|
||||||
|
requiresControl: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: IFormState = {
|
||||||
|
requestTypeId: '',
|
||||||
|
routeType: RouteType.Sequential,
|
||||||
|
companyId: '',
|
||||||
|
departmentId: '',
|
||||||
|
byProject: false,
|
||||||
|
requiresControl: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROUTE_TYPE_OPTIONS = Object.values(RouteType)
|
||||||
|
.filter(v => v !== RouteType.None)
|
||||||
|
.map(v => ({ value: v, label: routeTypeLabels[v] }));
|
||||||
|
|
||||||
|
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, onOpenSteps }) => {
|
||||||
|
const [requestTypes, setRequestTypes] = React.useState<RequestType[]>([]);
|
||||||
|
const [companies, setCompanies] = React.useState<Company[]>([]);
|
||||||
|
const [departments, setDepartments] = React.useState<Department[]>([]);
|
||||||
|
const [dictLoading, setDictLoading] = React.useState(true);
|
||||||
|
const [dictError, setDictError] = React.useState<string | undefined>();
|
||||||
|
const [items, setItems] = React.useState<TemplateRoute[]>([]);
|
||||||
|
const [filterRequestTypeId, setFilterRequestTypeId] = React.useState<string>('');
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [loadError, setLoadError] = React.useState<string | undefined>();
|
||||||
|
|
||||||
|
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
||||||
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
|
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
|
||||||
|
const [formError, setFormError] = React.useState<string | undefined>();
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
|
||||||
|
const [deleting, setDeleting] = React.useState<TemplateRoute | undefined>();
|
||||||
|
const [deleteError, setDeleteError] = React.useState<string | undefined>();
|
||||||
|
const [deleteBusy, setDeleteBusy] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
api.requestTypes.list().then(setRequestTypes).catch(() => { /* показывается в общей ошибке ниже */ });
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
// Справочники 1С грузим один раз на экран: они статичны, а отделов ~700 —
|
||||||
|
// поиск делаем на клиенте, без запроса на каждый символ.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!dictionary) return;
|
||||||
|
|
||||||
|
setDictLoading(true);
|
||||||
|
setDictError(undefined);
|
||||||
|
Promise.all([dictionary.companies(), dictionary.departments()])
|
||||||
|
.then(([loadedCompanies, loadedDepartments]) => {
|
||||||
|
setCompanies(loadedCompanies);
|
||||||
|
setDepartments(loadedDepartments);
|
||||||
|
})
|
||||||
|
.catch((e: ApiClientError) => setDictError(e.message))
|
||||||
|
.finally(() => setDictLoading(false));
|
||||||
|
}, [dictionary]);
|
||||||
|
|
||||||
|
const load = React.useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setLoadError(undefined);
|
||||||
|
const requestTypeId = filterRequestTypeId ? Number(filterRequestTypeId) : undefined;
|
||||||
|
api.templateRoutes
|
||||||
|
.list(requestTypeId)
|
||||||
|
.then(setItems)
|
||||||
|
.catch((e: ApiClientError) => setLoadError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [api, filterRequestTypeId]);
|
||||||
|
|
||||||
|
React.useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const requestTypeName = (id: number): string => requestTypes.find(rt => rt.id === id)?.name ?? `#${id}`;
|
||||||
|
|
||||||
|
// Пока справочник не загрузился (или маршрут ссылается на удалённую запись),
|
||||||
|
// показываем усечённый Guid — чтобы строка таблицы не выглядела пустой.
|
||||||
|
const companyName = (id: string): string => companies.find(c => c.id === id)?.name ?? shortGuid(id);
|
||||||
|
|
||||||
|
const departmentName = (id: string): string => departments.find(d => d.id === id)?.name ?? shortGuid(id);
|
||||||
|
|
||||||
|
// Отдел принадлежит компании, поэтому список сужаем под выбранную:
|
||||||
|
// иначе пользователь листает ~700 позиций из всех юрлиц сразу.
|
||||||
|
const departmentsForSelectedCompany = React.useMemo(
|
||||||
|
() => (form.companyId ? departments.filter(d => d.companyId === form.companyId) : []),
|
||||||
|
[departments, form.companyId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const openCreate = (): void => {
|
||||||
|
setEditing(undefined);
|
||||||
|
setForm({ ...EMPTY_FORM, requestTypeId: filterRequestTypeId });
|
||||||
|
setFormError(undefined);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (item: TemplateRoute): void => {
|
||||||
|
setEditing(item);
|
||||||
|
setForm({
|
||||||
|
requestTypeId: String(item.requestTypeId),
|
||||||
|
routeType: item.routeType,
|
||||||
|
companyId: item.companyId,
|
||||||
|
departmentId: item.departmentId ?? '',
|
||||||
|
byProject: item.byProject,
|
||||||
|
requiresControl: item.requiresControl
|
||||||
|
});
|
||||||
|
setFormError(undefined);
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
if (!editing && !form.requestTypeId) {
|
||||||
|
setFormError('Выберите тип заявки.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.companyId.trim()) {
|
||||||
|
setFormError('Компания обязательна (Guid из справочника api.tnsu.kz).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setFormError(undefined);
|
||||||
|
|
||||||
|
const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null;
|
||||||
|
const request = editing
|
||||||
|
? api.templateRoutes.update(editing.id, {
|
||||||
|
routeType: form.routeType,
|
||||||
|
companyId: form.companyId.trim(),
|
||||||
|
departmentId,
|
||||||
|
byProject: form.byProject,
|
||||||
|
requiresControl: form.requiresControl
|
||||||
|
})
|
||||||
|
: api.templateRoutes.create({
|
||||||
|
routeType: form.routeType,
|
||||||
|
requestTypeId: Number(form.requestTypeId),
|
||||||
|
companyId: form.companyId.trim(),
|
||||||
|
departmentId,
|
||||||
|
byProject: form.byProject,
|
||||||
|
requiresControl: form.requiresControl
|
||||||
|
});
|
||||||
|
|
||||||
|
request
|
||||||
|
.then(() => {
|
||||||
|
setShowForm(false);
|
||||||
|
load();
|
||||||
|
})
|
||||||
|
.catch((e: ApiClientError) => setFormError(e.message))
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (): void => {
|
||||||
|
if (!deleting) return;
|
||||||
|
setDeleteBusy(true);
|
||||||
|
setDeleteError(undefined);
|
||||||
|
api.templateRoutes
|
||||||
|
.remove(deleting.id)
|
||||||
|
.then(() => {
|
||||||
|
setDeleting(undefined);
|
||||||
|
load();
|
||||||
|
})
|
||||||
|
.catch((e: ApiClientError) => setDeleteError(e.message))
|
||||||
|
.finally(() => setDeleteBusy(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumbs items={[{ label: 'Главная' }, { label: 'Маршруты согласования' }]} />
|
||||||
|
<PageHeader
|
||||||
|
icon="route"
|
||||||
|
subtitle="Настройка согласований"
|
||||||
|
title="Маршруты согласования"
|
||||||
|
actions={<Button variant="accent" icon="plus" onClick={openCreate}>Добавить маршрут</Button>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 18, maxWidth: 320 }}>
|
||||||
|
<Field label="Фильтр по типу заявки">
|
||||||
|
<Select
|
||||||
|
options={requestTypes.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||||
|
placeholder="Все типы заявок"
|
||||||
|
value={filterRequestTypeId}
|
||||||
|
onChange={e => setFilterRequestTypeId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dictError && <Alert kind="warn">Справочник компаний и отделов недоступен: {dictError}</Alert>}
|
||||||
|
{loadError && <Alert kind="err">{loadError}</Alert>}
|
||||||
|
{deleteError && <Alert kind="err">{deleteError}</Alert>}
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: 'requestType',
|
||||||
|
header: 'Тип заявки',
|
||||||
|
render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'routeType',
|
||||||
|
header: 'Тип маршрута',
|
||||||
|
render: r => <Badge variant={r.routeType === RouteType.Parallel ? 'blue' : 'gold'}>{routeTypeLabels[r.routeType]}</Badge>
|
||||||
|
},
|
||||||
|
{ key: 'company', header: 'Компания', render: r => companyName(r.companyId) },
|
||||||
|
{ key: 'department', header: 'Отдел', render: r => (r.departmentId ? departmentName(r.departmentId) : '—') },
|
||||||
|
{
|
||||||
|
key: 'byProject',
|
||||||
|
header: 'По проекту',
|
||||||
|
align: 'center',
|
||||||
|
render: r => (r.byProject ? <Badge variant="ok">Да</Badge> : <Badge variant="neutral">Нет</Badge>)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'control',
|
||||||
|
header: 'Контроль',
|
||||||
|
align: 'center',
|
||||||
|
render: r => (r.requiresControl ? <Badge variant="gold">Требуется</Badge> : <Badge variant="neutral">Нет</Badge>)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
width: '160px',
|
||||||
|
align: 'right',
|
||||||
|
sticky: true,
|
||||||
|
render: r => (
|
||||||
|
<ActionsCell>
|
||||||
|
<Button size="sm" variant="secondary" icon="clipboard" onClick={() => onOpenSteps(r)}>Шаги</Button>
|
||||||
|
<IconButton icon="edit" label="Редактировать" onClick={() => openEdit(r)} />
|
||||||
|
<IconButton icon="trash" label="Удалить" danger onClick={() => { setDeleteError(undefined); setDeleting(r); }} />
|
||||||
|
</ActionsCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
rows={items}
|
||||||
|
keyOf={r => r.id}
|
||||||
|
emptyMessage={loading ? 'Загрузка...' : 'Маршрутов пока нет.'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<Modal
|
||||||
|
title={editing ? `Маршрут #${editing.id}` : 'Новый маршрут'}
|
||||||
|
onClose={() => setShowForm(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={() => setShowForm(false)} disabled={saving}>Отмена</Button>
|
||||||
|
<Button variant="accent" icon="check" onClick={submit} disabled={saving}>
|
||||||
|
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formError && <Alert kind="err">{formError}</Alert>}
|
||||||
|
<FormGrid>
|
||||||
|
<Field label="Тип заявки" required full>
|
||||||
|
<Select
|
||||||
|
options={requestTypes.map(rt => ({ value: String(rt.id), label: rt.name }))}
|
||||||
|
placeholder="Выберите тип заявки"
|
||||||
|
value={form.requestTypeId}
|
||||||
|
disabled={!!editing}
|
||||||
|
onChange={e => setForm({ ...form, requestTypeId: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Тип маршрута" required full>
|
||||||
|
<Select
|
||||||
|
options={ROUTE_TYPE_OPTIONS}
|
||||||
|
value={form.routeType}
|
||||||
|
onChange={e => setForm({ ...form, routeType: e.target.value as RouteType })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Компания" required full hint="Справочник 1С (api.tnsu.kz).">
|
||||||
|
<SearchableSelect
|
||||||
|
options={companies.map(c => ({ value: c.id, label: c.name }))}
|
||||||
|
value={form.companyId}
|
||||||
|
loading={dictLoading}
|
||||||
|
placeholder="Выберите компанию"
|
||||||
|
searchPlaceholder="Поиск по названию компании..."
|
||||||
|
onChange={companyId =>
|
||||||
|
// Смена компании обнуляет отдел: прежний принадлежал другой
|
||||||
|
// компании, и бэкенд не нашёл бы такой маршрут при resolve.
|
||||||
|
setForm({ ...form, companyId, departmentId: '' })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Отдел"
|
||||||
|
full
|
||||||
|
hint={
|
||||||
|
form.companyId
|
||||||
|
? 'Необязательно — пусто означает маршрут «по умолчанию» для всей компании.'
|
||||||
|
: 'Сначала выберите компанию.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SearchableSelect
|
||||||
|
options={departmentsForSelectedCompany.map(d => ({ value: d.id, label: d.name }))}
|
||||||
|
value={form.departmentId}
|
||||||
|
loading={dictLoading}
|
||||||
|
disabled={!form.companyId}
|
||||||
|
allowClear
|
||||||
|
clearLabel="Без отдела (маршрут по умолчанию)"
|
||||||
|
placeholder="Без отдела (маршрут по умолчанию)"
|
||||||
|
searchPlaceholder="Поиск по названию отдела..."
|
||||||
|
onChange={departmentId => setForm({ ...form, departmentId })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="По проекту">
|
||||||
|
<Checkbox
|
||||||
|
label="Маршрут учитывает конкретный проект"
|
||||||
|
checked={form.byProject}
|
||||||
|
onChange={v => setForm({ ...form, byProject: v })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Контроль">
|
||||||
|
<Checkbox
|
||||||
|
label="Требуется отдельный контроль исполнения"
|
||||||
|
checked={form.requiresControl}
|
||||||
|
onChange={v => setForm({ ...form, requiresControl: v })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FormGrid>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleting && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Удалить маршрут?"
|
||||||
|
text={`Маршрут #${deleting.id} и все его шаги будут удалены безвозвратно.`}
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
busy={deleteBusy}
|
||||||
|
onCancel={() => setDeleting(undefined)}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function shortGuid(guid: string): string {
|
||||||
|
return guid.length > 8 ? `${guid.slice(0, 8)}…` : guid;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: $r-md;
|
||||||
|
font-size: 12.5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border: 1px solid;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,19 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Alert.module.scss';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
|
export type AlertKind = 'warn' | 'ok' | 'err';
|
||||||
|
|
||||||
|
export interface IAlertProps {
|
||||||
|
kind: AlertKind;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS = { warn: 'warning', ok: 'check', err: 'close' } as const;
|
||||||
|
|
||||||
|
export const Alert: React.FC<IAlertProps> = ({ kind, children }) => (
|
||||||
|
<div className={`${styles.alert} ${styles[kind]}`}>
|
||||||
|
<Icon name={ICONS[kind]} size="sm" />
|
||||||
|
<span>{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 11px;
|
||||||
|
border-radius: $r-full;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok { background: $green-50; color: $green-600; }
|
||||||
|
.gold { background: $gold-100; color: $amber-700; }
|
||||||
|
.neutral { background: $gray-100; color: $gray-500; }
|
||||||
|
.blue { background: $blue-50; color: $blue-600; }
|
||||||
|
.red { background: $red-50; color: $red-500; }
|
||||||
|
|
||||||
|
.count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: $navy-900;
|
||||||
|
color: $gold-300;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Badge.module.scss';
|
||||||
|
|
||||||
|
export type BadgeVariant = 'ok' | 'gold' | 'neutral' | 'blue' | 'red';
|
||||||
|
|
||||||
|
export interface IBadgeProps {
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Badge: React.FC<IBadgeProps> = ({ variant = 'neutral', children }) => (
|
||||||
|
<span className={`${styles.badge} ${styles[variant]}`}>{children}</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CountBadge: React.FC<{ count: number }> = ({ count }) => (
|
||||||
|
<span className={styles.count}>{count}</span>
|
||||||
|
);
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: $font-sans;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: .18s;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: .15px;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: .5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: $navy-800;
|
||||||
|
color: #fff;
|
||||||
|
border-color: $navy-800;
|
||||||
|
&:hover:not(:disabled) { background: $navy-700; box-shadow: $sh-2; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent {
|
||||||
|
@include btn-accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
background: #fff;
|
||||||
|
color: $navy-800;
|
||||||
|
border-color: $gray-300;
|
||||||
|
&:hover:not(:disabled) { border-color: $gold-500; color: $navy-900; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: $navy-700;
|
||||||
|
border-color: transparent;
|
||||||
|
&:hover:not(:disabled) { background: $navy-100; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background: #fff;
|
||||||
|
color: $red-500;
|
||||||
|
border-color: $gray-300;
|
||||||
|
&:hover:not(:disabled) { border-color: $red-500; background: $red-50; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sm {
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton {
|
||||||
|
width: 33px;
|
||||||
|
height: 33px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
color: $gray-400;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) { color: $navy-800; background: $navy-50; border-color: $gray-200; }
|
||||||
|
&.danger:hover:not(:disabled) { color: $red-500; background: $red-50; }
|
||||||
|
&:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Button.module.scss';
|
||||||
|
import { Icon, IconName } from './Icon';
|
||||||
|
|
||||||
|
export type ButtonVariant = 'primary' | 'accent' | 'secondary' | 'ghost' | 'danger';
|
||||||
|
|
||||||
|
export interface IButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: 'md' | 'sm';
|
||||||
|
icon?: IconName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button: React.FC<IButtonProps> = ({
|
||||||
|
variant = 'secondary',
|
||||||
|
size = 'md',
|
||||||
|
icon,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
|
const classes = [styles.btn, styles[variant], size === 'sm' ? styles.sm : '', className]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className={classes} {...rest}>
|
||||||
|
{icon && <Icon name={icon} size="sm" />}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
icon: IconName;
|
||||||
|
danger?: boolean;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IconButton: React.FC<IIconButtonProps> = ({ icon, danger, label, className, ...rest }) => {
|
||||||
|
const classes = [styles.iconButton, danger ? styles.danger : '', className].filter(Boolean).join(' ');
|
||||||
|
return (
|
||||||
|
<button type="button" className={classes} aria-label={label} title={label} {...rest}>
|
||||||
|
<Icon name={icon} size="sm" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navCard {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: $r-md;
|
||||||
|
padding: 22px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
box-shadow: $sh-1;
|
||||||
|
cursor: pointer;
|
||||||
|
@include card-hover-gold;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: $grad-gold-v;
|
||||||
|
opacity: 0;
|
||||||
|
transition: .22s;
|
||||||
|
}
|
||||||
|
&:hover::before { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.navIcon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: $r-md;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: $navy-50;
|
||||||
|
color: $navy-700;
|
||||||
|
border: 1px solid $navy-100;
|
||||||
|
transition: .22s;
|
||||||
|
|
||||||
|
.navCard:hover & { background: $gold-50; color: $gold-600; border-color: $gold-300; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.navTitle { font-size: 14.5px; font-weight: 600; color: $navy-900; letter-spacing: .1px; }
|
||||||
|
.navDesc { font-size: 12px; color: $gray-400; margin-top: 2px; }
|
||||||
|
|
||||||
|
.navArrow {
|
||||||
|
color: $gray-300;
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
transition: .22s;
|
||||||
|
.navCard:hover & { color: $gold-500; transform: translateX(3px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.statRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statCard {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: $r-md;
|
||||||
|
padding: 18px 20px;
|
||||||
|
box-shadow: $sh-1;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
color: $gray-400;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-family: $font-serif;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Card.module.scss';
|
||||||
|
import { Icon, IconName } from './Icon';
|
||||||
|
|
||||||
|
export const CardGrid: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
|
<div className={styles.grid}>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface INavCardProps {
|
||||||
|
icon: IconName;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NavCard: React.FC<INavCardProps> = ({ icon, title, description, onClick }) => (
|
||||||
|
<a className={styles.navCard} onClick={e => { e.preventDefault(); onClick(); }} href="#">
|
||||||
|
<div className={styles.navIcon}><Icon name={icon} /></div>
|
||||||
|
<div>
|
||||||
|
<div className={styles.navTitle}>{title}</div>
|
||||||
|
<div className={styles.navDesc}>{description}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.navArrow}><Icon name="chevron-right" size="sm" /></div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const StatRow: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
|
<div className={styles.statRow}>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const StatCard: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
|
||||||
|
<div className={styles.statCard}>
|
||||||
|
<div className={styles.statLabel}>{label}</div>
|
||||||
|
<div className={styles.statValue}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
box-shadow: $sh-1;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Внутренний скролл-контейнер: если колонок больше, чем помещается в узкую
|
||||||
|
// колонку страницы (например, в редакторе SharePoint), таблица должна
|
||||||
|
// скроллиться по горизонтали, а не обрезаться по overflow:hidden у .wrap.
|
||||||
|
.scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
// min-width, не width: при большом числе колонок таблица должна иметь
|
||||||
|
// право стать шире контейнера — тогда её подхватит .scroll (overflow-x).
|
||||||
|
min-width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table thead th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 13px 18px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
color: $gray-400;
|
||||||
|
font-weight: 600;
|
||||||
|
background: $gray-50;
|
||||||
|
border-bottom: 1px solid $gray-200;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody td {
|
||||||
|
padding: 15px 18px;
|
||||||
|
border-bottom: 1px solid $gray-100;
|
||||||
|
color: $gray-700;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
.table tbody tr { transition: .12s; }
|
||||||
|
.table tbody tr:hover { background: $gold-50; }
|
||||||
|
|
||||||
|
.stickyCell {
|
||||||
|
position: sticky;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table thead th.stickyCell { z-index: 2; }
|
||||||
|
|
||||||
|
.table tbody td.stickyCell {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: -6px 0 6px -6px rgba(15, 36, 64, .12);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover td.stickyCell { background: $gold-50; }
|
||||||
|
|
||||||
|
.primaryCell {
|
||||||
|
font-weight: 500;
|
||||||
|
color: $navy-900;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsCell {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 48px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './DataTable.module.scss';
|
||||||
|
|
||||||
|
export interface IColumn<T> {
|
||||||
|
key: string;
|
||||||
|
header: string;
|
||||||
|
render: (row: T) => React.ReactNode;
|
||||||
|
align?: 'left' | 'right' | 'center';
|
||||||
|
width?: string;
|
||||||
|
// Держит колонку у правого края видимой области при горизонтальной
|
||||||
|
// прокрутке — чтобы кнопки действий не уезжали за край на узких экранах.
|
||||||
|
sticky?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDataTableProps<T> {
|
||||||
|
columns: IColumn<T>[];
|
||||||
|
rows: T[];
|
||||||
|
keyOf: (row: T) => string | number;
|
||||||
|
emptyMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<T>({ columns, rows, keyOf, emptyMessage }: IDataTableProps<T>): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div className={styles.scroll}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map(col => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
className={col.sticky ? styles.stickyCell : undefined}
|
||||||
|
style={{ width: col.width, textAlign: col.align ?? 'left' }}
|
||||||
|
>
|
||||||
|
{col.header}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map(row => (
|
||||||
|
<tr key={keyOf(row)}>
|
||||||
|
{columns.map(col => (
|
||||||
|
<td
|
||||||
|
key={col.key}
|
||||||
|
className={col.sticky ? styles.stickyCell : undefined}
|
||||||
|
style={{ textAlign: col.align ?? 'left' }}
|
||||||
|
>
|
||||||
|
{col.render(row)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 && <div className={styles.empty}>{emptyMessage}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrimaryCell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
|
<span className={styles.primaryCell}>{children}</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ActionsCell: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
|
<span className={styles.actionsCell}>{children}</span>
|
||||||
|
);
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 18px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
// Grid-элементы по умолчанию не сжимаются уже своего min-content
|
||||||
|
// (min-width: auto) — из-за этого длинный текст/плейсхолдер в инпуте
|
||||||
|
// мог распирать колонку и вызывать горизонтальный скролл модалки.
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.full { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $gray-700;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.req { color: $gold-600; }
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.select,
|
||||||
|
.textarea {
|
||||||
|
// border-box обязателен: width:100% + padding/border в content-box
|
||||||
|
// (браузерный дефолт) делает элемент шире контейнера на padding+border
|
||||||
|
// и распирает модалку в горизонтальный скролл.
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: $font-sans;
|
||||||
|
color: $gray-900;
|
||||||
|
transition: .18s;
|
||||||
|
background: #fff;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@include input-focus-gold;
|
||||||
|
|
||||||
|
&::placeholder { color: $gray-400; }
|
||||||
|
&:disabled { background: $gray-50; color: $gray-400; cursor: not-allowed; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea { resize: vertical; min-height: 80px; }
|
||||||
|
|
||||||
|
.select {
|
||||||
|
appearance: none;
|
||||||
|
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;
|
||||||
|
padding-right: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorText {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $gray-700;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
accent-color: $gold-500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radioGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radioRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $gray-700;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&.disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: $gold-500;
|
||||||
|
cursor: pointer;
|
||||||
|
&:disabled { cursor: not-allowed; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './FormField.module.scss';
|
||||||
|
|
||||||
|
export const FormGrid: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
|
<div className={styles.grid}>{children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface IFieldProps {
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
full?: boolean;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Field: React.FC<IFieldProps> = ({ label, required, full, error, hint, children }) => (
|
||||||
|
<div className={`${styles.field} ${full ? styles.full : ''}`}>
|
||||||
|
<span className={styles.label}>
|
||||||
|
{label} {required && <span className={styles.req}>*</span>}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
{error && <span className={styles.errorText}>{error}</span>}
|
||||||
|
{!error && hint && <span className={styles.hint}>{hint}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TextInput: React.FC<React.InputHTMLAttributes<HTMLInputElement>> = props => (
|
||||||
|
<input {...props} className={styles.input} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Textarea: React.FC<React.TextareaHTMLAttributes<HTMLTextAreaElement>> = props => (
|
||||||
|
<textarea {...props} className={styles.textarea} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ISelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'children'> {
|
||||||
|
options: ISelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Select: React.FC<ISelectProps> = ({ options, placeholder, ...rest }) => (
|
||||||
|
<select {...rest} className={styles.select}>
|
||||||
|
{placeholder && <option value="">{placeholder}</option>}
|
||||||
|
{options.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ICheckboxProps {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Checkbox: React.FC<ICheckboxProps> = ({ label, checked, onChange, disabled }) => (
|
||||||
|
<label className={styles.checkboxRow}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className={styles.checkbox}
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={e => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface IRadioOption<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
disabledHint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRadioGroupProps<T extends string> {
|
||||||
|
name: string;
|
||||||
|
value: T;
|
||||||
|
options: IRadioOption<T>[];
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadioGroup<T extends string>({ name, value, options, onChange }: IRadioGroupProps<T>): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className={styles.radioGroup}>
|
||||||
|
{options.map(opt => (
|
||||||
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
className={`${styles.radioRow} ${opt.disabled ? styles.disabled : ''}`}
|
||||||
|
title={opt.disabled ? opt.disabledHint : undefined}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
className={styles.radio}
|
||||||
|
name={name}
|
||||||
|
checked={value === opt.value}
|
||||||
|
disabled={opt.disabled}
|
||||||
|
onChange={() => onChange(opt.value)}
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
// Единый набор иконок в стиле референса (stroke, round caps, viewBox 24x24).
|
||||||
|
export type IconName =
|
||||||
|
| 'route'
|
||||||
|
| 'clipboard'
|
||||||
|
| 'flag-check'
|
||||||
|
| 'people'
|
||||||
|
| 'warning'
|
||||||
|
| 'check'
|
||||||
|
| 'close'
|
||||||
|
| 'plus'
|
||||||
|
| 'edit'
|
||||||
|
| 'trash'
|
||||||
|
| 'more'
|
||||||
|
| 'chevron-right'
|
||||||
|
| 'chevron-down'
|
||||||
|
| 'chevron-left'
|
||||||
|
| 'arrow-left'
|
||||||
|
| 'search'
|
||||||
|
| 'sort'
|
||||||
|
| 'mail'
|
||||||
|
| 'briefcase'
|
||||||
|
| 'flag'
|
||||||
|
| 'crown'
|
||||||
|
| 'refresh';
|
||||||
|
|
||||||
|
let spriteInjected = false;
|
||||||
|
|
||||||
|
const SPRITE_SVG = `
|
||||||
|
<symbol id="i-route" viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><path d="M9 6h7a3 3 0 0 1 3 3v0a3 3 0 0 1-3 3H8a3 3 0 0 0-3 3v0a3 3 0 0 0 3 3h7"/></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-flag-check" viewBox="0 0 24 24"><path d="M4 3v18"/><path d="M4 4h13l-2 4 2 4H4"/></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-warning" viewBox="0 0 24 24"><path d="M12 2 1 21h22z" stroke-linejoin="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-close" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></symbol>
|
||||||
|
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></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-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-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-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-sort" viewBox="0 0 24 24"><path d="M7 3v18M7 3l-4 4m4-4 4 4M17 21V3m0 18-4-4m4 4 4-4"/></symbol>
|
||||||
|
<symbol id="i-mail" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m4 6 8 7 8-7"/></symbol>
|
||||||
|
<symbol id="i-briefcase" viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M3 12h18"/></symbol>
|
||||||
|
<symbol id="i-flag" viewBox="0 0 24 24"><path d="M5 3v18"/><path d="M5 4h13l-3 4 3 4H5"/></symbol>
|
||||||
|
<symbol id="i-crown" viewBox="0 0 24 24"><path d="m3 8 4 3 5-6 5 6 4-3-2 11H5z"/></symbol>
|
||||||
|
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></symbol>
|
||||||
|
`;
|
||||||
|
|
||||||
|
function ensureSpriteInjected(): void {
|
||||||
|
if (spriteInjected || typeof document === 'undefined') return;
|
||||||
|
const holder = document.createElement('div');
|
||||||
|
holder.style.display = 'none';
|
||||||
|
holder.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg">${SPRITE_SVG}</svg>`;
|
||||||
|
document.body.appendChild(holder);
|
||||||
|
spriteInjected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IIconProps {
|
||||||
|
name: IconName;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE_PX: Record<'sm' | 'md' | 'lg', number> = { sm: 16, md: 20, lg: 24 };
|
||||||
|
|
||||||
|
export const Icon: React.FC<IIconProps> = ({ name, size = 'md', className, style }) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
ensureSpriteInjected();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const px = SIZE_PX[size];
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={px}
|
||||||
|
height={px}
|
||||||
|
className={className}
|
||||||
|
style={{ flexShrink: 0, fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round', ...style }}
|
||||||
|
>
|
||||||
|
<use href={`#i-${name}`} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(10,27,48,.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 300;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
box-shadow: $sh-modal;
|
||||||
|
width: 480px;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: calc(100vh - 40px);
|
||||||
|
overflow-y: auto;
|
||||||
|
border-top: 3px solid $gold-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow { width: 420px; text-align: center; padding: 30px; }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 22px 26px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: $font-serif;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body { padding: 18px 26px 26px; }
|
||||||
|
|
||||||
|
.narrowBody { padding: 0; }
|
||||||
|
|
||||||
|
.text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $gray-500;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 26px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrowFooter { justify-content: center; padding: 0; margin-top: 24px; }
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Modal.module.scss';
|
||||||
|
import { Button, IconButton } from './Button';
|
||||||
|
|
||||||
|
export interface IModalProps {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children }) => (
|
||||||
|
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
|
<div className={styles.box}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.title}>{title}</div>
|
||||||
|
<IconButton icon="close" label="Закрыть" onClick={onClose} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>{children}</div>
|
||||||
|
{footer && <div className={styles.footer}>{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface IConfirmDialogProps {
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
busy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfirmDialog: React.FC<IConfirmDialogProps> = ({ title, text, confirmLabel, onConfirm, onCancel, busy }) => (
|
||||||
|
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onCancel(); }}>
|
||||||
|
<div className={`${styles.box} ${styles.narrow}`}>
|
||||||
|
<div className={styles.title}>{title}</div>
|
||||||
|
<p className={styles.text}>{text}</p>
|
||||||
|
<div className={styles.narrowFooter}>
|
||||||
|
<Button variant="secondary" onClick={onCancel} disabled={busy}>Отмена</Button>
|
||||||
|
<Button variant="danger" icon="trash" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.bread {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $gray-400;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadLink {
|
||||||
|
color: $navy-700;
|
||||||
|
text-decoration: none;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover { color: $gold-600; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sep { color: $gold-400; font-size: 10px; }
|
||||||
|
|
||||||
|
.ph {
|
||||||
|
@include page-header;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
box-shadow: $sh-2;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -60px;
|
||||||
|
top: -60px;
|
||||||
|
width: 220px;
|
||||||
|
height: 220px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(212,160,30,.14), transparent 65%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.phLeft { display: flex; align-items: center; gap: 18px; position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
.phIcon {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: $r-md;
|
||||||
|
background: rgba(212,160,30,.14);
|
||||||
|
border: 1px solid rgba(212,160,30,.3);
|
||||||
|
color: $gold-300;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phSub {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: rgba(255,255,255,.55);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phTitle {
|
||||||
|
font-family: $font-serif;
|
||||||
|
font-size: $fs-page-title;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
margin-top: 3px;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phRight {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './PageChrome.module.scss';
|
||||||
|
import { Icon, IconName } from './Icon';
|
||||||
|
|
||||||
|
export interface IBreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Breadcrumbs: React.FC<{ items: IBreadcrumbItem[] }> = ({ items }) => (
|
||||||
|
<div className={styles.bread}>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{i > 0 && <span className={styles.sep}>◆</span>}
|
||||||
|
{item.onClick ? (
|
||||||
|
<button className={styles.breadLink} onClick={item.onClick}>{item.label}</button>
|
||||||
|
) : (
|
||||||
|
<span>{item.label}</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface IPageHeaderProps {
|
||||||
|
icon: IconName;
|
||||||
|
subtitle: string;
|
||||||
|
title: string;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageHeader: React.FC<IPageHeaderProps> = ({ icon, subtitle, title, actions }) => (
|
||||||
|
<div className={styles.ph}>
|
||||||
|
<div className={styles.phLeft}>
|
||||||
|
<div className={styles.phIcon}><Icon name={icon} /></div>
|
||||||
|
<div>
|
||||||
|
<div className={styles.phSub}>{subtitle}</div>
|
||||||
|
<div className={styles.phTitle}>{title}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{actions && <div className={styles.phRight}>{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: #fff;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: $gray-900;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
@include input-focus-gold;
|
||||||
|
|
||||||
|
&:disabled { background: $gray-50; color: $gray-400; cursor: not-allowed; }
|
||||||
|
&.open { border-color: $gold-500; box-shadow: 0 0 0 3px rgba(212,160,30,.12); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder { color: $gray-400; }
|
||||||
|
|
||||||
|
.chevron { color: $gray-400; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: $r-md;
|
||||||
|
box-shadow: $sh-modal;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
padding: 6px;
|
||||||
|
z-index: 400;
|
||||||
|
max-height: 320px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid $gray-200;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13.5px;
|
||||||
|
outline: 0;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&::placeholder { color: $gray-400; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: $gray-700;
|
||||||
|
transition: .1s;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: left;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-family: $font-sans;
|
||||||
|
|
||||||
|
&:hover, &.active { background: $gold-50; color: $navy-900; }
|
||||||
|
&.selected { font-weight: 600; color: $navy-900; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemSub {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: $gray-400;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 16px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid $gray-100;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './SearchableSelect.module.scss';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
|
export interface ISearchableOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
/** Второстепенная строка под названием — например, компания у отдела. */
|
||||||
|
sublabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISearchableSelectProps {
|
||||||
|
options: ISearchableOption[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
/** Показывать пункт «очистить» — для необязательных полей. */
|
||||||
|
allowClear?: boolean;
|
||||||
|
clearLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchableSelect: React.FC<ISearchableSelectProps> = ({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Выберите значение',
|
||||||
|
searchPlaceholder = 'Поиск...',
|
||||||
|
disabled,
|
||||||
|
loading,
|
||||||
|
allowClear,
|
||||||
|
clearLabel = 'Не выбрано'
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [query, setQuery] = React.useState('');
|
||||||
|
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const selected = options.filter(o => o.value === value)[0];
|
||||||
|
|
||||||
|
// Поиск на клиенте: справочник целиком уже загружен, отделов ~700 —
|
||||||
|
// фильтровать в памяти дешевле, чем ходить на сервер на каждый символ.
|
||||||
|
const filtered = React.useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return options;
|
||||||
|
return options.filter(o =>
|
||||||
|
o.label.toLowerCase().indexOf(q) !== -1 ||
|
||||||
|
(o.sublabel ? o.sublabel.toLowerCase().indexOf(q) !== -1 : false)
|
||||||
|
);
|
||||||
|
}, [options, query]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const onDocumentMouseDown = (e: MouseEvent): void => {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyDown = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Escape') setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', onDocumentMouseDown);
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDocumentMouseDown);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open && searchRef.current) searchRef.current.focus();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const toggle = (): void => {
|
||||||
|
if (disabled) return;
|
||||||
|
setQuery('');
|
||||||
|
setOpen(!open);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pick = (next: string): void => {
|
||||||
|
onChange(next);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root} ref={rootRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.trigger} ${open ? styles.open : ''}`}
|
||||||
|
onClick={toggle}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
>
|
||||||
|
<span className={`${styles.value} ${selected ? '' : styles.placeholder}`}>
|
||||||
|
{loading ? 'Загрузка справочника...' : (selected ? selected.label : placeholder)}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="sm" className={styles.chevron} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className={styles.menu}>
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
className={styles.search}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className={styles.list}>
|
||||||
|
{filtered.length === 0 && <div className={styles.empty}>Ничего не найдено</div>}
|
||||||
|
{filtered.map(opt => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={opt.value}
|
||||||
|
className={`${styles.item} ${opt.value === value ? styles.selected : ''}`}
|
||||||
|
onClick={() => pick(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
{opt.sublabel && <span className={styles.itemSub}>{opt.sublabel}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{allowClear && value && (
|
||||||
|
<div className={styles.clear}>
|
||||||
|
<button type="button" className={styles.item} onClick={() => pick('')}>
|
||||||
|
{clearLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export * from './Icon';
|
||||||
|
export * from './Button';
|
||||||
|
export * from './Badge';
|
||||||
|
export * from './Alert';
|
||||||
|
export * from './PageChrome';
|
||||||
|
export * from './Card';
|
||||||
|
export * from './DataTable';
|
||||||
|
export * from './Modal';
|
||||||
|
export * from './FormField';
|
||||||
|
export * from './SearchableSelect';
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
declare interface IApprovalAdminWebPartStrings {
|
||||||
|
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 'ApprovalAdminWebPartStrings' {
|
||||||
|
const strings: IApprovalAdminWebPartStrings;
|
||||||
|
export = strings;
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// Зеркало DTO и enum'ов backend (Tnsu.ApprovalRoute.Api / Application / Domain).
|
||||||
|
// Имена и форма типов должны 1:1 соответствовать C#-стороне — см.
|
||||||
|
// backend/src/Tnsu.ApprovalRoute.Domain/Enums/*.cs и
|
||||||
|
// backend/src/Tnsu.ApprovalRoute.Application/**/*.cs
|
||||||
|
|
||||||
|
export enum RouteType {
|
||||||
|
None = 'None',
|
||||||
|
Parallel = 'Parallel',
|
||||||
|
Sequential = 'Sequential'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum RouteStepStatus {
|
||||||
|
None = 'None',
|
||||||
|
Approval = 'Approval',
|
||||||
|
Execution = 'Execution'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ProjectRole {
|
||||||
|
None = 'None',
|
||||||
|
ProjectManager = 'ProjectManager',
|
||||||
|
Planner = 'Planner',
|
||||||
|
ManagerHSE = 'ManagerHSE',
|
||||||
|
Security = 'Security'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum HeadType {
|
||||||
|
None = 'None',
|
||||||
|
Chief = 'Chief',
|
||||||
|
DirectionHead = 'DirectionHead',
|
||||||
|
DirectorTansu = 'DirectorTansu'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routeTypeLabels: Record<RouteType, string> = {
|
||||||
|
[RouteType.None]: 'Не задан',
|
||||||
|
[RouteType.Parallel]: 'Параллельный',
|
||||||
|
[RouteType.Sequential]: 'Последовательный'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const routeStepStatusLabels: Record<RouteStepStatus, string> = {
|
||||||
|
[RouteStepStatus.None]: 'Не задан',
|
||||||
|
[RouteStepStatus.Approval]: 'Согласование',
|
||||||
|
[RouteStepStatus.Execution]: 'Исполнение'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const projectRoleLabels: Record<ProjectRole, string> = {
|
||||||
|
[ProjectRole.None]: '—',
|
||||||
|
[ProjectRole.ProjectManager]: 'Руководитель проекта',
|
||||||
|
[ProjectRole.Planner]: 'Планировщик',
|
||||||
|
[ProjectRole.ManagerHSE]: 'Менеджер HSE',
|
||||||
|
[ProjectRole.Security]: 'Безопасность'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const headTypeLabels: Record<HeadType, string> = {
|
||||||
|
[HeadType.None]: '—',
|
||||||
|
[HeadType.Chief]: 'Непосредственный руководитель',
|
||||||
|
[HeadType.DirectionHead]: 'Руководитель направления',
|
||||||
|
[HeadType.DirectorTansu]: 'Директор Tansu'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Справочники api.tnsu.kz, проксируемые нашим бэкендом ----
|
||||||
|
// (api/dictionary/companies, api/dictionary/departments)
|
||||||
|
|
||||||
|
export interface Company {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Department {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
companyId: string;
|
||||||
|
company: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Request Types (api/request-types) ----
|
||||||
|
|
||||||
|
export interface RequestType {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
pageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRequestTypeInput {
|
||||||
|
name: string;
|
||||||
|
pageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateRequestTypeInput = CreateRequestTypeInput;
|
||||||
|
|
||||||
|
export interface PatchRequestTypeInput {
|
||||||
|
name?: string;
|
||||||
|
pageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Template Routes (api/template-routes) ----
|
||||||
|
|
||||||
|
export interface TemplateRoute {
|
||||||
|
id: number;
|
||||||
|
routeType: RouteType;
|
||||||
|
requestTypeId: number;
|
||||||
|
departmentId: string | null;
|
||||||
|
companyId: string;
|
||||||
|
byProject: boolean;
|
||||||
|
requiresControl: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTemplateRouteInput {
|
||||||
|
routeType: RouteType;
|
||||||
|
requestTypeId: number;
|
||||||
|
departmentId: string | null;
|
||||||
|
companyId: string;
|
||||||
|
byProject: boolean;
|
||||||
|
requiresControl: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTemplateRouteInput {
|
||||||
|
routeType: RouteType;
|
||||||
|
departmentId: string | null;
|
||||||
|
companyId: string;
|
||||||
|
byProject: boolean;
|
||||||
|
requiresControl: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatchTemplateRouteInput {
|
||||||
|
routeType?: RouteType;
|
||||||
|
departmentId?: string | null;
|
||||||
|
companyId?: string;
|
||||||
|
byProject?: boolean;
|
||||||
|
requiresControl?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Template Route Steps (api/template-routes/{routeId}/steps) ----
|
||||||
|
|
||||||
|
export interface TemplateRouteStep {
|
||||||
|
id: number;
|
||||||
|
orderNo: number;
|
||||||
|
executorEmail: string | null;
|
||||||
|
position: string | null;
|
||||||
|
projectRole: ProjectRole | null;
|
||||||
|
headType: HeadType | null;
|
||||||
|
status: RouteStepStatus;
|
||||||
|
routeId: number;
|
||||||
|
allowReassignToOtherApprovers: boolean;
|
||||||
|
warning?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTemplateRouteStepInput {
|
||||||
|
orderNo: number;
|
||||||
|
executorEmail: string | null;
|
||||||
|
position: string | null;
|
||||||
|
projectRole: ProjectRole | null;
|
||||||
|
headType: HeadType | null;
|
||||||
|
status: RouteStepStatus;
|
||||||
|
allowReassignToOtherApprovers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateTemplateRouteStepInput = CreateTemplateRouteStepInput;
|
||||||
|
|
||||||
|
export interface PatchTemplateRouteStepInput {
|
||||||
|
orderNo?: number;
|
||||||
|
executorEmail?: string | null;
|
||||||
|
position?: string | null;
|
||||||
|
projectRole?: ProjectRole | null;
|
||||||
|
headType?: HeadType | null;
|
||||||
|
status?: RouteStepStatus;
|
||||||
|
allowReassignToOtherApprovers?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Resolve (api/template-routes/resolve) ----
|
||||||
|
|
||||||
|
export interface ResolveTemplateRouteQuery {
|
||||||
|
requestTypeId: number;
|
||||||
|
userEmail?: string;
|
||||||
|
projectId?: string;
|
||||||
|
status: RouteStepStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedRouteStep {
|
||||||
|
id: number;
|
||||||
|
orderNo: number;
|
||||||
|
executorEmail: string | null;
|
||||||
|
position: string | null;
|
||||||
|
allowReassignToOtherApprovers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveTemplateRouteResult {
|
||||||
|
routeId: number;
|
||||||
|
routeType: RouteType;
|
||||||
|
requiresControl: boolean;
|
||||||
|
steps: ResolvedRouteStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ровно одно из этих полей должно быть заполнено на шаге маршрута
|
||||||
|
// (см. TemplateRouteStepFields.CountFilled на бэкенде).
|
||||||
|
export type StepExecutorKind = 'email' | 'position' | 'projectRole' | 'headType';
|
||||||
|
|
||||||
|
export function detectExecutorKind(step: {
|
||||||
|
executorEmail?: string | null;
|
||||||
|
position?: string | null;
|
||||||
|
projectRole?: ProjectRole | null;
|
||||||
|
headType?: HeadType | null;
|
||||||
|
}): StepExecutorKind {
|
||||||
|
if (step.executorEmail) return 'email';
|
||||||
|
if (step.position) return 'position';
|
||||||
|
if (step.projectRole && step.projectRole !== ProjectRole.None) return 'projectRole';
|
||||||
|
return 'headType';
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { IHttpClient } from './httpClient';
|
||||||
|
import {
|
||||||
|
CreateRequestTypeInput,
|
||||||
|
CreateTemplateRouteInput,
|
||||||
|
CreateTemplateRouteStepInput,
|
||||||
|
PatchRequestTypeInput,
|
||||||
|
PatchTemplateRouteInput,
|
||||||
|
PatchTemplateRouteStepInput,
|
||||||
|
RequestType,
|
||||||
|
ResolveTemplateRouteQuery,
|
||||||
|
ResolveTemplateRouteResult,
|
||||||
|
TemplateRoute,
|
||||||
|
TemplateRouteStep,
|
||||||
|
UpdateRequestTypeInput,
|
||||||
|
UpdateTemplateRouteInput,
|
||||||
|
UpdateTemplateRouteStepInput
|
||||||
|
} from '../models';
|
||||||
|
|
||||||
|
// Тонкий 1:1 фасад над контроллерами бэкенда:
|
||||||
|
// backend/src/Tnsu.ApprovalRoute.Api/Controllers/{RequestTypes,TemplateRoutes,TemplateRouteSteps}Controller.cs
|
||||||
|
|
||||||
|
export class ApprovalRouteApi {
|
||||||
|
constructor(private readonly http: IHttpClient) {}
|
||||||
|
|
||||||
|
public readonly requestTypes = {
|
||||||
|
list: (): Promise<RequestType[]> =>
|
||||||
|
this.http.get<RequestType[]>('/api/request-types'),
|
||||||
|
|
||||||
|
get: (id: number): Promise<RequestType> =>
|
||||||
|
this.http.get<RequestType>(`/api/request-types/${id}`),
|
||||||
|
|
||||||
|
create: (body: CreateRequestTypeInput): Promise<RequestType> =>
|
||||||
|
this.http.post<RequestType>('/api/request-types', body),
|
||||||
|
|
||||||
|
update: (id: number, body: UpdateRequestTypeInput): Promise<RequestType> =>
|
||||||
|
this.http.put<RequestType>(`/api/request-types/${id}`, body),
|
||||||
|
|
||||||
|
patch: (id: number, body: PatchRequestTypeInput): Promise<RequestType> =>
|
||||||
|
this.http.patch<RequestType>(`/api/request-types/${id}`, body),
|
||||||
|
|
||||||
|
remove: (id: number): Promise<void> =>
|
||||||
|
this.http.delete(`/api/request-types/${id}`)
|
||||||
|
};
|
||||||
|
|
||||||
|
public readonly templateRoutes = {
|
||||||
|
list: (requestTypeId?: number): Promise<TemplateRoute[]> =>
|
||||||
|
this.http.get<TemplateRoute[]>('/api/template-routes', { requestTypeId }),
|
||||||
|
|
||||||
|
get: (id: number): Promise<TemplateRoute> =>
|
||||||
|
this.http.get<TemplateRoute>(`/api/template-routes/${id}`),
|
||||||
|
|
||||||
|
create: (body: CreateTemplateRouteInput): Promise<TemplateRoute> =>
|
||||||
|
this.http.post<TemplateRoute>('/api/template-routes', body),
|
||||||
|
|
||||||
|
update: (id: number, body: UpdateTemplateRouteInput): Promise<TemplateRoute> =>
|
||||||
|
this.http.put<TemplateRoute>(`/api/template-routes/${id}`, body),
|
||||||
|
|
||||||
|
patch: (id: number, body: PatchTemplateRouteInput): Promise<TemplateRoute> =>
|
||||||
|
this.http.patch<TemplateRoute>(`/api/template-routes/${id}`, body),
|
||||||
|
|
||||||
|
remove: (id: number): Promise<void> =>
|
||||||
|
this.http.delete(`/api/template-routes/${id}`),
|
||||||
|
|
||||||
|
resolve: (query: ResolveTemplateRouteQuery): Promise<ResolveTemplateRouteResult> =>
|
||||||
|
this.http.get<ResolveTemplateRouteResult>('/api/template-routes/resolve', { ...query })
|
||||||
|
};
|
||||||
|
|
||||||
|
public readonly templateRouteSteps = {
|
||||||
|
list: (routeId: number): Promise<TemplateRouteStep[]> =>
|
||||||
|
this.http.get<TemplateRouteStep[]>(`/api/template-routes/${routeId}/steps`),
|
||||||
|
|
||||||
|
get: (routeId: number, id: number): Promise<TemplateRouteStep> =>
|
||||||
|
this.http.get<TemplateRouteStep>(`/api/template-routes/${routeId}/steps/${id}`),
|
||||||
|
|
||||||
|
create: (routeId: number, body: CreateTemplateRouteStepInput): Promise<TemplateRouteStep> =>
|
||||||
|
this.http.post<TemplateRouteStep>(`/api/template-routes/${routeId}/steps`, body),
|
||||||
|
|
||||||
|
update: (routeId: number, id: number, body: UpdateTemplateRouteStepInput): Promise<TemplateRouteStep> =>
|
||||||
|
this.http.put<TemplateRouteStep>(`/api/template-routes/${routeId}/steps/${id}`, body),
|
||||||
|
|
||||||
|
patch: (routeId: number, id: number, body: PatchTemplateRouteStepInput): Promise<TemplateRouteStep> =>
|
||||||
|
this.http.patch<TemplateRouteStep>(`/api/template-routes/${routeId}/steps/${id}`, body),
|
||||||
|
|
||||||
|
remove: (routeId: number, id: number): Promise<void> =>
|
||||||
|
this.http.delete(`/api/template-routes/${routeId}/steps/${id}`)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
|
|
||||||
|
// Ответ бэкенда всегда в конверте { data: T } на успехе (Api/Common/ApiResponse.cs)
|
||||||
|
// и { messages: string[] } на ошибке (ApiError). 204 No Content возвращается без тела.
|
||||||
|
interface IApiEnvelope<T> {
|
||||||
|
data?: T;
|
||||||
|
messages?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiClientError extends Error {
|
||||||
|
public readonly status: number;
|
||||||
|
public readonly messages: string[];
|
||||||
|
|
||||||
|
constructor(status: number, messages: string[]) {
|
||||||
|
super(messages.join('; ') || `HTTP ${status}`);
|
||||||
|
this.status = status;
|
||||||
|
this.messages = messages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||||
|
|
||||||
|
export interface IHttpClient {
|
||||||
|
get<T>(path: string, query?: QueryParams): Promise<T>;
|
||||||
|
post<T>(path: string, body?: unknown): Promise<T>;
|
||||||
|
put<T>(path: string, body?: unknown): Promise<T>;
|
||||||
|
patch<T>(path: string, body?: unknown): Promise<T>;
|
||||||
|
delete(path: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(baseUrl: string, path: string, query?: QueryParams): string {
|
||||||
|
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||||
|
const url = new URL(path.replace(/^\//, ''), normalizedBase);
|
||||||
|
if (query) {
|
||||||
|
Object.keys(query).forEach(key => {
|
||||||
|
const value = query[key];
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IMinimalResponse {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
text(): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseResponse<T>(response: IMinimalResponse): Promise<T> {
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let json: IApiEnvelope<T> | undefined;
|
||||||
|
try {
|
||||||
|
json = text ? JSON.parse(text) : undefined;
|
||||||
|
} catch {
|
||||||
|
// Ответ пришёл не в конверте ApiResponse/ApiError (например, "сырая" страница
|
||||||
|
// необработанного исключения ASP.NET Core или ошибка от прокси перед API).
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiClientError(response.status, [text || `HTTP ${response.status}`]);
|
||||||
|
}
|
||||||
|
throw new ApiClientError(response.status, ['Сервер вернул не-JSON ответ.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const messages = json?.messages ?? [`HTTP ${response.status}`];
|
||||||
|
throw new ApiClientError(response.status, messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (json?.data as T) ?? (undefined as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прод/staging: вызовы через AAD-защищённое SPFx-подключение (AadHttpClient).
|
||||||
|
* Требует webApiPermissionRequests в package-solution.json и админского
|
||||||
|
* согласия в SharePoint admin center → Advanced → API access.
|
||||||
|
*/
|
||||||
|
export class AadApiHttpClient implements IHttpClient {
|
||||||
|
constructor(private readonly client: AadHttpClient, private readonly baseUrl: string) {}
|
||||||
|
|
||||||
|
public get<T>(path: string, query?: QueryParams): Promise<T> {
|
||||||
|
const url = buildUrl(this.baseUrl, path, query);
|
||||||
|
return this.client
|
||||||
|
.get(url, AadHttpClient.configurations.v1)
|
||||||
|
.then((r: HttpClientResponse) => parseResponse<T>(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
public post<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
return this.send<T>('POST', path, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public put<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
return this.send<T>('PUT', path, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public patch<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
return this.send<T>('PATCH', path, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(path: string): Promise<void> {
|
||||||
|
await this.send<void>('DELETE', path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const url = buildUrl(this.baseUrl, path);
|
||||||
|
return this.client
|
||||||
|
.fetch(url, AadHttpClient.configurations.v1, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined
|
||||||
|
})
|
||||||
|
.then((r: HttpClientResponse) => parseResponse<T>(r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Локальная отладка: обычный fetch с заголовком X-Dev-User-Email.
|
||||||
|
* Работает только если бэкенд поднят с пустыми Entra:TenantId/Entra:Audience
|
||||||
|
* (см. backend/.../Auth/AuthSetup.cs — тогда включается DevAuthHandler).
|
||||||
|
*/
|
||||||
|
export class DevApiHttpClient implements IHttpClient {
|
||||||
|
constructor(private readonly baseUrl: string, private readonly devUserEmail: string) {}
|
||||||
|
|
||||||
|
public get<T>(path: string, query?: QueryParams): Promise<T> {
|
||||||
|
return this.send<T>('GET', path, undefined, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public post<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
return this.send<T>('POST', path, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public put<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
return this.send<T>('PUT', path, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public patch<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
return this.send<T>('PATCH', path, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(path: string): Promise<void> {
|
||||||
|
await this.send<void>('DELETE', path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async send<T>(method: string, path: string, body?: unknown, query?: QueryParams): Promise<T> {
|
||||||
|
const url = buildUrl(this.baseUrl, path, query);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Dev-User-Email': this.devUserEmail
|
||||||
|
},
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined
|
||||||
|
});
|
||||||
|
return parseResponse<T>(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
|
import { Company, Department } from '../models';
|
||||||
|
import { ApiClientError } from './httpClient';
|
||||||
|
|
||||||
|
// Справочники берём напрямую из api.tnsu.kz — тем же AAD-токеном, что и наш API:
|
||||||
|
// в appsettings.json бэкенда Entra:Audience и TnsuPortal:ClientId — один и тот же
|
||||||
|
// GUID, то есть обе службы живут на одной регистрации Entra. Прокси не нужен.
|
||||||
|
//
|
||||||
|
// Два отличия от нашего API, из-за которых здесь отдельный клиент:
|
||||||
|
// 1) портал отдаёт «голый» массив, без конверта { data: ... };
|
||||||
|
// 2) ключи в PascalCase (Id/Name/CompanyId/Company).
|
||||||
|
|
||||||
|
interface IPortalCompany {
|
||||||
|
Id: string;
|
||||||
|
Name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPortalDepartment {
|
||||||
|
Id: string;
|
||||||
|
Name: string;
|
||||||
|
CompanyId: string;
|
||||||
|
Company: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson<T>(client: AadHttpClient, baseUrl: string, path: string): Promise<T> {
|
||||||
|
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||||
|
const url = new URL(path.replace(/^\//, ''), normalizedBase).toString();
|
||||||
|
|
||||||
|
const response: HttpClientResponse = await client.get(url, AadHttpClient.configurations.v1);
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiClientError(response.status, [
|
||||||
|
`Справочник api.tnsu.kz вернул HTTP ${response.status}${text ? `: ${text}` : ''}`
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
throw new ApiClientError(response.status, ['Справочник api.tnsu.kz вернул не-JSON ответ.']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PortalDictionaryClient {
|
||||||
|
constructor(private readonly client: AadHttpClient, private readonly baseUrl: string) {}
|
||||||
|
|
||||||
|
public async companies(): Promise<Company[]> {
|
||||||
|
const raw = await getJson<IPortalCompany[]>(this.client, this.baseUrl, '/Dictionary/Companies');
|
||||||
|
return (raw ?? []).map(x => ({ id: x.Id, name: x.Name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async departments(): Promise<Department[]> {
|
||||||
|
const raw = await getJson<IPortalDepartment[]>(this.client, this.baseUrl, '/Dictionary/Departments');
|
||||||
|
return (raw ?? []).map(x => ({
|
||||||
|
id: x.Id,
|
||||||
|
name: x.Name,
|
||||||
|
companyId: x.CompanyId,
|
||||||
|
company: x.Company
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 249 B |
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "./node_modules/@microsoft/spfx-web-build-rig/profiles/default/tsconfig-base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"es5",
|
||||||
|
"es2015.core",
|
||||||
|
"es2015.collection",
|
||||||
|
"es2015.iterable",
|
||||||
|
"es2015.promise",
|
||||||
|
"es2015.proxy",
|
||||||
|
"es2017.object",
|
||||||
|
"es2018.promise"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user