feat: working version
This commit is contained in:
+321
@@ -0,0 +1,321 @@
|
|||||||
|
require('@rushstack/eslint-config/patch/modern-module-resolution');
|
||||||
|
module.exports = {
|
||||||
|
extends: ['@microsoft/eslint-config-spfx/lib/profiles/react'],
|
||||||
|
parserOptions: { tsconfigRootDir: __dirname },
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ['*.ts', '*.tsx'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
'parserOptions': {
|
||||||
|
'project': './tsconfig.json',
|
||||||
|
'ecmaVersion': 2018,
|
||||||
|
'sourceType': 'module'
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// Prevent usage of the JavaScript null value, while allowing code to access existing APIs that may require null. https://www.npmjs.com/package/@rushstack/eslint-plugin
|
||||||
|
'@rushstack/no-new-null': 1,
|
||||||
|
// Require Jest module mocking APIs to be called before any other statements in their code block. https://www.npmjs.com/package/@rushstack/eslint-plugin
|
||||||
|
'@rushstack/hoist-jest-mock': 1,
|
||||||
|
// Require chunk names for dynamic imports in SPFx projects. https://www.npmjs.com/package/@rushstack/eslint-plugin
|
||||||
|
'@rushstack/import-requires-chunk-name': 1,
|
||||||
|
// Ensure that React components rendered with ReactDOM.render() are unmounted with ReactDOM.unmountComponentAtNode(). https://www.npmjs.com/package/@rushstack/eslint-plugin
|
||||||
|
'@rushstack/pair-react-dom-render-unmount': 1,
|
||||||
|
// Require regular expressions to be constructed from string constants rather than dynamically building strings at runtime. https://www.npmjs.com/package/@rushstack/eslint-plugin-security
|
||||||
|
'@rushstack/security/no-unsafe-regexp': 1,
|
||||||
|
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||||
|
'@typescript-eslint/adjacent-overload-signatures': 1,
|
||||||
|
// RATIONALE: Code is more readable when the type of every variable is immediately obvious.
|
||||||
|
// Even if the compiler may be able to infer a type, this inference will be unavailable
|
||||||
|
// to a person who is reviewing a GitHub diff. This rule makes writing code harder,
|
||||||
|
// but writing code is a much less important activity than reading it.
|
||||||
|
//
|
||||||
|
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||||
|
'@typescript-eslint/explicit-function-return-type': [
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
'allowExpressions': true,
|
||||||
|
'allowTypedFunctionExpressions': true,
|
||||||
|
'allowHigherOrderFunctions': false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||||
|
// Rationale to disable: although this is a recommended rule, it is up to dev to select coding style.
|
||||||
|
// Set to 1 (warning) or 2 (error) to enable.
|
||||||
|
'@typescript-eslint/explicit-member-accessibility': 0,
|
||||||
|
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||||
|
'@typescript-eslint/no-array-constructor': 1,
|
||||||
|
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||||
|
//
|
||||||
|
// RATIONALE: The "any" keyword disables static type checking, the main benefit of using TypeScript.
|
||||||
|
// This rule should be suppressed only in very special cases such as JSON.stringify()
|
||||||
|
// where the type really can be anything. Even if the type is flexible, another type
|
||||||
|
// may be more appropriate such as "unknown", "{}", or "Record<k,V>".
|
||||||
|
'@typescript-eslint/no-explicit-any': 1,
|
||||||
|
// RATIONALE: The #1 rule of promises is that every promise chain must be terminated by a catch()
|
||||||
|
// handler. Thus wherever a Promise arises, the code must either append a catch handler,
|
||||||
|
// or else return the object to a caller (who assumes this responsibility). Unterminated
|
||||||
|
// promise chains are a serious issue. Besides causing errors to be silently ignored,
|
||||||
|
// they can also cause a NodeJS process to terminate unexpectedly.
|
||||||
|
'@typescript-eslint/no-floating-promises': 2,
|
||||||
|
// RATIONALE: Catches a common coding mistake.
|
||||||
|
'@typescript-eslint/no-for-in-array': 2,
|
||||||
|
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||||
|
'@typescript-eslint/no-misused-new': 2,
|
||||||
|
// RATIONALE: The "namespace" keyword is not recommended for organizing code because JavaScript lacks
|
||||||
|
// a "using" statement to traverse namespaces. Nested namespaces prevent certain bundler
|
||||||
|
// optimizations. If you are declaring loose functions/variables, it's better to make them
|
||||||
|
// static members of a class, since classes support property getters and their private
|
||||||
|
// members are accessible by unit tests. Also, the exercise of choosing a meaningful
|
||||||
|
// class name tends to produce more discoverable APIs: for example, search+replacing
|
||||||
|
// the function "reverse()" is likely to return many false matches, whereas if we always
|
||||||
|
// write "Text.reverse()" is more unique. For large scale organization, it's recommended
|
||||||
|
// to decompose your code into separate NPM packages, which ensures that component
|
||||||
|
// dependencies are tracked more conscientiously.
|
||||||
|
//
|
||||||
|
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||||
|
'@typescript-eslint/no-namespace': [
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
'allowDeclarations': false,
|
||||||
|
'allowDefinitionFiles': false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// RATIONALE: Parameter properties provide a shorthand such as "constructor(public title: string)"
|
||||||
|
// that avoids the effort of declaring "title" as a field. This TypeScript feature makes
|
||||||
|
// code easier to write, but arguably sacrifices readability: In the notes for
|
||||||
|
// "@typescript-eslint/member-ordering" we pointed out that fields are central to
|
||||||
|
// a class's design, so we wouldn't want to bury them in a constructor signature
|
||||||
|
// just to save some typing.
|
||||||
|
//
|
||||||
|
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||||
|
// Set to 1 (warning) or 2 (error) to enable the rule
|
||||||
|
'@typescript-eslint/parameter-properties': 0,
|
||||||
|
// RATIONALE: When left in shipping code, unused variables often indicate a mistake. Dead code
|
||||||
|
// may impact performance.
|
||||||
|
//
|
||||||
|
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
'vars': 'all',
|
||||||
|
// Unused function arguments often indicate a mistake in JavaScript code. However in TypeScript code,
|
||||||
|
// the compiler catches most of those mistakes, and unused arguments are fairly common for type signatures
|
||||||
|
// that are overriding a base class method or implementing an interface.
|
||||||
|
'args': 'none'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||||
|
'@typescript-eslint/no-use-before-define': [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
'functions': false,
|
||||||
|
'classes': true,
|
||||||
|
'variables': true,
|
||||||
|
'enums': true,
|
||||||
|
'typedefs': true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// Disallows require statements except in import statements.
|
||||||
|
// In other words, the use of forms such as var foo = require("foo") are banned. Instead use ES6 style imports or import foo = require("foo") imports.
|
||||||
|
'@typescript-eslint/no-var-requires': 'error',
|
||||||
|
// RATIONALE: The "module" keyword is deprecated except when describing legacy libraries.
|
||||||
|
//
|
||||||
|
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||||
|
'@typescript-eslint/prefer-namespace-keyword': 1,
|
||||||
|
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||||
|
// Rationale to disable: it's up to developer to decide if he wants to add type annotations
|
||||||
|
// Set to 1 (warning) or 2 (error) to enable the rule
|
||||||
|
'@typescript-eslint/no-inferrable-types': 0,
|
||||||
|
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||||
|
// Rationale to disable: declaration of empty interfaces may be helpful for generic types scenarios
|
||||||
|
'@typescript-eslint/no-empty-interface': 0,
|
||||||
|
// RATIONALE: This rule warns if setters are defined without getters, which is probably a mistake.
|
||||||
|
'accessor-pairs': 1,
|
||||||
|
// RATIONALE: In TypeScript, if you write x["y"] instead of x.y, it disables type checking.
|
||||||
|
'dot-notation': [
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
'allowPattern': '^_'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// RATIONALE: Catches code that is likely to be incorrect
|
||||||
|
'eqeqeq': 1,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'for-direction': 1,
|
||||||
|
// RATIONALE: Catches a common coding mistake.
|
||||||
|
'guard-for-in': 2,
|
||||||
|
// RATIONALE: If you have more than 2,000 lines in a single source file, it's probably time
|
||||||
|
// to split up your code.
|
||||||
|
'max-lines': ['warn', { max: 2000 }],
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-async-promise-executor': 2,
|
||||||
|
// RATIONALE: Deprecated language feature.
|
||||||
|
'no-caller': 2,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-compare-neg-zero': 2,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-cond-assign': 2,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-constant-condition': 1,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-control-regex': 2,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-debugger': 1,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-delete-var': 2,
|
||||||
|
// RATIONALE: Catches code that is likely to be incorrect
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-duplicate-case': 2,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-empty': 1,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-empty-character-class': 2,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-empty-pattern': 1,
|
||||||
|
// RATIONALE: Eval is a security concern and a performance concern.
|
||||||
|
'no-eval': 1,
|
||||||
|
// RATIONALE: Catches code that is likely to be incorrect
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-ex-assign': 2,
|
||||||
|
// RATIONALE: System types are global and should not be tampered with in a scalable code base.
|
||||||
|
// If two different libraries (or two versions of the same library) both try to modify
|
||||||
|
// a type, only one of them can win. Polyfills are acceptable because they implement
|
||||||
|
// a standardized interoperable contract, but polyfills are generally coded in plain
|
||||||
|
// JavaScript.
|
||||||
|
'no-extend-native': 1,
|
||||||
|
// Disallow unnecessary labels
|
||||||
|
'no-extra-label': 1,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-fallthrough': 2,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-func-assign': 1,
|
||||||
|
// RATIONALE: Catches a common coding mistake.
|
||||||
|
'no-implied-eval': 2,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-invalid-regexp': 2,
|
||||||
|
// RATIONALE: Catches a common coding mistake.
|
||||||
|
'no-label-var': 2,
|
||||||
|
// RATIONALE: Eliminates redundant code.
|
||||||
|
'no-lone-blocks': 1,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-misleading-character-class': 2,
|
||||||
|
// RATIONALE: Catches a common coding mistake.
|
||||||
|
'no-multi-str': 2,
|
||||||
|
// RATIONALE: It's generally a bad practice to call "new Thing()" without assigning the result to
|
||||||
|
// a variable. Either it's part of an awkward expression like "(new Thing()).doSomething()",
|
||||||
|
// or else implies that the constructor is doing nontrivial computations, which is often
|
||||||
|
// a poor class design.
|
||||||
|
'no-new': 1,
|
||||||
|
// RATIONALE: Obsolete language feature that is deprecated.
|
||||||
|
'no-new-func': 2,
|
||||||
|
// RATIONALE: Obsolete language feature that is deprecated.
|
||||||
|
'no-new-object': 2,
|
||||||
|
// RATIONALE: Obsolete notation.
|
||||||
|
'no-new-wrappers': 1,
|
||||||
|
// RATIONALE: Catches code that is likely to be incorrect
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-octal': 2,
|
||||||
|
// RATIONALE: Catches code that is likely to be incorrect
|
||||||
|
'no-octal-escape': 2,
|
||||||
|
// RATIONALE: Catches code that is likely to be incorrect
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-regex-spaces': 2,
|
||||||
|
// RATIONALE: Catches a common coding mistake.
|
||||||
|
'no-return-assign': 2,
|
||||||
|
// RATIONALE: Security risk.
|
||||||
|
'no-script-url': 1,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-self-assign': 2,
|
||||||
|
// RATIONALE: Catches a common coding mistake.
|
||||||
|
'no-self-compare': 2,
|
||||||
|
// RATIONALE: This avoids statements such as "while (a = next(), a && a.length);" that use
|
||||||
|
// commas to create compound expressions. In general code is more readable if each
|
||||||
|
// step is split onto a separate line. This also makes it easier to set breakpoints
|
||||||
|
// in the debugger.
|
||||||
|
'no-sequences': 1,
|
||||||
|
// RATIONALE: Catches code that is likely to be incorrect
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-shadow-restricted-names': 2,
|
||||||
|
// RATIONALE: Obsolete language feature that is deprecated.
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-sparse-arrays': 2,
|
||||||
|
// RATIONALE: Although in theory JavaScript allows any possible data type to be thrown as an exception,
|
||||||
|
// such flexibility adds pointless complexity, by requiring every catch block to test
|
||||||
|
// the type of the object that it receives. Whereas if catch blocks can always assume
|
||||||
|
// that their object implements the "Error" contract, then the code is simpler, and
|
||||||
|
// we generally get useful additional information like a call stack.
|
||||||
|
'no-throw-literal': 2,
|
||||||
|
// RATIONALE: Catches a common coding mistake.
|
||||||
|
'no-unmodified-loop-condition': 1,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-unsafe-finally': 2,
|
||||||
|
// RATIONALE: Catches a common coding mistake.
|
||||||
|
'no-unused-expressions': 1,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-unused-labels': 1,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-useless-catch': 1,
|
||||||
|
// RATIONALE: Avoids a potential performance problem.
|
||||||
|
'no-useless-concat': 1,
|
||||||
|
// RATIONALE: The "var" keyword is deprecated because of its confusing "hoisting" behavior.
|
||||||
|
// Always use "let" or "const" instead.
|
||||||
|
//
|
||||||
|
// STANDARDIZED BY: @typescript-eslint\eslint-plugin\dist\configs\recommended.json
|
||||||
|
'no-var': 2,
|
||||||
|
// RATIONALE: Generally not needed in modern code.
|
||||||
|
'no-void': 1,
|
||||||
|
// RATIONALE: Obsolete language feature that is deprecated.
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'no-with': 2,
|
||||||
|
// RATIONALE: Makes logic easier to understand, since constants always have a known value
|
||||||
|
// @typescript-eslint\eslint-plugin\dist\configs\eslint-recommended.js
|
||||||
|
'prefer-const': 1,
|
||||||
|
// RATIONALE: Catches a common coding mistake where "resolve" and "reject" are confused.
|
||||||
|
'promise/param-names': 2,
|
||||||
|
// RATIONALE: Catches code that is likely to be incorrect
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'require-atomic-updates': 2,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'require-yield': 1,
|
||||||
|
// "Use strict" is redundant when using the TypeScript compiler.
|
||||||
|
'strict': [
|
||||||
|
2,
|
||||||
|
'never'
|
||||||
|
],
|
||||||
|
// RATIONALE: Catches code that is likely to be incorrect
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
'use-isnan': 2,
|
||||||
|
// STANDARDIZED BY: eslint\conf\eslint-recommended.js
|
||||||
|
// Set to 1 (warning) or 2 (error) to enable.
|
||||||
|
// Rationale to disable: !!{}
|
||||||
|
'no-extra-boolean-cast': 0,
|
||||||
|
// ====================================================================
|
||||||
|
// @microsoft/eslint-plugin-spfx
|
||||||
|
// ====================================================================
|
||||||
|
'@microsoft/spfx/no-require-ensure': 2,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// For unit tests, we can be a little bit less strict. The settings below revise the
|
||||||
|
// defaults specified in the extended configurations, as well as above.
|
||||||
|
files: [
|
||||||
|
// Test files
|
||||||
|
'*.test.ts',
|
||||||
|
'*.test.tsx',
|
||||||
|
'*.spec.ts',
|
||||||
|
'*.spec.tsx',
|
||||||
|
|
||||||
|
// Facebook convention
|
||||||
|
'**/__mocks__/*.ts',
|
||||||
|
'**/__mocks__/*.tsx',
|
||||||
|
'**/__tests__/*.ts',
|
||||||
|
'**/__tests__/*.tsx',
|
||||||
|
|
||||||
|
// Microsoft convention
|
||||||
|
'**/test/*.ts',
|
||||||
|
'**/test/*.tsx'
|
||||||
|
],
|
||||||
|
rules: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
lib/
|
||||||
|
lib-commonjs/
|
||||||
|
dist/
|
||||||
|
temp/
|
||||||
|
release/
|
||||||
|
jest-output/
|
||||||
|
*.sppkg
|
||||||
|
.heft/
|
||||||
|
sharepoint/solution/
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# Ignore everything by default
|
||||||
|
**
|
||||||
|
|
||||||
|
# Use negative patterns to bring back the specific things we want to publish
|
||||||
|
!/bin/**
|
||||||
|
!/dist/**
|
||||||
|
!/EULA/**
|
||||||
|
!/lib/**
|
||||||
|
!ThirdPartyNotice.txt
|
||||||
|
|
||||||
|
# Ignore certain files in the above folders
|
||||||
|
/dist/*.stats.*
|
||||||
|
/dist/**/*.js.map
|
||||||
|
/lib/**/*.js.map
|
||||||
|
/lib/**/test/**
|
||||||
|
|
||||||
|
# NOTE: These don't need to be specified, because NPM includes them automatically.
|
||||||
|
#
|
||||||
|
# package.json
|
||||||
|
# README (and its variants)
|
||||||
|
# CHANGELOG (and its variants)
|
||||||
|
# LICENSE / LICENCE
|
||||||
Vendored
+23
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Hosted workbench",
|
||||||
|
"type": "msedge",
|
||||||
|
"request": "launch",
|
||||||
|
"url": "https://{tenantDomain}/_layouts/workbench.aspx",
|
||||||
|
"webRoot": "${workspaceRoot}",
|
||||||
|
"sourceMaps": true,
|
||||||
|
"sourceMapPathOverrides": {
|
||||||
|
"webpack:///.././src/*": "${webRoot}/src/*",
|
||||||
|
"webpack:///../../../src/*": "${webRoot}/src/*",
|
||||||
|
"webpack:///../../../../src/*": "${webRoot}/src/*",
|
||||||
|
"webpack:///../../../../../src/*": "${webRoot}/src/*"
|
||||||
|
},
|
||||||
|
"runtimeArgs": [
|
||||||
|
"--remote-debugging-port=9222",
|
||||||
|
"-incognito"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
// Place your settings in this file to overwrite default and user settings.
|
||||||
|
{
|
||||||
|
// Configure glob patterns for excluding files and folders in the file explorer.
|
||||||
|
"files.exclude": {
|
||||||
|
"**/.git": true,
|
||||||
|
"**/.DS_Store": true,
|
||||||
|
"**/bower_components": true,
|
||||||
|
"**/coverage": true,
|
||||||
|
"**/jest-output": true,
|
||||||
|
"**/lib-amd": true,
|
||||||
|
"src/**/*.scss.ts": true
|
||||||
|
},
|
||||||
|
"typescript.tsdk": ".\\node_modules\\typescript\\lib"
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"@microsoft/generator-sharepoint": {
|
||||||
|
"useGulp": false,
|
||||||
|
"plusBeta": false,
|
||||||
|
"isCreatingSolution": true,
|
||||||
|
"nodeVersion": "22.22.2",
|
||||||
|
"sdksVersions": {},
|
||||||
|
"version": "1.22.2",
|
||||||
|
"libraryName": "kps-ct-form",
|
||||||
|
"libraryId": "340fb0b3-70e1-4aba-904d-94651e35fe80",
|
||||||
|
"environment": "spo",
|
||||||
|
"packageManager": "npm",
|
||||||
|
"solutionName": "kps-ct-form",
|
||||||
|
"solutionShortDescription": "kps-ct-form description",
|
||||||
|
"skipFeatureDeployment": true,
|
||||||
|
"isDomainIsolated": false,
|
||||||
|
"componentType": "webpart"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
Ты — опытный Senior Frontend Engineer.
|
||||||
|
Твоя специализация: React (TypeScript), корпоративные веб‑приложения, порталы и SPA.
|
||||||
|
|
||||||
|
ОБЩИЕ ПРИНЦИПЫ
|
||||||
|
- Пиши код так, как если бы он шёл в production.
|
||||||
|
- Предпочитай простые и читаемые решения.
|
||||||
|
- Избегай overengineering.
|
||||||
|
- Всегда думай о поддерживаемости кода через 1–2 года.
|
||||||
|
- Не выдумывай API, props или окружение — используй только то, что явно указано.
|
||||||
|
- Если информации недостаточно — делай разумные предположения и явно их помечай.
|
||||||
|
|
||||||
|
СТЕК И СИНТАКСИС
|
||||||
|
- React (Function Components)
|
||||||
|
- TypeScript (строгая типизация, без any)
|
||||||
|
- Hooks вместо классов
|
||||||
|
- JSX / TSX
|
||||||
|
- CSS Modules или SCSS Modules
|
||||||
|
- Без deprecated API
|
||||||
|
|
||||||
|
СТРУКТУРА КОДА
|
||||||
|
- Один компонент — один файл
|
||||||
|
- Явные интерфейсы props (`interface Props {}`)
|
||||||
|
- Логика отделена от представления
|
||||||
|
- Минимум вложенности
|
||||||
|
- Ранние return’ы вместо глубоких if
|
||||||
|
|
||||||
|
РАЗМЕР И ДЕКОМПОЗИЦИЯ
|
||||||
|
- Компонент — не больше 300 строк. Больше — сигнал, что пора разбивать.
|
||||||
|
- Функция / хук — не больше ~50 строк, делает одну вещь.
|
||||||
|
- Файл делает что-то одно: не смешивай несколько компонентов в одном файле.
|
||||||
|
- Не больше 3–4 уровней вложенности JSX — глубже выноси в под-компонент.
|
||||||
|
- Props: до ~7 штук. Больше — сгруппируй в объект или пересмотри разбивку.
|
||||||
|
- Большой компонент дели по смыслу: под-компоненты (`Foo.tsx` → `FooHeader.tsx`, `FooRow.tsx`), кастомные хуки для логики (`useFooData.ts`), утилиты (`utils/`).
|
||||||
|
- Повторяешь один и тот же блок 3-й раз — выноси в общий компонент/функцию.
|
||||||
|
|
||||||
|
ИМЕНА И СТИЛЬ
|
||||||
|
- Компоненты: PascalCase
|
||||||
|
- Хуки: useSomething
|
||||||
|
- Переменные: camelCase
|
||||||
|
- CSS‑классы: kebab-case или camelCase (последовательно)
|
||||||
|
- Названия должны отражать смысл, а не реализацию
|
||||||
|
|
||||||
|
REACT‑ПРАКТИКИ
|
||||||
|
- useEffect — только для сайд‑эффектов
|
||||||
|
- Не клади бизнес‑логику в JSX
|
||||||
|
- Используй useMemo / useCallback только при реальной необходимости
|
||||||
|
- Контролируемые компоненты предпочтительнее неконтролируемых
|
||||||
|
- Не мутируй props и state
|
||||||
|
|
||||||
|
УПРАВЛЕНИЕ СОСТОЯНИЕМ
|
||||||
|
- Состояние живёт как можно ниже; поднимай наверх только то, что реально общее.
|
||||||
|
- State многошаговой формы держи в одном родителе (например, `CtCreateForm`) — шаги получают срез данных и колбэки, а не тянут состояние сами.
|
||||||
|
- Prop-drilling глубже 2–3 уровней — сигнал вынести общее в React Context.
|
||||||
|
- Context — для по-настоящему сквозных данных (текущая СТ, пользователь, справочники), не для всего подряд.
|
||||||
|
- Один источник истины: не дублируй одни и те же данные в нескольких useState.
|
||||||
|
- Изменяй state иммутабельно (`setItems(prev => [...prev, x])`), без мутаций массивов/объектов.
|
||||||
|
- Производные данные считай на лету (useMemo), не храни в отдельном state.
|
||||||
|
|
||||||
|
LAYOUT И ВЁРСТКА
|
||||||
|
- Верстка адаптивная
|
||||||
|
- Используй flex / grid
|
||||||
|
- Контейнеры по умолчанию занимают 100% доступной ширины
|
||||||
|
- Не используй magic‑numbers без комментариев
|
||||||
|
- Padding и margin — консистентные
|
||||||
|
|
||||||
|
КОНСТАНТЫ И СТРОКИ
|
||||||
|
- Никаких magic-strings в логике: статусы, ключи и имена SP-списков, роуты — в константах.
|
||||||
|
- Повторяющиеся значения выноси в один модуль констант (`constants.ts`), а не хардкодь по месту.
|
||||||
|
- Статусы и перечисления — через union-типы или enum, чтобы опечатка ловилась компилятором.
|
||||||
|
- Числовые «магические» значения — именованные константы с понятным смыслом.
|
||||||
|
- Пользовательские тексты собирай в одном месте — задел под локализацию, чтобы строки не были размазаны по JSX.
|
||||||
|
- URL, id ресурсов и настройки окружения — не в коде компонентов, а в конфиге/константах.
|
||||||
|
|
||||||
|
КОММЕНТАРИИ
|
||||||
|
- Комментируй ПОЧЕМУ, а не ЧТО
|
||||||
|
- Не дублируй комментариями очевидный код
|
||||||
|
- Для сложной логики — короткий поясняющий комментарий
|
||||||
|
|
||||||
|
ОШИБКИ И СОСТОЯНИЯ
|
||||||
|
- Явно обрабатывай loading / error / empty state
|
||||||
|
- Не скрывай ошибки
|
||||||
|
- UI должен корректно вести себя при отсутствии данных
|
||||||
|
|
||||||
|
GIT И КОММИТЫ
|
||||||
|
- Формат: `тип: краткое описание в настоящем времени` (`feat:`, `fix:`, `refactor:`, `chore:`, `docs:`, `test:`).
|
||||||
|
- Один коммит — одно логическое изменение, без свалки несвязанных правок.
|
||||||
|
- Сообщение объясняет ЧТО и ЗАЧЕМ, а не пересказывает диф.
|
||||||
|
- Не коммить закомментированный/отладочный код и секреты.
|
||||||
|
- Пиши так, чтобы по истории джун понял ход работы без расспросов.
|
||||||
|
|
||||||
|
ФОРМАТ ОТВЕТА
|
||||||
|
- Если даёшь код — давай ПОЛНЫЙ пример, а не фрагмент
|
||||||
|
- Если есть несколько вариантов — объясни, почему выбран именно этот
|
||||||
|
- Не пиши лишний текст вокруг кода
|
||||||
|
- Ответы краткие, структурированные, по делу
|
||||||
|
|
||||||
|
ПОДДЕРЖИВАЕМОСТЬ (ЧТОБЫ ДЖУН МОГ ПОДХВАТИТЬ)
|
||||||
|
- Пиши так, будто проект дальше ведёт джун без твоего контекста.
|
||||||
|
- Никакой «магии» и умных однострочников — понятный код важнее короткого.
|
||||||
|
- Понятные имена вместо комментариев-костылей: `isLoading`, а не `flag`.
|
||||||
|
- Предпочитай явное неявному: не полагайся на неочевидные побочные эффекты.
|
||||||
|
- Структура папок предсказуемая и одинаковая по всему проекту.
|
||||||
|
- Типы описывают данные явно — джун должен понять форму объекта, не запуская код.
|
||||||
|
- Сложную бизнес-логику сопровождай коротким «почему» в комментарии.
|
||||||
|
- Не оставляй мёртвый/закомментированный код — удаляй.
|
||||||
|
|
||||||
|
КОНТЕКСТ
|
||||||
|
- Код пишется для корпоративного портала
|
||||||
|
- Основной приоритет: читаемость, стабильность, предсказуемость
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
# Стартовый промт для нового SPFx-проекта (многошаговая форма)
|
||||||
|
|
||||||
|
> **Как использовать этот файл.**
|
||||||
|
> Положи его в корень нового репозитория (например, переименуй в `CLAUDE.md`, чтобы Claude Code подхватывал автоматически).
|
||||||
|
> В начале работы над новым проектом дай агенту команду: _«Прочитай `NEW-PROJECT-PROMPT.md` и следуй ему»_.
|
||||||
|
> Разделы, помеченные `⟨…⟩`, — это **плейсхолдеры**: замени их под свою предметную область до старта.
|
||||||
|
> Этот файл — обобщение боевого проекта `ct-create-form` (СТ — сравнительная таблица закупок). Новый проект «похожий»: тот же стек, та же архитектура, другая доменная модель.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Роль
|
||||||
|
|
||||||
|
Ты — опытный Senior Frontend Engineer. Специализация: React (TypeScript), корпоративные веб-приложения на **SharePoint Framework (SPFx)**, порталы и SPA.
|
||||||
|
Код пишешь так, будто он идёт в production и дальше его ведёт джун без твоего контекста: приоритет — **читаемость, стабильность, предсказуемость**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Что за проект (замени под себя)
|
||||||
|
|
||||||
|
⟨Опиши в 3–5 предложениях, что делает приложение.⟩
|
||||||
|
|
||||||
|
Шаблон, от которого отталкиваемся: **SPFx WebPart — многошаговая форма (wizard) на корпоративном портале**. Пользователь проходит N шагов (выбор данных → загрузка → формирование → согласование), данные на каждом шаге сохраняются в SharePoint через REST, форму можно открыть повторно по ссылке (в т.ч. из уведомления Power Automate/Teams) и продолжить с любого шага.
|
||||||
|
|
||||||
|
- **Предметная область:** ⟨напр. «заявка на …», «карточка …», «согласование …»⟩
|
||||||
|
- **Шаги формы:** 4 шага (подробно — см. §12). Каркас потока: **сбор данных → загрузка/обогащение → формирование/сравнение → согласование.**
|
||||||
|
- **Роли пользователей:** ⟨автор/инициатор, согласующие, наблюдатели с ограниченным доступом⟩
|
||||||
|
- **Внешние системы:** ⟨SharePoint-списки, внешний API, Power Automate flows⟩
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Технологический стек (фиксированный)
|
||||||
|
|
||||||
|
- **SPFx 1.22.x** (Heft-сборка: `heft start` / `heft test` / `heft package-solution`)
|
||||||
|
- **Node** `>=22.14.0 <23`
|
||||||
|
- **React 17** (function components + hooks, без классов)
|
||||||
|
- **TypeScript ~5.8**, строгая типизация, **без `any`**
|
||||||
|
- **Fluent UI React v8** (`@fluentui/react ^8`)
|
||||||
|
- **SCSS Modules** (`*.module.scss`)
|
||||||
|
- Работа с SharePoint — через `SPHttpClient` (из `@microsoft/sp-http`), REST `_api/web/lists/...`. **PnPjs не используем.**
|
||||||
|
- Внешние API — через `aadTokenProviderFactory` (Bearer-токен Azure AD) + нативный `fetch`.
|
||||||
|
|
||||||
|
> Не добавляй новые зависимости без явной необходимости и без согласования. Не выдумывай API/props/окружение — используй только то, что явно есть.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Эталонная структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
src/webparts/⟨feature⟩/
|
||||||
|
⟨Feature⟩WebPart.ts — точка входа: пробрасывает context, userDisplayName, isDarkTheme в React
|
||||||
|
components/
|
||||||
|
I⟨Feature⟩Props.ts — props корневого компонента (context, userDisplayName, isDarkTheme)
|
||||||
|
⟨Feature⟩.tsx — ГЛАВНЫЙ контейнер (functional). Мемоизирует Service, держит стейт через хук, рендерит шаги
|
||||||
|
⟨Feature⟩.module.scss
|
||||||
|
types/domain.ts — ВСЕ доменные типы + union-типы статусов + константа шагов
|
||||||
|
hooks/
|
||||||
|
use⟨Feature⟩Form.ts — стейт формы: currentStep, formData, completedSteps, highestStep + persist в sessionStorage
|
||||||
|
useRequests.ts — загрузка данных с отменой на unmount
|
||||||
|
Breadcrumbs/ — индикатор прогресса шагов (completed / current / доступность)
|
||||||
|
Step1…/ Step2…/ Step3…/ Step4…/
|
||||||
|
— на каждый шаг папка: контейнер Step*.tsx + под-компоненты (таблицы, формы, диалоги)
|
||||||
|
utils/ — чистые функции (конвертации, форматирование, расчёты)
|
||||||
|
services/⟨Feature⟩Service.ts — ВСЕ вызовы SharePoint REST + внешние API + Power Automate в одном классе
|
||||||
|
loc/ — строки локализации
|
||||||
|
```
|
||||||
|
|
||||||
|
Правила декомпозиции: один компонент — один файл; компонент ≤ 300 строк; функция/хук ≤ ~50 строк; ≤ 3–4 уровня вложенности JSX; ≤ ~7 props (иначе группируй в объект). Повторил блок 3-й раз → выноси в общий компонент/функцию/хук.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ключевые архитектурные паттерны
|
||||||
|
|
||||||
|
Эти паттерны — суть проекта. Соблюдай их, расширяя форму.
|
||||||
|
|
||||||
|
### 5.1. Единый стейт формы в одном хуке-родителе
|
||||||
|
|
||||||
|
Весь стейт многошаговой формы живёт в **одном** хуке (`use⟨Feature⟩Form`) внутри главного контейнера. Шаги **не** держат общий стейт сами — они получают срез данных и колбэки.
|
||||||
|
|
||||||
|
- `formData` — единый объект со всеми данными формы (см. `CTFormData` как образец).
|
||||||
|
- Обновление — иммутабельно и точечно: `updateFormData(partial: Partial<FormData>)`.
|
||||||
|
- Производные данные считай на лету (`useMemo`), не дублируй в отдельный `useState`.
|
||||||
|
- `currentStep`, `completedSteps` (пройденные), `highestStep` (максимально доступный) — тоже здесь.
|
||||||
|
|
||||||
|
### 5.2. Контракт props для шага (единый на все шаги)
|
||||||
|
|
||||||
|
Каждый `Step*` получает один и тот же набор:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface StepProps {
|
||||||
|
service: ⟨Feature⟩Service;
|
||||||
|
formData: FormData;
|
||||||
|
onSave: (partial: Partial<FormData>) => void; // = updateFormData
|
||||||
|
onNext?: () => void;
|
||||||
|
onPrev?: () => void;
|
||||||
|
onStepComplete: (step: FormStep) => void;
|
||||||
|
isReadOnly?: boolean; // форма на согласовании/согласована/отклонена
|
||||||
|
isRestrictedView?: boolean; // урезанный доступ (напр. скрытие цен)
|
||||||
|
userDisplayName: string;
|
||||||
|
isNotificationLink?: boolean; // открыто по ссылке из уведомления
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3. Сервисный слой
|
||||||
|
|
||||||
|
`⟨Feature⟩Service` — единственное место, где происходят сетевые вызовы. Компоненты не делают `fetch`/`SPHttpClient` напрямую.
|
||||||
|
|
||||||
|
- Конструктор принимает `WebPartContext`, вытаскивает `baseUrl = context.pageContext.web.absoluteUrl` и `spHttpClient`.
|
||||||
|
- Приватные хелперы для URL списков: `_li(listName)` и `_liById(listName, id)`.
|
||||||
|
- Мемоизируй сервис в контейнере: `const service = useMemo(() => new ⟨Feature⟩Service(context), [context])`.
|
||||||
|
|
||||||
|
### 5.4. Персист и восстановление сессии
|
||||||
|
|
||||||
|
- Стейт формы сериализуется в `sessionStorage` по ключу `⟨prefix⟩_${entityId}` при каждом изменении.
|
||||||
|
- `entityId` кладётся в URL (`?⟨entity⟩Id=…`) через `history.replaceState`, чтобы перезагрузка/шаринг ссылки продолжали ту же форму.
|
||||||
|
- Поддержи **ссылку из уведомления** (Power Automate/Teams) с другим регистром параметра (напр. `?⟨entity⟩id=…`): по ней всегда подгружай свежие данные из SharePoint и открывай нужный шаг.
|
||||||
|
- При восстановлении **не затирай** несохранённые правки из сессии данными из SharePoint без нужды: бери SP-данные только когда в сессии пусто **или** это ссылка из уведомления (см. слияние в `⟨Feature⟩.tsx`).
|
||||||
|
|
||||||
|
### 5.5. Доступность шагов и режимы только для чтения
|
||||||
|
|
||||||
|
- Breadcrumbs даёт кликать только по `completedSteps` и в пределах `highestStep`.
|
||||||
|
- `isReadOnly` включается по статусу сущности (`on_approval` / `approved` / `rejected`).
|
||||||
|
- Особые ограничения по компании/роли (напр. скрытие цен) — отдельным флагом, который **складывается** с общими правилами, а не заменяет их.
|
||||||
|
|
||||||
|
### 5.6. Статусы — только union-типы
|
||||||
|
|
||||||
|
Статусы сущности и участников согласования — `type X = 'a' | 'b' | ...`, чтобы опечатка ловилась компилятором. Никаких magic-strings по коду.
|
||||||
|
|
||||||
|
### 5.7. Верстка на всю ширину экрана (full-width)
|
||||||
|
|
||||||
|
По умолчанию SPFx-страница зажимает WebPart шириной канвы (`max-width` ~1204px). Чтобы форма занимала всю ширину экрана, нужны **две вещи в связке**:
|
||||||
|
|
||||||
|
**1. Флаг в манифесте WebPart** (`⟨Feature⟩WebPart.manifest.json`) — разрешает положить WebPart в полноширинную секцию страницы:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"supportsFullBleed": true
|
||||||
|
```
|
||||||
|
|
||||||
|
После деплоя на странице добавь WebPart в **полноширинную секцию** (Full-width section — доступна только на страницах без баннера/на пустом шаблоне).
|
||||||
|
|
||||||
|
**2. Обход контейнера канвы в корневом `*.module.scss`** через `:global` — снимает `max-width` и у воркбенча (dev), и у живой канвы:
|
||||||
|
|
||||||
|
```scss
|
||||||
|
:global {
|
||||||
|
#workbenchPageContent,
|
||||||
|
.CanvasComponent.LCS .CanvasZone {
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.root {
|
||||||
|
min-height: 100vh; // тянем на всю высоту вьюпорта
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`!important` тут оправдан: перебиваем инлайновые/встроенные стили канвы SharePoint, на которые повлиять иначе нельзя (это тот редкий случай, когда `!important` — не костыль). Селекторы `#workbenchPageContent` и `.CanvasComponent.LCS .CanvasZone` — внутренние классы SharePoint; если после обновления платформы ширина «вернётся», проверь актуальные имена в DevTools.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Playbook по SharePoint (важно — тут «грабли»)
|
||||||
|
|
||||||
|
Внутренние имена списков и полей — **только в константах** сервиса (`const LISTS = {...}`), не размазывай по коду. Помни: внутреннее имя SP-поля ≠ отображаемое (напр. поле «Статус» может иметь internal name `status`).
|
||||||
|
|
||||||
|
**Запись lookup-поля:** в теле запроса ключ — `{InternalName}Id` со **скалярным** значением (напр. `{ winSupplierId: 42 }`). Суффикс `LookupId` бывает только в GET-ответах, для записи он не нужен.
|
||||||
|
|
||||||
|
**Грабли с «защищёнными» списками (Retention / event receiver).**
|
||||||
|
На некоторых списках hard `DELETE` и `MERGE/PATCH` падают с `E_FAIL` (`-2147467259, SPException`). Признак — на списке стоит политика удержания или ресивер.
|
||||||
|
|
||||||
|
- ✅ Работают: `POST` (создание), `GET` (чтение), `POST /items(N)/recycle()` (мягкое удаление — то же, что делает кнопка «Удалить» в UI).
|
||||||
|
- ❌ Не работают: `DELETE` любым синтаксисом, `MERGE/PATCH`.
|
||||||
|
- **Приём:** для «обновления» такой записи — `recycle()` + повторный `POST`, а не `MERGE`.
|
||||||
|
- Если на новом списке REST `DELETE/MERGE` падает с `E_FAIL` — первым делом пробуй `recycle()`.
|
||||||
|
|
||||||
|
**Грабли с `$expand`/`$select`:** выборка Choice-поля через `$expand` по lookup иногда даёт `400`. Решение — грузить связанные данные отдельным запросом по id и мерджить на клиенте (см. загрузку курсов валют в `⟨Feature⟩.tsx`).
|
||||||
|
|
||||||
|
**Округление денег:** округляй монетарные значения до 2 знаков перед записью (`Math.round(v*100)/100`), чтобы не улетали float-хвосты.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Внешний API и Power Automate
|
||||||
|
|
||||||
|
**Azure AD Bearer-токен** (паттерн SPFx):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const tokenProvider = await context.aadTokenProviderFactory.getTokenProvider();
|
||||||
|
const token = await tokenProvider.getToken(⟨AAD_RESOURCE_ID⟩);
|
||||||
|
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
|
```
|
||||||
|
|
||||||
|
- Внутренний API TNSU: base `https://api.tnsu.kz`, AAD Resource ID `9232d9e2-9807-4753-808f-07250e5474b0`.
|
||||||
|
- Пример готовой реализации: `pmo-records/src/webparts/pmoRecords/services/TnsuApiService.ts`.
|
||||||
|
- Требуется прописать разрешение в `package-solution.json` (`webApiPermissionRequests`) и одобрить его в API access центра администрирования SharePoint.
|
||||||
|
|
||||||
|
**Power Automate:** триггеры вызываются POST-запросом на HTTP-trigger URL. Все URL флоу — **в константах сервиса**, не в компонентах. Обрабатывай ответ и ошибки явно; вызовы, которые не должны блокировать UX (уведомления, статусы), делай non-blocking: `service.doX(...).catch(() => undefined)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Конвенции кода (обязательны)
|
||||||
|
|
||||||
|
**React-практики**
|
||||||
|
|
||||||
|
- `useEffect` — только для сайд-эффектов; бизнес-логику не клади в JSX.
|
||||||
|
- `useMemo`/`useCallback` — только при реальной необходимости.
|
||||||
|
- Контролируемые компоненты предпочтительнее неконтролируемых. Props/state не мутируем.
|
||||||
|
- Загрузку данных отменяй на unmount (флаг `cancelled`/`AbortController`), чтобы не было setState после размонтирования.
|
||||||
|
|
||||||
|
**Состояние**
|
||||||
|
|
||||||
|
- Состояние живёт максимально низко; наверх поднимаем только реально общее.
|
||||||
|
- Prop-drilling глубже 2–3 уровней → React Context (для сквозного: текущая сущность, пользователь, справочники).
|
||||||
|
- Один источник истины; изменения иммутабельны (`setItems(prev => [...prev, x])`).
|
||||||
|
|
||||||
|
**Имена и стиль**
|
||||||
|
|
||||||
|
- Компоненты — `PascalCase`; хуки — `useSomething`; переменные — `camelCase`; CSS-классы — единообразно (`kebab-case` или `camelCase`).
|
||||||
|
- Имена отражают смысл, а не реализацию (`isLoading`, а не `flag`).
|
||||||
|
|
||||||
|
**Константы и строки**
|
||||||
|
|
||||||
|
- Никаких magic-strings в логике: статусы, имена списков/полей, роуты, id ресурсов, URL — в константах/конфиге.
|
||||||
|
- Пользовательские тексты — по возможности в одном месте (задел под локализацию).
|
||||||
|
- Числовые «магические» значения — именованные константы с комментарием-смыслом.
|
||||||
|
|
||||||
|
**Ошибки и состояния**
|
||||||
|
|
||||||
|
- Явно обрабатывай `loading` / `error` / `empty`. Ошибки не скрывай. UI корректен при отсутствии данных.
|
||||||
|
|
||||||
|
**Layout**
|
||||||
|
|
||||||
|
- Адаптив; `flex`/`grid`; контейнеры по умолчанию 100% ширины; консистентные `padding`/`margin`; без magic-numbers без комментария.
|
||||||
|
|
||||||
|
**Комментарии**
|
||||||
|
|
||||||
|
- Комментируй **ПОЧЕМУ**, а не ЧТО. Не дублируй очевидный код. Сложную бизнес-логику сопровождай коротким «почему».
|
||||||
|
- Не оставляй мёртвый/закомментированный код — удаляй.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Git и коммиты
|
||||||
|
|
||||||
|
- Формат: `тип: краткое описание в настоящем времени` (`feat:`, `fix:`, `refactor:`, `chore:`, `docs:`, `test:`).
|
||||||
|
- Один коммит — одно логическое изменение. Сообщение объясняет **что** и **зачем**, а не пересказывает диф.
|
||||||
|
- Не коммить отладочный код и секреты. По истории джун должен понять ход работы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Чеклист старта нового проекта
|
||||||
|
|
||||||
|
1. `yo @microsoft/sharepoint` (SPFx 1.22.x) → WebPart с React-фреймворком. Проверь Node `22.x`.
|
||||||
|
2. Замени все `⟨плейсхолдеры⟩` в этом файле и переименуй его в `CLAUDE.md`.
|
||||||
|
3. Опиши доменную модель в `types/domain.ts`: сущности, `type Status = …`, `FORM_STEPS`.
|
||||||
|
4. Заведи `⟨Feature⟩Service` с `const LISTS = {...}` и хелперами `_li`/`_liById`.
|
||||||
|
5. Собери каркас: `WebPart.ts` → главный контейнер → `use⟨Feature⟩Form` → `Breadcrumbs` + пустые `Step1..N`.
|
||||||
|
6. Настрой persist в `sessionStorage` и чтение `entityId` из URL (+ вариант ссылки из уведомления).
|
||||||
|
7. Пропиши API-разрешения в `package-solution.json`, если нужен внешний API.
|
||||||
|
8. Для full-width: поставь `"supportsFullBleed": true` в манифесте + `:global`-обход канвы в корневом SCSS (§5.7), и клади WebPart в полноширинную секцию страницы.
|
||||||
|
9. Реализуй шаги по одному, соблюдая единый контракт props (§5.2).
|
||||||
|
|
||||||
|
## 11. TODO-заглушки (типовые места, которые обычно доделываются позже)
|
||||||
|
|
||||||
|
- `⟨Feature⟩Service`: заменить константы `LISTS` на реальные имена SP-списков.
|
||||||
|
- Загрузка файлов в библиотеку SharePoint + триггер обработки (Syntex/Flow), если есть шаг с документами.
|
||||||
|
- People picker (`NormalPeoplePicker`) для добавления согласующих.
|
||||||
|
- Вызовы Power Automate для уведомлений/сохранения статусов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Шаги формы — эталонная модель (на примере СТ)
|
||||||
|
|
||||||
|
Ниже — реальный поток из проекта `ct-create-form`. Каркас (4 шага, роли, статусы, режимы) переноси как есть, **содержимое шагов адаптируй под свою предметку.** Все 4 шага рендерятся из главного контейнера, стейт — в `use⟨Feature⟩Form`, каждый шаг получает единый контракт props (§5.2).
|
||||||
|
|
||||||
|
Метки шагов (`FORM_STEPS` в `types/domain.ts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const FORM_STEPS = [
|
||||||
|
{ step: 1, label: "Выбор позиций" },
|
||||||
|
{ step: 2, label: "Загрузка КП" },
|
||||||
|
{ step: 3, label: "Формирование СТ" },
|
||||||
|
{ step: 4, label: "Согласование" },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 1 — «Выбор позиций» (сбор исходных данных)
|
||||||
|
|
||||||
|
**Задача:** пользователь выбирает исходные записи (в СТ — позиции потребностей), из которых собирается сущность.
|
||||||
|
|
||||||
|
- Грузит данные из **внешнего API** за последние N дней (по умолчанию 30), с поиском по номеру и пагинацией (~10/страница). Загрузка — через хук с отменой на unmount (`useRequests`).
|
||||||
|
- Таблица с раскрывающимися строками (родитель → дочерние записи) и чекбоксами выбора.
|
||||||
|
- **Бизнес-ограничение:** все выбранные записи — из одного проекта. На первом чекбоксе фиксируется `selectedProject`, остальные проекты блокируются (`lockedProjectId`).
|
||||||
|
- **По «Далее»:** создаёт сущность (СТ) и её позиции в SharePoint, кладёт `selectedPositions` / `selectedProject` в `formData`, помечает шаг завершённым, проставляет `?⟨entity⟩Id` в URL.
|
||||||
|
- **Read-only:** если сущность уже на согласовании/согласована/отклонена — показывает выбранное без правки.
|
||||||
|
|
||||||
|
### Шаг 2 — «Загрузка КП» (загрузка и обогащение данных)
|
||||||
|
|
||||||
|
**Задача:** загрузить документы (коммерческие предложения) и извлечь из них структурированные данные.
|
||||||
|
|
||||||
|
- **Drag&drop загрузка файлов** в библиотеку SharePoint (`FileUploadArea`).
|
||||||
|
- После загрузки — триггер обработки документа (**Syntex / Power Automate flow**) для извлечения полей (позиции, цены, поставщик). Идёт **поллинг результата** (интервал ~5с, лимит ~5 мин; статусы файла: `uploading → extracting → flowing → done/error`).
|
||||||
|
- **Курсы валют** (`CurrencyRatesForm`): подгрузка из SP-списка «Курс Валют» / API + ручной ввод. Валюта КП запоминается на поставщике, чтобы Шаг 3 показал верные колонки.
|
||||||
|
- Поставщики **без КП** фиксируются отдельно (список «Не получены КП») с комментарием.
|
||||||
|
- **Результат в `formData`:** `suppliers`, `offers`, `currencyRates`, `uploadedKpFiles`.
|
||||||
|
|
||||||
|
### Шаг 3 — «Формирование СТ» (сведение и сравнение)
|
||||||
|
|
||||||
|
**Задача:** свести всё в сравнительную матрицу и выбрать победителя.
|
||||||
|
|
||||||
|
- **Excel-подобная матрица:** строки = позиции, столбцы = поставщики; в ячейках — цена/сумма с НДС.
|
||||||
|
- **Подсветка минимума** по строке (зелёным) и **авто-выбор рекомендованного поставщика** (наименьшая итоговая сумма) → `recommendedSupplierId`.
|
||||||
|
- Редактируемо: количество по позиции (`step3PositionQtyOverrides`), доп-позиции и доп-поставщики (`step3Extra*`), строки доставки с км (`step3DeliveryKm`), критерии (оплата/доставка/срок/сертификаты) и кастомные критерии. Конвертация единиц измерения — `utils/unitConversion`.
|
||||||
|
- **AI-комментарий** по таблице (через Power Automate flow).
|
||||||
|
- **Скрытие цен** (`isRestrictedView`) для урезанного доступа.
|
||||||
|
- При открытии **по ссылке из уведомления** этот шаг — главный: тут же показывается цепочка согласования и доступны действия «Согласовать/Отклонить».
|
||||||
|
|
||||||
|
### Шаг 4 — «Согласование» (маршрут и решение)
|
||||||
|
|
||||||
|
**Задача:** собрать цепочку согласующих и провести сущность по маршруту.
|
||||||
|
|
||||||
|
- **Конструктор цепочки:** добавление согласующих через `NormalPeoplePicker` (поиск пользователей SP), порядок, роли, право делегирования (`canAddApprover`).
|
||||||
|
- Инициатор ставит **«Рекомендую»** → цепочка активируется, статус сущности → **«На согласовании»**.
|
||||||
|
- **Диалог «Согласовать/Отклонить»** с комментарием. Статусы участников: `pending | recommends | approved | rejected | skipped`.
|
||||||
|
- **Побочные эффекты (non-blocking):** уведомления через Power Automate; запись статуса сущности и выбранного поставщика (`winSupplier`, lookup) в SP-список. Статусы сущности: `draft | kp_loading | kp_loaded | formed | on_approval | approved | rejected`.
|
||||||
|
|
||||||
|
> **Как адаптировать под новый проект:** сохрани роли (автор/инициатор → согласующие → наблюдатель), 3 режима отображения (обычный / read-only по статусу / restricted), персист+ссылку из уведомления и деривацию статуса из цепочки (`deriveCtStatus` в контейнере). Меняй только _содержимое_: какие данные собираешь на Шаге 1, что грузишь/извлекаешь на Шаге 2, что и как сравниваешь на Шаге 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ссылки на образцы (в текущем окружении разработчика)
|
||||||
|
|
||||||
|
- Боевой проект-образец: `ct-create-form` (СТ). Смотри `CtService.ts`, `useCtForm.ts`, `CtCreateForm.tsx`.
|
||||||
|
- Родственный проект той же архитектуры: `oid-create-form`.
|
||||||
|
- Пример внешнего API с AAD-токеном: `pmo-records/.../TnsuApiService.ts`.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# kps-ct-form
|
||||||
|
|
||||||
|
## 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": {
|
||||||
|
"kps-ct-form-web-part": {
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"entrypoint": "./lib/webparts/kpsCtForm/KpsCtFormWebPart.js",
|
||||||
|
"manifest": "./src/webparts/kpsCtForm/KpsCtFormWebPart.manifest.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"externals": {},
|
||||||
|
"localizedResources": {
|
||||||
|
"KpsCtFormWebPartStrings": "lib/webparts/kpsCtForm/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": "kps-ct-form",
|
||||||
|
"accessKey": "<!-- ACCESS KEY -->"
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
|
||||||
|
"solution": {
|
||||||
|
"name": "kps-ct-form-client-side-solution",
|
||||||
|
"id": "340fb0b3-70e1-4aba-904d-94651e35fe80",
|
||||||
|
"version": "1.0.0.0",
|
||||||
|
"includeClientSideAssets": true,
|
||||||
|
"skipFeatureDeployment": true,
|
||||||
|
"isDomainIsolated": false,
|
||||||
|
"webApiPermissionRequests": [
|
||||||
|
{
|
||||||
|
"resource": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
|
"scope": "user_impersonation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resource": "Microsoft Flow Service",
|
||||||
|
"scope": "Flows.Read.All"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"developer": {
|
||||||
|
"name": "",
|
||||||
|
"websiteUrl": "",
|
||||||
|
"privacyUrl": "",
|
||||||
|
"termsOfUseUrl": "",
|
||||||
|
"mpnId": "Undefined-1.22.2"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"shortDescription": {
|
||||||
|
"default": "kps-ct-form description"
|
||||||
|
},
|
||||||
|
"longDescription": {
|
||||||
|
"default": "kps-ct-form description"
|
||||||
|
},
|
||||||
|
"screenshotPaths": [],
|
||||||
|
"videoUrl": "",
|
||||||
|
"categories": []
|
||||||
|
},
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"title": "kps-ct-form Feature",
|
||||||
|
"description": "The feature that activates elements of the kps-ct-form solution.",
|
||||||
|
"id": "2161c099-d0c8-4e19-a596-6a93d09eb523",
|
||||||
|
"version": "1.0.0.0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"zippedPackage": "solution/kps-ct-form.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/sites/kps/_layouts/workbench.aspx"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "@microsoft/spfx-web-build-rig/profiles/default/config/typescript.json",
|
||||||
|
"staticAssetsToCopy": {
|
||||||
|
"fileExtensions": [".resx", ".jpg", ".png", ".woff", ".eot", ".ttf", ".svg", ".gif"],
|
||||||
|
"includeGlobs": ["webparts/*/loc/*.js"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
|
||||||
|
"cdnBasePath": "<!-- PATH TO CDN -->"
|
||||||
|
}
|
||||||
+282
@@ -0,0 +1,282 @@
|
|||||||
|
# =============================================================
|
||||||
|
# SPFx + Heft project bootstrap script (PowerShell)
|
||||||
|
# Запустите из корня вашей ПУСТОЙ папки проекта:
|
||||||
|
# .\setup-spfx-heft.ps1
|
||||||
|
#
|
||||||
|
# Если запуск скриптов заблокирован, выполните сначала:
|
||||||
|
# Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# ---- Цвета вывода ----
|
||||||
|
function Info { param($msg) Write-Host "[INFO] $msg" -ForegroundColor Cyan }
|
||||||
|
function Success { param($msg) Write-Host "[OK] $msg" -ForegroundColor Green }
|
||||||
|
function Warn { param($msg) Write-Host "[WARN] $msg" -ForegroundColor Yellow }
|
||||||
|
function Header { param($msg) Write-Host "`n$msg" -ForegroundColor White }
|
||||||
|
|
||||||
|
Header "================================================="
|
||||||
|
Header " SPFx + Heft — автоматическая инициализация"
|
||||||
|
Header "=================================================`n"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 1. Проверка Node.js (SPFx 1.20+ требует Node 18+)
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
Info "Проверка Node.js..."
|
||||||
|
|
||||||
|
try {
|
||||||
|
$nodeVersion = node -e "process.stdout.write(process.version)" 2>&1
|
||||||
|
} catch {
|
||||||
|
Warn "Node.js не найден. Установите Node 18 LTS: https://nodejs.org"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$nodeMajor = [int]($nodeVersion.TrimStart('v').Split('.')[0])
|
||||||
|
if ($nodeMajor -lt 18) {
|
||||||
|
Warn "Обнаружен Node.js $nodeVersion. SPFx 1.20+ требует Node 18.x или выше."
|
||||||
|
Warn "Установите Node 18 LTS через nvm-windows или с https://nodejs.org"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Success "Node.js $nodeVersion — подходит."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 2. Вспомогательная функция: установка глобального пакета
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
function Install-GlobalIfMissing {
|
||||||
|
param(
|
||||||
|
[string]$Package,
|
||||||
|
[string]$Command = ""
|
||||||
|
)
|
||||||
|
if ($Command -eq "") { $Command = $Package.Split('/')[-1] }
|
||||||
|
|
||||||
|
if (-not (Get-Command $Command -ErrorAction SilentlyContinue)) {
|
||||||
|
Info "Устанавливаем $Package глобально..."
|
||||||
|
npm install -g $Package
|
||||||
|
Success "$Package установлен."
|
||||||
|
} else {
|
||||||
|
Success "$Package уже установлен."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Install-GlobalIfMissing "@microsoft/generator-sharepoint" "yo"
|
||||||
|
Install-GlobalIfMissing "yo" "yo"
|
||||||
|
Install-GlobalIfMissing "@rushstack/heft" "heft"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 3. Создание package.json
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
Info "Создаём package.json..."
|
||||||
|
|
||||||
|
$packageJson = @'
|
||||||
|
{
|
||||||
|
"name": "spfx-heft-project",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "heft build",
|
||||||
|
"bundle": "heft build && gulp bundle --ship",
|
||||||
|
"package-solution": "heft build && gulp package-solution --ship",
|
||||||
|
"serve": "heft build && gulp serve",
|
||||||
|
"clean": "heft clean"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"ajv": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
|
||||||
|
$packageJson | Set-Content -Encoding UTF8 "package.json"
|
||||||
|
Success "package.json создан."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 4. Создание структуры папок
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
Info "Создаём структуру папок..."
|
||||||
|
New-Item -ItemType Directory -Force -Path "config" | Out-Null
|
||||||
|
New-Item -ItemType Directory -Force -Path "src\webparts" | Out-Null
|
||||||
|
Success "Папки созданы."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 5. Запуск Yeoman-генератора SPFx
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
Info "Запускаем Yeoman-генератор SPFx..."
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host ">>> Сейчас откроется интерактивный генератор Yeoman." -ForegroundColor Yellow
|
||||||
|
Write-Host ">>> Выберите тип: WebPart, Framework: No framework (или React)." -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
yo "@microsoft/sharepoint"
|
||||||
|
|
||||||
|
npm uninstall @microsoft/sp-build-web ajv gulp
|
||||||
|
npm uninstall @microsoft/rush-stack-compiler-5.3
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 6. Установка Heft и Rush Stack зависимостей
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
Info "Устанавливаем Heft и плагины..."
|
||||||
|
|
||||||
|
$devDeps = @(
|
||||||
|
"@microsoft/spfx-web-build-rig@1.22.1",
|
||||||
|
"@microsoft/spfx-heft-plugins@1.22.1",
|
||||||
|
"@microsoft/eslint-config-spfx@1.22.1",
|
||||||
|
"@microsoft/eslint-plugin-spfx@1.22.17",
|
||||||
|
"@microsoft/sp-module-interfaces@1.22.1",
|
||||||
|
"@rushstack/eslint-config@4.5.2",
|
||||||
|
"@rushstack/heft@1.1.2",
|
||||||
|
"@types/heft-jest@1.0.2",
|
||||||
|
"@typescript-eslint/parser@8.46.2"
|
||||||
|
)
|
||||||
|
|
||||||
|
npm install --save-dev @($devDeps) --save-exact --force --legacy-peer-deps
|
||||||
|
npm install typescript@~5.8.0 --save-dev
|
||||||
|
|
||||||
|
Success "Heft и плагины установлены."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 7. config/rig.json
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
Info "Создаём config\rig.json..."
|
||||||
|
New-Item -ItemType Directory -Force -Path "config" | Out-Null
|
||||||
|
|
||||||
|
$rigJson = @'
|
||||||
|
{
|
||||||
|
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
|
||||||
|
"rigPackageName": "@microsoft/spfx-web-build-rig"
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
|
||||||
|
$rigJson | Set-Content -Encoding UTF8 "config\rig.json"
|
||||||
|
Success "config\rig.json создан."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 8. config/sass.json
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
Info "Создаём config\sass.json..."
|
||||||
|
New-Item -ItemType Directory -Force -Path "config" | Out-Null
|
||||||
|
|
||||||
|
$sassJson = @'
|
||||||
|
{
|
||||||
|
"$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft-sass-plugin.schema.json",
|
||||||
|
"extends": "@microsoft/spfx-web-build-rig/profiles/default/config/sass.json"
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
|
||||||
|
$sassJson | Set-Content -Encoding UTF8 "config\sass.json"
|
||||||
|
Success "config\sass.json создан."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 9. config/typescript.json
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
Info "Создаём config\typescript.json..."
|
||||||
|
New-Item -ItemType Directory -Force -Path "config" | Out-Null
|
||||||
|
|
||||||
|
$typescriptJson = @'
|
||||||
|
{
|
||||||
|
"extends": "@microsoft/spfx-web-build-rig/profiles/default/config/typescript.json",
|
||||||
|
"staticAssetsToCopy": {
|
||||||
|
"fileExtensions": [".resx", ".jpg", ".png", ".woff", ".eot", ".ttf", ".svg", ".gif"],
|
||||||
|
"includeGlobs": ["webparts/*/loc/*.js"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
|
||||||
|
$typescriptJson | Set-Content -Encoding UTF8 "config\typescript.json"
|
||||||
|
Success "config\typescript.json создан."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 8. tsconfig.json (если Yeoman ещё не создал)
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
if (-not (Test-Path "tsconfig.json")) {
|
||||||
|
Info "Создаём tsconfig.json..."
|
||||||
|
|
||||||
|
$tsConfig = @'
|
||||||
|
{
|
||||||
|
"extends": "./node_modules/@microsoft/spfx-web-build-rig/profiles/default/tsconfig-base.json"
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
|
||||||
|
$tsConfig | Set-Content -Encoding UTF8 "tsconfig.json"
|
||||||
|
Success "tsconfig.json создан."
|
||||||
|
} else {
|
||||||
|
Success "tsconfig.json уже существует, пропускаем."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 9. .eslintrc.js (если не создан)
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
if (-not (Test-Path ".eslintrc.js")) {
|
||||||
|
Info "Создаём .eslintrc.js..."
|
||||||
|
|
||||||
|
$eslintConfig = @'
|
||||||
|
require('@rushstack/eslint-config/patch/modern-module-resolution');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
extends: ['@microsoft/eslint-config-spfx/lib/profiles/default'],
|
||||||
|
parserOptions: { tsconfigRootDir: __dirname }
|
||||||
|
};
|
||||||
|
'@
|
||||||
|
|
||||||
|
$eslintConfig | Set-Content -Encoding UTF8 ".eslintrc.js"
|
||||||
|
Success ".eslintrc.js создан."
|
||||||
|
} else {
|
||||||
|
Success ".eslintrc.js уже существует, пропускаем."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 10. .gitignore
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
Info "Создаём .gitignore..."
|
||||||
|
|
||||||
|
$gitignore = @'
|
||||||
|
node_modules/
|
||||||
|
lib/
|
||||||
|
dist/
|
||||||
|
temp/
|
||||||
|
*.sppkg
|
||||||
|
.heft/
|
||||||
|
sharepoint/solution/
|
||||||
|
'@
|
||||||
|
|
||||||
|
$gitignore | Set-Content -Encoding UTF8 ".gitignore"
|
||||||
|
Success ".gitignore создан."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# 11. Финальный npm install
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
$devDeps = @(
|
||||||
|
"@microsoft/sp-component-base@1.22.1",
|
||||||
|
"@microsoft/sp-core-library@1.22.1",
|
||||||
|
"@microsoft/sp-lodash-subset@1.22.1",
|
||||||
|
"@microsoft/sp-office-ui-fabric-core@1.22.1",
|
||||||
|
"@microsoft/sp-property-pane@1.22.1",
|
||||||
|
"@microsoft/sp-webpart-base@1.22.1"
|
||||||
|
)
|
||||||
|
|
||||||
|
Info "Финальный npm install..."
|
||||||
|
npm install @($devDeps) --save-exact --legacy-peer-deps
|
||||||
|
Success "Все зависимости установлены."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# Готово
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=================================================" -ForegroundColor Green
|
||||||
|
Write-Host " Проект успешно инициализирован!" -ForegroundColor Green
|
||||||
|
Write-Host "=================================================" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Доступные команды:" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " npm run serve " -NoNewline -ForegroundColor Cyan
|
||||||
|
Write-Host "— локальная разработка (gulp serve)"
|
||||||
|
Write-Host " npm run build " -NoNewline -ForegroundColor Cyan
|
||||||
|
Write-Host "— сборка через Heft"
|
||||||
|
Write-Host " npm run bundle " -NoNewline -ForegroundColor Cyan
|
||||||
|
Write-Host "— бандлинг для продакшна"
|
||||||
|
Write-Host " npm run package-solution " -NoNewline -ForegroundColor Cyan
|
||||||
|
Write-Host "— создание .sppkg пакета"
|
||||||
|
Write-Host " npm run clean " -NoNewline -ForegroundColor Cyan
|
||||||
|
Write-Host "— очистка артефактов сборки"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Следующий шаг: " -NoNewline -ForegroundColor Yellow
|
||||||
|
Write-Host "npm run serve" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
Generated
+20036
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "kps-ct-form",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.14.0 < 23.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "heft test --clean --production && heft package-solution --production",
|
||||||
|
"start": "heft start --clean",
|
||||||
|
"clean": "heft clean",
|
||||||
|
"eject-webpack": "heft eject-webpack"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fluentui/react": "^8.106.4",
|
||||||
|
"@microsoft/sp-component-base": "1.22.1",
|
||||||
|
"@microsoft/sp-core-library": "1.22.1",
|
||||||
|
"@microsoft/sp-lodash-subset": "1.22.1",
|
||||||
|
"@microsoft/sp-office-ui-fabric-core": "1.22.1",
|
||||||
|
"@microsoft/sp-property-pane": "1.22.1",
|
||||||
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
|
"react": "17.0.1",
|
||||||
|
"react-dom": "17.0.1",
|
||||||
|
"tslib": "2.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@microsoft/eslint-config-spfx": "1.22.2",
|
||||||
|
"@microsoft/eslint-plugin-spfx": "1.22.2",
|
||||||
|
"@microsoft/sp-module-interfaces": "1.22.2",
|
||||||
|
"@microsoft/spfx-heft-plugins": "1.22.2",
|
||||||
|
"@microsoft/spfx-web-build-rig": "1.22.2",
|
||||||
|
"@rushstack/eslint-config": "4.5.2",
|
||||||
|
"@rushstack/heft": "1.1.2",
|
||||||
|
"@types/heft-jest": "1.0.2",
|
||||||
|
"@types/react": "17.0.45",
|
||||||
|
"@types/react-dom": "17.0.17",
|
||||||
|
"@types/webpack-env": "~1.15.2",
|
||||||
|
"@typescript-eslint/parser": "8.46.2",
|
||||||
|
"css-loader": "~7.1.2",
|
||||||
|
"eslint": "8.57.1",
|
||||||
|
"eslint-plugin-react-hooks": "4.3.0",
|
||||||
|
"typescript": "~5.8.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@rushstack/heft": "1.1.2"
|
||||||
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"@types/react": "17.0.45"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
|
||||||
|
"id": "62bec383-4744-4235-8d54-15d168e0282b",
|
||||||
|
"alias": "KpsCtFormWebPart",
|
||||||
|
"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,
|
||||||
|
|
||||||
|
// Разрешает класть WebPart в полноширинную секцию страницы (full-bleed). См. §5.7 гайда.
|
||||||
|
"supportsFullBleed": true,
|
||||||
|
|
||||||
|
"preconfiguredEntries": [{
|
||||||
|
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Advanced
|
||||||
|
"group": { "default": "Advanced" },
|
||||||
|
"title": { "default": "kps-ct-form" },
|
||||||
|
"description": { "default": "kps-ct-form description" },
|
||||||
|
"officeFabricIconFontName": "Page",
|
||||||
|
"properties": {
|
||||||
|
"description": "kps-ct-form"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as ReactDom from 'react-dom';
|
||||||
|
import { Version } from '@microsoft/sp-core-library';
|
||||||
|
import { type IPropertyPaneConfiguration, PropertyPaneTextField } from '@microsoft/sp-property-pane';
|
||||||
|
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
||||||
|
import { IReadonlyTheme } from '@microsoft/sp-component-base';
|
||||||
|
import { initializeIcons } from '@fluentui/react';
|
||||||
|
|
||||||
|
import * as strings from 'KpsCtFormWebPartStrings';
|
||||||
|
import KpsCtForm from './components/KpsCtForm';
|
||||||
|
import { IKpsCtFormProps } from './components/IKpsCtFormProps';
|
||||||
|
|
||||||
|
export interface IKpsCtFormWebPartProps {
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Точка входа: пробрасывает context, userDisplayName, isDarkTheme в React (§4).
|
||||||
|
export default class KpsCtFormWebPart extends BaseClientSideWebPart<IKpsCtFormWebPartProps> {
|
||||||
|
private _isDarkTheme: boolean = false;
|
||||||
|
|
||||||
|
// Регистрируем иконки Fluent (стрелки/галочки/загрузка). disableWarnings — на случай,
|
||||||
|
// если хост-страница уже их зарегистрировала (иначе шумит в консоль).
|
||||||
|
protected onInit(): Promise<void> {
|
||||||
|
initializeIcons(undefined, { disableWarnings: true });
|
||||||
|
return super.onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): void {
|
||||||
|
const element: React.ReactElement<IKpsCtFormProps> = React.createElement(KpsCtForm, {
|
||||||
|
context: this.context,
|
||||||
|
userDisplayName: this.context.pageContext.user.displayName,
|
||||||
|
isDarkTheme: this._isDarkTheme
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDom.render(element, this.domElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onThemeChanged(currentTheme: IReadonlyTheme | undefined): void {
|
||||||
|
if (!currentTheme) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isDarkTheme = !!currentTheme.isInverted;
|
||||||
|
const { semanticColors } = currentTheme;
|
||||||
|
|
||||||
|
if (semanticColors) {
|
||||||
|
this.domElement.style.setProperty('--bodyText', semanticColors.bodyText || null);
|
||||||
|
this.domElement.style.setProperty('--link', semanticColors.link || null);
|
||||||
|
this.domElement.style.setProperty('--linkHovered', semanticColors.linkHovered || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onDispose(): void {
|
||||||
|
ReactDom.unmountComponentAtNode(this.domElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get dataVersion(): Version {
|
||||||
|
return Version.parse('1.0');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
|
||||||
|
return {
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
header: {
|
||||||
|
description: strings.PropertyPaneDescription
|
||||||
|
},
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
groupName: strings.BasicGroupName,
|
||||||
|
groupFields: [
|
||||||
|
PropertyPaneTextField('description', {
|
||||||
|
label: strings.DescriptionFieldLabel
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,71 @@
|
|||||||
|
@import '../common/theme';
|
||||||
|
|
||||||
|
.stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Один шаг: кружок + подпись под ним.
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 6px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid $border-strong;
|
||||||
|
background: $hover;
|
||||||
|
color: $muted;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed {
|
||||||
|
border-color: $ok;
|
||||||
|
background: $ok;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current {
|
||||||
|
border-color: $accent;
|
||||||
|
background: $accent;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $muted;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.labelCurrent {
|
||||||
|
color: $accent;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Линия между кружками, выровнена по центру кружка (высота 28 → отступ ~13px).
|
||||||
|
.connector {
|
||||||
|
flex: 0 0 40px;
|
||||||
|
height: 2px;
|
||||||
|
margin-top: 13px;
|
||||||
|
background: $border-strong;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Icon } from '@fluentui/react';
|
||||||
|
import styles from './Breadcrumbs.module.scss';
|
||||||
|
import { FormStep, FormStepMeta } from '../types/domain';
|
||||||
|
|
||||||
|
export interface BreadcrumbsProps {
|
||||||
|
steps: ReadonlyArray<FormStepMeta>;
|
||||||
|
currentStep: FormStep;
|
||||||
|
completedSteps: FormStep[];
|
||||||
|
highestStep: FormStep; // максимально доступный шаг (§5.5)
|
||||||
|
onStepClick: (step: FormStep) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Степпер прогресса (эталон): кружки с номером/галочкой, подпись снизу, коннекторы между.
|
||||||
|
// Кликабельны только шаги в пределах highestStep (§5.5).
|
||||||
|
export function Breadcrumbs(props: BreadcrumbsProps): JSX.Element {
|
||||||
|
const { steps, currentStep, completedSteps, highestStep, onStepClick } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className={styles.stepper} aria-label="Прогресс формы">
|
||||||
|
{steps.map(({ step, label }, index) => {
|
||||||
|
const isCompleted = completedSteps.indexOf(step) !== -1;
|
||||||
|
const isCurrent = step === currentStep;
|
||||||
|
const isNavigable = step <= highestStep;
|
||||||
|
|
||||||
|
const circleClass = [
|
||||||
|
styles.circle,
|
||||||
|
isCompleted ? styles.completed : '',
|
||||||
|
isCurrent ? styles.current : ''
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={step}>
|
||||||
|
{index > 0 ? <span className={styles.connector} /> : undefined}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.step}
|
||||||
|
disabled={!isNavigable}
|
||||||
|
aria-current={isCurrent ? 'step' : undefined}
|
||||||
|
onClick={() => onStepClick(step)}
|
||||||
|
>
|
||||||
|
<span className={circleClass}>
|
||||||
|
{isCompleted ? <Icon iconName="CheckMark" /> : step}
|
||||||
|
</span>
|
||||||
|
<span className={isCurrent ? `${styles.label} ${styles.labelCurrent}` : styles.label}>{label}</span>
|
||||||
|
</button>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
|
||||||
|
// Props корневого компонента: контекст SPFx + данные пользователя/темы (§4).
|
||||||
|
export interface IKpsCtFormProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
userDisplayName: string;
|
||||||
|
isDarkTheme: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
@import '~@fluentui/react/dist/sass/References.scss';
|
||||||
|
|
||||||
|
// Full-width (§5.7): снимаем max-width канвы SharePoint и у воркбенча (dev), и на живой странице.
|
||||||
|
// !important здесь оправдан — перебиваем встроенные стили канвы, иначе неотключаемые.
|
||||||
|
// Если после обновления платформы ширина «вернётся» — проверить актуальные селекторы в DevTools.
|
||||||
|
:global {
|
||||||
|
#workbenchPageContent,
|
||||||
|
.CanvasComponent.LCS .CanvasZone {
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.root {
|
||||||
|
min-height: 100vh; // тянем на всю высоту вьюпорта
|
||||||
|
color: var(--bodyText);
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Единый вид состояний loading/error/empty (§8).
|
||||||
|
.state {
|
||||||
|
padding: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #a4262c;
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import styles from './KpsCtForm.module.scss';
|
||||||
|
import { IKpsCtFormProps } from './IKpsCtFormProps';
|
||||||
|
import { KpsCtFormService } from '../services/KpsCtFormService';
|
||||||
|
import { useKpsCtForm } from './hooks/useKpsCtForm';
|
||||||
|
import { Breadcrumbs } from './Breadcrumbs/Breadcrumbs';
|
||||||
|
import { FormHeader } from './common/FormHeader';
|
||||||
|
import { Step1SelectPositions } from './Step1SelectPositions/Step1SelectPositions';
|
||||||
|
import { Step2LoadKp } from './Step2LoadKp/Step2LoadKp';
|
||||||
|
import { Step3BuildCt } from './Step3BuildCt/Step3BuildCt';
|
||||||
|
import { Step4Approval } from './Step4Approval/Step4Approval';
|
||||||
|
import { loadFormState, readEntityFromUrl, sessionKey } from './utils/session';
|
||||||
|
import {
|
||||||
|
CtFormData,
|
||||||
|
CtStatus,
|
||||||
|
CT_STATUS_META,
|
||||||
|
EMPTY_FORM_DATA,
|
||||||
|
FORM_STEPS,
|
||||||
|
FormStep,
|
||||||
|
READ_ONLY_STATUSES,
|
||||||
|
StepProps
|
||||||
|
} from './types/domain';
|
||||||
|
|
||||||
|
// Главный контейнер (§4): мемоизирует сервис, держит стейт через хук, рендерит шаги.
|
||||||
|
export default function KpsCtForm(props: IKpsCtFormProps): JSX.Element {
|
||||||
|
const { context, userDisplayName } = props;
|
||||||
|
|
||||||
|
// Единственное место создания сервиса — мемоизируем на context (§5.3).
|
||||||
|
const service = useMemo(() => new KpsCtFormService(context), [context]);
|
||||||
|
|
||||||
|
// id сущности и признак ссылки из уведомления читаем из URL один раз (§5.4).
|
||||||
|
const url = useMemo(() => readEntityFromUrl(), []);
|
||||||
|
|
||||||
|
const form = useKpsCtForm(url.ctId);
|
||||||
|
const { formData, currentStep, completedSteps, highestStep } = form;
|
||||||
|
|
||||||
|
// Загружаем свежие данные из SharePoint только если это ссылка из уведомления
|
||||||
|
// ИЛИ в сессии пусто — иначе не затираем несохранённые правки (§5.4).
|
||||||
|
const shouldLoadFromSp = useMemo(
|
||||||
|
() => !!url.ctId && (url.isNotificationLink || loadFormState(sessionKey(url.ctId)) === undefined),
|
||||||
|
[url.ctId, url.isNotificationLink]
|
||||||
|
);
|
||||||
|
|
||||||
|
const spLoad = useSharePointLoad(service, form.replaceFormData, form.goToStep, form.markStepComplete, url, shouldLoadFromSp);
|
||||||
|
|
||||||
|
// Режимы отображения (§5.5): read-only по статусу + отдельный флаг ограниченного доступа.
|
||||||
|
const status: CtStatus = deriveCtStatus(formData);
|
||||||
|
const isReadOnly = READ_ONLY_STATUSES.indexOf(status) !== -1;
|
||||||
|
const isRestrictedView = false; // TODO: включать по компании/роли пользователя.
|
||||||
|
|
||||||
|
if (spLoad.isLoading) {
|
||||||
|
return <div className={styles.state}>Загрузка…</div>;
|
||||||
|
}
|
||||||
|
if (spLoad.error) {
|
||||||
|
return <div className={`${styles.state} ${styles.error}`}>Ошибка загрузки: {spLoad.error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepProps: StepProps = {
|
||||||
|
service,
|
||||||
|
formData,
|
||||||
|
onSave: form.updateFormData,
|
||||||
|
onNext: form.next,
|
||||||
|
onPrev: form.prev,
|
||||||
|
onStepComplete: form.markStepComplete,
|
||||||
|
isReadOnly,
|
||||||
|
isRestrictedView,
|
||||||
|
userDisplayName,
|
||||||
|
isNotificationLink: url.isNotificationLink
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = formData.ctId ? `Сравнительная таблица №${formData.ctId}` : 'Новая сравнительная таблица';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.root}>
|
||||||
|
<div className={styles.inner}>
|
||||||
|
<FormHeader title={title} statusLabel={CT_STATUS_META[status].label} statusKind={CT_STATUS_META[status].kind} />
|
||||||
|
<Breadcrumbs
|
||||||
|
steps={FORM_STEPS}
|
||||||
|
currentStep={currentStep}
|
||||||
|
completedSteps={completedSteps}
|
||||||
|
highestStep={highestStep}
|
||||||
|
onStepClick={form.goToStep}
|
||||||
|
/>
|
||||||
|
{renderStep(currentStep, stepProps)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рендер текущего шага. Все шаги получают единый контракт props (§5.2).
|
||||||
|
function renderStep(step: FormStep, stepProps: StepProps): JSX.Element {
|
||||||
|
switch (step) {
|
||||||
|
case 1:
|
||||||
|
return <Step1SelectPositions {...stepProps} />;
|
||||||
|
case 2:
|
||||||
|
return <Step2LoadKp {...stepProps} />;
|
||||||
|
case 3:
|
||||||
|
return <Step3BuildCt {...stepProps} />;
|
||||||
|
case 4:
|
||||||
|
return <Step4Approval {...stepProps} />;
|
||||||
|
default:
|
||||||
|
return <React.Fragment />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Деривация статуса сущности из цепочки согласования (§12).
|
||||||
|
// TODO: реальная логика (pending/recommends/approved/rejected → статус СТ). Пока — статус из formData.
|
||||||
|
function deriveCtStatus(formData: CtFormData): CtStatus {
|
||||||
|
return formData.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Самый дальний «заполненный» шаг существующей СТ — для приземления при входе по URL (режим редактирования).
|
||||||
|
// По фактическим данным реконструкции: цепочка/на согласовании → 4, есть офферы → 3, есть поставщики → 2,
|
||||||
|
// иначе (есть позиции) → 1.
|
||||||
|
function furthestFilledStep(data: CtFormData): FormStep {
|
||||||
|
if (data.approvers.length > 0 || data.status === 'on_approval' || data.status === 'approved' || data.status === 'rejected') {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
if (data.offers.length > 0) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
if (data.suppliers.length > 0) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpLoadState {
|
||||||
|
isLoading: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подгрузка сущности из SharePoint (ссылка из уведомления / восстановление при пустой сессии).
|
||||||
|
// Отменяет setState на unmount (§8). По уведомлению открывает Шаг 3 — он главный (§12).
|
||||||
|
function useSharePointLoad(
|
||||||
|
service: KpsCtFormService,
|
||||||
|
replaceFormData: (data: CtFormData) => void,
|
||||||
|
goToStep: (step: FormStep) => void,
|
||||||
|
markStepComplete: (step: FormStep) => void,
|
||||||
|
url: { ctId?: number; isNotificationLink: boolean },
|
||||||
|
shouldLoad: boolean
|
||||||
|
): SpLoadState {
|
||||||
|
const [state, setState] = useState<SpLoadState>({ isLoading: shouldLoad });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldLoad || !url.ctId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setState({ isLoading: true });
|
||||||
|
|
||||||
|
const run = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const data = await service.loadCt(url.ctId as number);
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loaded = { ...EMPTY_FORM_DATA, ...data, ctId: url.ctId };
|
||||||
|
replaceFormData(loaded);
|
||||||
|
if (url.isNotificationLink) {
|
||||||
|
// Согласующий по ссылке: открываем Шаг 3 (главный) и открываем навигацию — Шаги 1–3 к этому
|
||||||
|
// моменту уже пройдены инициатором (СТ «на согласовании»), поэтому помечаем их пройденными
|
||||||
|
// (→ highestStep=4, доступны все шаги), как в эталоне.
|
||||||
|
goToStep(3);
|
||||||
|
markStepComplete(1);
|
||||||
|
markStepComplete(2);
|
||||||
|
markStepComplete(3);
|
||||||
|
} else {
|
||||||
|
// Обычный вход по URL (режим редактирования): открываем навигацию по фактическому прогрессу
|
||||||
|
// и приземляем на самый дальний заполненный шаг.
|
||||||
|
const furthest = furthestFilledStep(loaded);
|
||||||
|
for (let s = 1; s <= Math.min(furthest, 3); s++) {
|
||||||
|
markStepComplete(s as FormStep);
|
||||||
|
}
|
||||||
|
if (loaded.status === 'approved' || loaded.status === 'rejected') {
|
||||||
|
markStepComplete(4);
|
||||||
|
}
|
||||||
|
goToStep(furthest);
|
||||||
|
}
|
||||||
|
setState({ isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setState({ isLoading: false, error: (error as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// run() сам ловит ошибки внутрь state; .catch — чтобы промис не «висел» необработанным.
|
||||||
|
run().catch(() => undefined);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [service, replaceFormData, goToStep, markStepComplete, url.ctId, url.isNotificationLink, shouldLoad]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Checkbox } from '@fluentui/react';
|
||||||
|
import styles from './Step1SelectPositions.module.scss';
|
||||||
|
import { PurchaseRequestDetail } from '../types/domain';
|
||||||
|
import { UseStep1SelectionResult } from './useStep1Selection';
|
||||||
|
|
||||||
|
interface RequestLinesProps {
|
||||||
|
detail: PurchaseRequestDetail;
|
||||||
|
selection: UseStep1SelectionResult;
|
||||||
|
disabled?: boolean; // форма только для чтения
|
||||||
|
}
|
||||||
|
|
||||||
|
// Позиции (строки) заявки: таблица с чекбоксами. То, что попадёт в СТ.
|
||||||
|
// Цены на Шаге 1 не показываем — они появятся на Шагах 2–3 (КП/сравнение).
|
||||||
|
export function RequestLines({ detail, selection, disabled }: RequestLinesProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className={styles.lines}>
|
||||||
|
<div className={`${styles.lineRow} ${styles.lineHead}`}>
|
||||||
|
<span />
|
||||||
|
<span>№</span>
|
||||||
|
<span>Код</span>
|
||||||
|
<span>Наименование</span>
|
||||||
|
<span>Кат. №</span>
|
||||||
|
<span>Кол-во</span>
|
||||||
|
<span>Ед. изм.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detail.lines.map((line) => (
|
||||||
|
<div key={line.id} className={styles.lineRow}>
|
||||||
|
<Checkbox
|
||||||
|
checked={selection.selectedIds.has(line.id)}
|
||||||
|
onChange={() => selection.toggleLine(detail, line)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span>{line.lineNo}</span>
|
||||||
|
<span>{line.code || '—'}</span>
|
||||||
|
<span className={styles.lineName}>{line.name}</span>
|
||||||
|
<span>{line.catalogNumber || '—'}</span>
|
||||||
|
<span>{line.quantity}</span>
|
||||||
|
<span>{line.unit}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Checkbox, Icon } from '@fluentui/react';
|
||||||
|
import styles from './Step1SelectPositions.module.scss';
|
||||||
|
import { Pill } from '../common/Pill';
|
||||||
|
import { PurchaseRequest, PurchaseRequestDetail } from '../types/domain';
|
||||||
|
import { RequestState } from '../hooks/useRequests';
|
||||||
|
import { UseStep1SelectionResult } from './useStep1Selection';
|
||||||
|
import { RequestLines } from './RequestLines';
|
||||||
|
import { formatDateTime } from '../utils/format';
|
||||||
|
|
||||||
|
interface RequestRowProps {
|
||||||
|
request: PurchaseRequest;
|
||||||
|
expanded: boolean;
|
||||||
|
disabled: boolean; // заявка другого проекта — выбор недоступен (фиксация одного проекта)
|
||||||
|
detailState?: RequestState<PurchaseRequestDetail>;
|
||||||
|
selection: UseStep1SelectionResult;
|
||||||
|
isReadOnly?: boolean;
|
||||||
|
onToggleExpand: () => void;
|
||||||
|
onToggleSelectAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Раскрытое тело заявки: состояния загрузки/ошибки/пусто или таблица позиций (§8).
|
||||||
|
function renderBody(
|
||||||
|
detailState: RequestState<PurchaseRequestDetail> | undefined,
|
||||||
|
selection: UseStep1SelectionResult,
|
||||||
|
isReadOnly?: boolean
|
||||||
|
): JSX.Element {
|
||||||
|
if (!detailState || detailState.isLoading) {
|
||||||
|
return <div className={styles.reqBody}>Загрузка позиций…</div>;
|
||||||
|
}
|
||||||
|
if (detailState.error) {
|
||||||
|
return <div className={`${styles.reqBody} ${styles.error}`}>Ошибка: {detailState.error.message}</div>;
|
||||||
|
}
|
||||||
|
if (!detailState.data || detailState.data.lines.length === 0) {
|
||||||
|
return <div className={styles.reqBody}>В заявке нет позиций.</div>;
|
||||||
|
}
|
||||||
|
return <RequestLines detail={detailState.data} selection={selection} disabled={isReadOnly} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заявка (потребность) — строка таблицы + раскрытие в позиции по клику.
|
||||||
|
export function RequestRow(props: RequestRowProps): JSX.Element {
|
||||||
|
const { request, expanded, disabled, detailState, selection, isReadOnly, onToggleExpand, onToggleSelectAll } = props;
|
||||||
|
|
||||||
|
const detail = detailState?.data;
|
||||||
|
const total = detail?.lines.length ?? 0;
|
||||||
|
const allSelected = detail !== undefined && selection.isRequestFullySelected(detail);
|
||||||
|
const someSelected = selection.selectedCountForRequest(request.id) > 0 && !allSelected;
|
||||||
|
|
||||||
|
const onKeyDown = (event: React.KeyboardEvent): void => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
onToggleExpand();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={disabled ? `${styles.request} ${styles.requestDisabled}` : styles.request}>
|
||||||
|
<div
|
||||||
|
className={styles.trow}
|
||||||
|
role="button"
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={disabled ? undefined : onToggleExpand}
|
||||||
|
onKeyDown={disabled ? undefined : onKeyDown}
|
||||||
|
>
|
||||||
|
<span className={styles.cellCheck} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
indeterminate={someSelected}
|
||||||
|
disabled={disabled || isReadOnly}
|
||||||
|
onChange={onToggleSelectAll}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<Icon iconName={expanded ? 'ChevronDown' : 'ChevronRight'} className={styles.chevron} />
|
||||||
|
<span className={styles.reqNumber}>{request.number}</span>
|
||||||
|
<span className={styles.ellipsis}>{request.projectName || '—'}</span>
|
||||||
|
<span className={styles.ellipsis}>{request.vehicleName || '—'}</span>
|
||||||
|
<span className={styles.ellipsis}>{request.initiatorFullName || '—'}</span>
|
||||||
|
<span>{formatDateTime(request.createdAt)}</span>
|
||||||
|
<span className={styles.cellPositions}>{detail ? <Pill kind="neutral">{total}</Pill> : '—'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded ? renderBody(detailState, selection, isReadOnly) : undefined}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Checkbox, Icon } from '@fluentui/react';
|
||||||
|
import styles from './Step1SelectPositions.module.scss';
|
||||||
|
import { Pill } from '../common/Pill';
|
||||||
|
import { SelectedPosition } from '../types/domain';
|
||||||
|
|
||||||
|
interface Step1ReadOnlyViewProps {
|
||||||
|
positions: SelectedPosition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestGroup {
|
||||||
|
key: string;
|
||||||
|
number: string;
|
||||||
|
projectName: string;
|
||||||
|
positions: SelectedPosition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем сохранённые позиции по номеру потребности. При реконструкции из SP requestId пустой,
|
||||||
|
// поэтому ключ — requestNumber; если и он пуст — одна общая группа.
|
||||||
|
function groupByRequest(positions: SelectedPosition[]): RequestGroup[] {
|
||||||
|
const map = new Map<string, RequestGroup>();
|
||||||
|
positions.forEach((p) => {
|
||||||
|
const key = p.requestNumber || p.requestId || '—';
|
||||||
|
const group = map.get(key);
|
||||||
|
if (group) {
|
||||||
|
group.positions.push(p);
|
||||||
|
} else {
|
||||||
|
map.set(key, { key, number: p.requestNumber || '—', projectName: p.projectName, positions: [p] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Позиции группы (раскрытие потребности) — только для чтения. Цены на Шаге 1 не показываем.
|
||||||
|
function ReadOnlyLines({ positions }: { positions: SelectedPosition[] }): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className={styles.lines}>
|
||||||
|
<div className={`${styles.lineRow} ${styles.lineHead}`}>
|
||||||
|
<span />
|
||||||
|
<span>№</span>
|
||||||
|
<span>Код</span>
|
||||||
|
<span>Наименование</span>
|
||||||
|
<span>Кат. №</span>
|
||||||
|
<span>Кол-во</span>
|
||||||
|
<span>Ед. изм.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{positions.map((p, i) => (
|
||||||
|
<div key={p.lineId} className={styles.lineRow}>
|
||||||
|
<Checkbox checked disabled />
|
||||||
|
<span>{i + 1}</span>
|
||||||
|
<span>{p.code || '—'}</span>
|
||||||
|
<span className={styles.lineName}>{p.name}</span>
|
||||||
|
<span>{p.catalogNumber || '—'}</span>
|
||||||
|
<span>{p.quantity}</span>
|
||||||
|
<span>{p.unit}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 1 в режиме просмотра готовой СТ: таблица строится из сохранённых позиций (formData),
|
||||||
|
// БЕЗ обращения к API заявок. Иначе показывались бы актуальные заявки за последние N дней, не
|
||||||
|
// относящиеся к этой СТ (у сохранённых позиций ключ = SP Id, а не lineId из API — не сматчить).
|
||||||
|
export function Step1ReadOnlyView({ positions }: Step1ReadOnlyViewProps): JSX.Element {
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
if (positions.length === 0) {
|
||||||
|
return <p className={styles.state}>В этой СТ нет сохранённых позиций.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = groupByRequest(positions);
|
||||||
|
const projectName = positions[0].projectName;
|
||||||
|
|
||||||
|
const toggle = (key: string): void => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: React.KeyboardEvent, key: string): void => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
toggle(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div className={styles.lockBanner}>
|
||||||
|
Проект: <b>{projectName || '—'}</b>. Позиций в СТ: {positions.length}.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tableScroll}>
|
||||||
|
<div className={styles.table}>
|
||||||
|
<div className={`${styles.trow} ${styles.thead}`}>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span>№ потребности</span>
|
||||||
|
<span>Проект</span>
|
||||||
|
<span>Техника</span>
|
||||||
|
<span>Инициатор</span>
|
||||||
|
<span>Дата создания</span>
|
||||||
|
<span className={styles.cellPositions}>Позиций</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.key} className={styles.request}>
|
||||||
|
<div
|
||||||
|
className={styles.trow}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-expanded={expanded.has(group.key)}
|
||||||
|
onClick={() => toggle(group.key)}
|
||||||
|
onKeyDown={(e) => onKeyDown(e, group.key)}
|
||||||
|
>
|
||||||
|
<span className={styles.cellCheck} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox checked disabled />
|
||||||
|
</span>
|
||||||
|
<Icon iconName={expanded.has(group.key) ? 'ChevronDown' : 'ChevronRight'} className={styles.chevron} />
|
||||||
|
<span className={styles.reqNumber}>{group.number}</span>
|
||||||
|
<span className={styles.ellipsis}>{group.projectName || '—'}</span>
|
||||||
|
<span className={styles.ellipsis}>—</span>
|
||||||
|
<span className={styles.ellipsis}>—</span>
|
||||||
|
<span>—</span>
|
||||||
|
<span className={styles.cellPositions}>
|
||||||
|
<Pill kind="neutral">{group.positions.length}</Pill>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded.has(group.key) ? <ReadOnlyLines positions={group.positions} /> : undefined}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
@import '../common/theme';
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Состояния списка (loading/error/empty), §8.
|
||||||
|
.state {
|
||||||
|
margin: 0;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockBanner {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-left: 3px solid $accent;
|
||||||
|
background: $accent-bg;
|
||||||
|
color: $text;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footHint {
|
||||||
|
margin: 0;
|
||||||
|
color: $muted;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Таблица заявок ---
|
||||||
|
|
||||||
|
.tableScroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
min-width: 900px; // ниже — горизонтальный скролл, чтобы колонки не схлопывались
|
||||||
|
}
|
||||||
|
|
||||||
|
// Общий грид строки: [чекбокс][стрелка] № | проект | техника | инициатор | дата | позиций.
|
||||||
|
.trow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px 24px 140px minmax(140px, 1fr) minmax(160px, 1.4fr) 160px 140px 90px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thead {
|
||||||
|
background: $head-bg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строка-потребность.
|
||||||
|
.request {
|
||||||
|
border-top: 1px solid $border;
|
||||||
|
|
||||||
|
.trow {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.requestDisabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
|
||||||
|
.trow {
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellCheck {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reqNumber {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обрезаем длинные значения многоточием (проект/техника/инициатор).
|
||||||
|
.ellipsis {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellPositions {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Позиции (раскрытие заявки) ---
|
||||||
|
|
||||||
|
.reqBody {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid $border;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lines {
|
||||||
|
border-top: 1px solid $border;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Табличная строка позиции: [чекбокс] № | код | наименование | кат.№ | кол-во | ед. изм.
|
||||||
|
.lineRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 36px 44px 130px minmax(180px, 1fr) 120px 90px 90px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-top: 1px solid $border;
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lineHead {
|
||||||
|
color: $muted;
|
||||||
|
font-weight: 600;
|
||||||
|
background: $head-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lineName {
|
||||||
|
font-variant-numeric: normal;
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { StepShell } from '../common/StepShell';
|
||||||
|
import { PurchaseRequest, StepProps } from '../types/domain';
|
||||||
|
import { useRequests } from '../hooks/useRequests';
|
||||||
|
import { usePurchaseRequestDetails } from '../hooks/usePurchaseRequestDetails';
|
||||||
|
import { useStep1Selection } from './useStep1Selection';
|
||||||
|
import { RequestRow } from './RequestRow';
|
||||||
|
import { Step1ReadOnlyView } from './Step1ReadOnlyView';
|
||||||
|
import styles from './Step1SelectPositions.module.scss';
|
||||||
|
|
||||||
|
// Шаг 1 «Выбор позиций» (§12): таблица заявок TNSU → раскрытие в позиции → выбор строк.
|
||||||
|
// Все выбранные позиции — из одного проекта; выбор живёт в formData (источник истины).
|
||||||
|
export function Step1SelectPositions(props: StepProps): JSX.Element {
|
||||||
|
const { service, formData, onSave, onNext, onStepComplete, isReadOnly } = props;
|
||||||
|
|
||||||
|
// В режиме просмотра готовой СТ в API заявок НЕ ходим — таблица строится из сохранённых позиций
|
||||||
|
// (Step1ReadOnlyView). Иначе показывались бы актуальные заявки, не относящиеся к этой СТ.
|
||||||
|
const requests = useRequests<PurchaseRequest[]>(
|
||||||
|
() => (isReadOnly ? Promise.resolve([]) : service.getPurchaseRequests()),
|
||||||
|
[service, isReadOnly]
|
||||||
|
);
|
||||||
|
const { details, loadDetail } = usePurchaseRequestDetails(service);
|
||||||
|
const selection = useStep1Selection(formData, onSave);
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
|
// Заявки, у которых по чекбоксу «выбрать все» ждём догрузки позиций, чтобы выделить их.
|
||||||
|
const [pendingSelectAll, setPendingSelectAll] = useState<Set<string>>(new Set());
|
||||||
|
// Создание СТ на «Далее»: индикатор + ошибка.
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const openRequest = (id: string): void => {
|
||||||
|
setExpanded((prev) => new Set(prev).add(id));
|
||||||
|
loadDetail(id); // повторный вызов безопасен (кэш в хуке)
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpand = (request: PurchaseRequest): void => {
|
||||||
|
if (!expanded.has(request.id)) {
|
||||||
|
openRequest(request.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(request.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectAll = (request: PurchaseRequest): void => {
|
||||||
|
const detail = details[request.id]?.data;
|
||||||
|
if (detail) {
|
||||||
|
selection.toggleAllLines(detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Позиции ещё не загружены — раскрываем, грузим и выбираем все по готовности.
|
||||||
|
openRequest(request.id);
|
||||||
|
setPendingSelectAll((prev) => new Set(prev).add(request.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Догруженные заявки из очереди «выбрать все» — выделяем и убираем из очереди.
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingSelectAll.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const done: string[] = [];
|
||||||
|
pendingSelectAll.forEach((id) => {
|
||||||
|
const detail = details[id]?.data;
|
||||||
|
if (detail) {
|
||||||
|
selection.selectAllLines(detail);
|
||||||
|
done.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (done.length > 0) {
|
||||||
|
setPendingSelectAll((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
done.forEach((id) => next.delete(id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [details, pendingSelectAll, selection]);
|
||||||
|
|
||||||
|
const handleNext = (): void => {
|
||||||
|
// СТ уже создана (повторный вход) или read-only — просто идём дальше, без повторного POST.
|
||||||
|
if (formData.ctId || isReadOnly) {
|
||||||
|
onStepComplete(1);
|
||||||
|
onNext?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError(undefined);
|
||||||
|
service
|
||||||
|
.createCt(formData)
|
||||||
|
.then(({ ctId, positionSpIds }) => {
|
||||||
|
// сохраняем SP Id созданных позиций в formData — нужны для офферов на Шаге 3
|
||||||
|
const selectedPositions = formData.selectedPositions.map((p) => ({ ...p, spId: positionSpIds[p.lineId] }));
|
||||||
|
onSave({ ctId, status: 'draft', selectedPositions }); // ctId → в URL (эффект хука), форма продолжится после перезагрузки
|
||||||
|
onStepComplete(1);
|
||||||
|
onNext?.();
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
setSaveError(e.message);
|
||||||
|
setSaving(false); // при успехе шаг сменится — стейт не трогаем (размонтирование)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StepShell
|
||||||
|
title="Выбор позиций"
|
||||||
|
onNext={handleNext}
|
||||||
|
nextLabel={saving ? 'Создание…' : isReadOnly ? 'Далее' : 'Создать и далее'}
|
||||||
|
nextDisabled={saving || (!isReadOnly && selection.selectedCount === 0)}
|
||||||
|
>
|
||||||
|
{isReadOnly ? <Step1ReadOnlyView positions={formData.selectedPositions} /> : renderBody()}
|
||||||
|
</StepShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
function renderBody(): JSX.Element {
|
||||||
|
if (requests.isLoading) {
|
||||||
|
return <p className={styles.state}>Загрузка заявок…</p>;
|
||||||
|
}
|
||||||
|
if (requests.error) {
|
||||||
|
return <p className={`${styles.state} ${styles.error}`}>Ошибка загрузки заявок: {requests.error.message}</p>;
|
||||||
|
}
|
||||||
|
const list = requests.data ?? [];
|
||||||
|
if (list.length === 0) {
|
||||||
|
return <p className={styles.state}>Заявок на закупку нет.</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
{selection.lockedProjectName ? (
|
||||||
|
<div className={styles.lockBanner}>
|
||||||
|
Проект зафиксирован: <b>{selection.lockedProjectName}</b>. Позиции — только из него. Выбрано позиций:{' '}
|
||||||
|
{selection.selectedCount}.
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
<div className={styles.tableScroll}>
|
||||||
|
<div className={styles.table}>
|
||||||
|
<div className={`${styles.trow} ${styles.thead}`}>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span>№ потребности</span>
|
||||||
|
<span>Проект</span>
|
||||||
|
<span>Техника</span>
|
||||||
|
<span>Инициатор</span>
|
||||||
|
<span>Дата создания</span>
|
||||||
|
<span className={styles.cellPositions}>Позиций</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{list.map((request) => (
|
||||||
|
<RequestRow
|
||||||
|
key={request.id}
|
||||||
|
request={request}
|
||||||
|
expanded={expanded.has(request.id)}
|
||||||
|
disabled={!!selection.lockedProjectName && request.projectName !== selection.lockedProjectName}
|
||||||
|
detailState={details[request.id]}
|
||||||
|
selection={selection}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
onToggleExpand={() => toggleExpand(request)}
|
||||||
|
onToggleSelectAll={() => toggleSelectAll(request)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveError ? (
|
||||||
|
<p className={`${styles.state} ${styles.error}`}>Не удалось создать СТ: {saveError}</p>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
<p className={styles.footHint}>Выберите позиции для формирования СТ.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
CtFormData,
|
||||||
|
PurchaseRequestDetail,
|
||||||
|
PurchaseRequestLine,
|
||||||
|
SelectedPosition
|
||||||
|
} from '../types/domain';
|
||||||
|
|
||||||
|
export interface UseStep1SelectionResult {
|
||||||
|
selectedIds: Set<string>; // id выбранных строк — для быстрой проверки в UI
|
||||||
|
selectedCount: number;
|
||||||
|
lockedProjectId?: string; // проект зафиксирован после первого выбора
|
||||||
|
lockedProjectName?: string; // имя зафиксированного проекта (в списке заявок есть только имя)
|
||||||
|
selectedCountForRequest: (requestId: string) => number; // для бейджа на свёрнутой строке
|
||||||
|
isRequestFullySelected: (detail: PurchaseRequestDetail) => boolean;
|
||||||
|
toggleLine: (detail: PurchaseRequestDetail, line: PurchaseRequestLine) => void;
|
||||||
|
toggleAllLines: (detail: PurchaseRequestDetail) => void;
|
||||||
|
selectAllLines: (detail: PurchaseRequestDetail) => void; // идемпотентно добавить все строки
|
||||||
|
}
|
||||||
|
|
||||||
|
// Собираем позицию-снимок из строки заявки и её деталей.
|
||||||
|
function toSelectedPosition(detail: PurchaseRequestDetail, line: PurchaseRequestLine): SelectedPosition {
|
||||||
|
return {
|
||||||
|
lineId: line.id,
|
||||||
|
code: line.code,
|
||||||
|
name: line.name,
|
||||||
|
catalogNumber: line.catalogNumber,
|
||||||
|
quantity: line.quantity,
|
||||||
|
unit: line.unit,
|
||||||
|
estimatedUnitPrice: line.estimatedUnitPrice,
|
||||||
|
estimatedAmount: line.estimatedAmount,
|
||||||
|
requestId: detail.id,
|
||||||
|
requestNumber: detail.number,
|
||||||
|
vehicleName: detail.vehicleName,
|
||||||
|
projectId: detail.projectId,
|
||||||
|
projectCode: detail.projectCode,
|
||||||
|
projectName: detail.projectName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выбор позиций Шага 1. Источник истины — formData.selectedPositions (§5.1):
|
||||||
|
// пишем через onSave, так выбор переживает переходы между шагами и персист в сессии.
|
||||||
|
export function useStep1Selection(
|
||||||
|
formData: CtFormData,
|
||||||
|
onSave: (partial: Partial<CtFormData>) => void
|
||||||
|
): UseStep1SelectionResult {
|
||||||
|
const selected = formData.selectedPositions;
|
||||||
|
const selectedIds = useMemo(() => new Set(selected.map((p) => p.lineId)), [selected]);
|
||||||
|
|
||||||
|
// Проект берём из первой выбранной позиции — все остальные обязаны совпадать (бизнес-правило).
|
||||||
|
const first = selected[0];
|
||||||
|
const lockedProjectId = first?.projectId;
|
||||||
|
const lockedProjectName = first?.projectName;
|
||||||
|
|
||||||
|
// Сколько позиций выбрано у каждой заявки — считаем один раз на изменение выбора.
|
||||||
|
const countByRequest = useMemo(() => {
|
||||||
|
const acc: Record<string, number> = {};
|
||||||
|
selected.forEach((p) => {
|
||||||
|
acc[p.requestId] = (acc[p.requestId] ?? 0) + 1;
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
const commit = useCallback(
|
||||||
|
(next: SelectedPosition[]): void => {
|
||||||
|
onSave({
|
||||||
|
selectedPositions: next,
|
||||||
|
lockedProjectId: next[0]?.projectId,
|
||||||
|
selectedProject: next[0]?.projectName
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onSave]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isRequestFullySelected = useCallback(
|
||||||
|
(detail: PurchaseRequestDetail): boolean =>
|
||||||
|
detail.lines.length > 0 && detail.lines.every((line) => selectedIds.has(line.id)),
|
||||||
|
[selectedIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleLine = useCallback(
|
||||||
|
(detail: PurchaseRequestDetail, line: PurchaseRequestLine): void => {
|
||||||
|
if (selectedIds.has(line.id)) {
|
||||||
|
commit(selected.filter((p) => p.lineId !== line.id));
|
||||||
|
} else {
|
||||||
|
commit([...selected, toSelectedPosition(detail, line)]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selected, selectedIds, commit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleAllLines = useCallback(
|
||||||
|
(detail: PurchaseRequestDetail): void => {
|
||||||
|
const lineIds = detail.lines.map((line) => line.id);
|
||||||
|
if (isRequestFullySelected(detail)) {
|
||||||
|
commit(selected.filter((p) => lineIds.indexOf(p.lineId) === -1));
|
||||||
|
} else {
|
||||||
|
const additions = detail.lines
|
||||||
|
.filter((line) => !selectedIds.has(line.id))
|
||||||
|
.map((line) => toSelectedPosition(detail, line));
|
||||||
|
commit([...selected, ...additions]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selected, selectedIds, isRequestFullySelected, commit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectAllLines = useCallback(
|
||||||
|
(detail: PurchaseRequestDetail): void => {
|
||||||
|
const additions = detail.lines
|
||||||
|
.filter((line) => !selectedIds.has(line.id))
|
||||||
|
.map((line) => toSelectedPosition(detail, line));
|
||||||
|
if (additions.length > 0) {
|
||||||
|
commit([...selected, ...additions]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selected, selectedIds, commit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedCountForRequest = useCallback(
|
||||||
|
(requestId: string): number => countByRequest[requestId] ?? 0,
|
||||||
|
[countByRequest]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedIds,
|
||||||
|
selectedCount: selected.length,
|
||||||
|
lockedProjectId,
|
||||||
|
lockedProjectName,
|
||||||
|
selectedCountForRequest,
|
||||||
|
isRequestFullySelected,
|
||||||
|
toggleLine,
|
||||||
|
toggleAllLines,
|
||||||
|
selectAllLines
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
@import '../common/theme';
|
||||||
|
|
||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
color: $muted;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
margin: 0;
|
||||||
|
color: $muted;
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presets {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presetsLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presetBtn {
|
||||||
|
height: 28px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nbkRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: auto;
|
||||||
|
align-self: flex-start;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $muted;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-bottom: 1px solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 6px 12px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background: $head-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
display: inline-block;
|
||||||
|
background: $border;
|
||||||
|
color: $text;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rateInput {
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.courseDate {
|
||||||
|
color: $muted;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { DefaultButton, IconButton, MessageBar, MessageBarType, Spinner, SpinnerSize, TextField } from '@fluentui/react';
|
||||||
|
import { CurrencyRate } from '../types/domain';
|
||||||
|
import { formatDate } from '../utils/format';
|
||||||
|
import styles from './CurrencyRatesForm.module.scss';
|
||||||
|
|
||||||
|
interface CurrencyRatesFormProps {
|
||||||
|
rates: CurrencyRate[];
|
||||||
|
onChange: (rates: CurrencyRate[]) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пресеты валют для быстрого добавления (name — для отображения; currency = код).
|
||||||
|
const PRESET_CURRENCIES: Array<{ currency: string; name: string }> = [
|
||||||
|
{ currency: 'USD', name: 'Доллар США' },
|
||||||
|
{ currency: 'EUR', name: 'Евро' },
|
||||||
|
{ currency: 'RUB', name: 'Российский рубль' },
|
||||||
|
{ currency: 'CNY', name: 'Китайский юань' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Бесплатный API курсов (без ключа): база USD, кросс-курсом считаем каждую валюту к KZT.
|
||||||
|
const RATES_API_URL = 'https://open.er-api.com/v6/latest/USD';
|
||||||
|
|
||||||
|
// Блок «Курсы валют» Шага 2: добавление валют, ручной ввод курса и авто-подтягивание актуального курса.
|
||||||
|
export function CurrencyRatesForm({ rates, onChange, readOnly = false }: CurrencyRatesFormProps): JSX.Element {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const addCurrency = (preset: { currency: string; name: string }): void => {
|
||||||
|
if (rates.some((r) => r.currency === preset.currency)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange([...rates, { currency: preset.currency, name: preset.name, rate: 0 }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRate = (currency: string, value: string): void => {
|
||||||
|
const parsed = parseFloat(value.replace(',', '.'));
|
||||||
|
// Дата курса = момент ручного ввода (когда курс зафиксирован пользователем).
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
onChange(
|
||||||
|
rates.map((r) => (r.currency === currency ? { ...r, rate: isNaN(parsed) ? 0 : parsed, courseDate: now } : r))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRate = (currency: string): void => {
|
||||||
|
onChange(rates.filter((r) => r.currency !== currency));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchLiveRates = (): void => {
|
||||||
|
if (rates.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
fetch(RATES_API_URL)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data: { result: string; rates: Record<string, number> }) => {
|
||||||
|
const usdToKzt = data.rates?.KZT;
|
||||||
|
if (data.result !== 'success' || !usdToKzt) {
|
||||||
|
throw new Error('Курс не получен');
|
||||||
|
}
|
||||||
|
// Дата курса = момент запроса актуального курса.
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
onChange(
|
||||||
|
rates.map((r) => {
|
||||||
|
const rateToUsd = data.rates[r.currency];
|
||||||
|
if (!rateToUsd) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
const rateToKzt = r.currency === 'USD' ? usdToKzt : usdToKzt / rateToUsd;
|
||||||
|
return { ...r, rate: Math.round(rateToKzt * 100) / 100, courseDate: now };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('Не удалось получить курсы. Проверьте подключение к интернету.');
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const presetsToShow = PRESET_CURRENCIES.filter((p) => !rates.some((r) => r.currency === p.currency));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root}>
|
||||||
|
{!readOnly ? (
|
||||||
|
<p className={styles.hint}>Укажите курсы валют к тенге. Если все КП в тенге — оставьте пустым.</p>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{!readOnly && presetsToShow.length > 0 ? (
|
||||||
|
<div className={styles.presets}>
|
||||||
|
<span className={styles.presetsLabel}>Добавить:</span>
|
||||||
|
{presetsToShow.map((p) => (
|
||||||
|
<DefaultButton key={p.currency} className={styles.presetBtn} onClick={() => addCurrency(p)}>
|
||||||
|
{p.currency} — {p.name}
|
||||||
|
</DefaultButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{!readOnly && rates.length > 0 ? (
|
||||||
|
<div className={styles.nbkRow}>
|
||||||
|
<DefaultButton iconProps={loading ? undefined : { iconName: 'Download' }} onClick={fetchLiveRates} disabled={loading}>
|
||||||
|
{loading ? <Spinner size={SpinnerSize.small} /> : 'Получить актуальный курс'}
|
||||||
|
</DefaultButton>
|
||||||
|
{error ? (
|
||||||
|
<MessageBar messageBarType={MessageBarType.error} isMultiline={false} className={styles.error}>
|
||||||
|
{error}
|
||||||
|
</MessageBar>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{rates.length === 0 ? (
|
||||||
|
<p className={styles.muted}>Все предложения в тенге (KZT).</p>
|
||||||
|
) : (
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Валюта</th>
|
||||||
|
<th>Код</th>
|
||||||
|
<th>Курс к KZT</th>
|
||||||
|
<th>Дата курса</th>
|
||||||
|
{!readOnly ? <th /> : undefined}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rates.map((rate) => (
|
||||||
|
<tr key={rate.currency}>
|
||||||
|
<td>{rate.name ?? rate.currency}</td>
|
||||||
|
<td>
|
||||||
|
<span className={styles.code}>{rate.currency}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{readOnly ? (
|
||||||
|
<span>{rate.rate === 0 ? '—' : `${rate.rate} KZT`}</span>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
value={rate.rate === 0 ? '' : String(rate.rate)}
|
||||||
|
onChange={(_, val) => updateRate(rate.currency, val ?? '')}
|
||||||
|
placeholder="0.00"
|
||||||
|
suffix="KZT"
|
||||||
|
className={styles.rateInput}
|
||||||
|
ariaLabel={`Курс ${rate.currency} к KZT`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={styles.courseDate}>{formatDate(rate.courseDate)}</span>
|
||||||
|
</td>
|
||||||
|
{!readOnly ? (
|
||||||
|
<td>
|
||||||
|
<IconButton iconProps={{ iconName: 'Delete' }} title={`Удалить ${rate.currency}`} onClick={() => removeRate(rate.currency)} />
|
||||||
|
</td>
|
||||||
|
) : undefined}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@import '../common/theme';
|
||||||
|
|
||||||
|
.fileList {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileIcon {
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileName {
|
||||||
|
color: $text;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&[href]:hover {
|
||||||
|
color: $accent;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: $head-bg;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge_done {
|
||||||
|
background: $ok-bg;
|
||||||
|
color: $ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge_error {
|
||||||
|
background: $danger-bg;
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
color: $muted;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplierTag {
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: $accent-bg;
|
||||||
|
color: $accent;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currency {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid $border-strong;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorMsg {
|
||||||
|
color: $danger;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: $muted;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Icon, Spinner, SpinnerSize } from '@fluentui/react';
|
||||||
|
import { KpFile, KpFileStatus } from './kpTypes';
|
||||||
|
import styles from './KpFileList.module.scss';
|
||||||
|
|
||||||
|
// Список загруженных файлов КП со статусом обработки (заливка/Syntex/поток), поставщиком и валютой.
|
||||||
|
// Презентационный компонент: всё поведение приходит через props (стайл-гайд).
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
files: KpFile[];
|
||||||
|
currencyOptions: string[];
|
||||||
|
onRemove: (localId: string) => void;
|
||||||
|
onCurrencyChange: (localId: string, currency: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<KpFileStatus, string> = {
|
||||||
|
uploading: 'Загрузка…',
|
||||||
|
extracting: 'Обработка…',
|
||||||
|
flowing: 'Разбор КП…',
|
||||||
|
done: 'Готово',
|
||||||
|
error: 'Ошибка'
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBusy = (status: KpFileStatus): boolean =>
|
||||||
|
status === 'uploading' || status === 'extracting' || status === 'flowing';
|
||||||
|
|
||||||
|
// Модификатор-класс бейджа задан только для done/error; для остальных — только базовый.
|
||||||
|
const badgeModifier = (status: KpFileStatus): string =>
|
||||||
|
status === 'done' ? styles.badge_done : status === 'error' ? styles.badge_error : '';
|
||||||
|
|
||||||
|
// Родитель рендерит список только при непустом files (см. Step2LoadKp).
|
||||||
|
export function KpFileList(props: Props): JSX.Element {
|
||||||
|
const { files, currencyOptions, onRemove, onCurrencyChange, disabled } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={styles.fileList}>
|
||||||
|
{files.map((f) => (
|
||||||
|
<li key={f.localId} className={styles.fileItem}>
|
||||||
|
<Icon iconName="TextDocument" className={styles.fileIcon} />
|
||||||
|
{f.url ? (
|
||||||
|
<a className={styles.fileName} href={f.url} target="_blank" rel="noreferrer">
|
||||||
|
{f.file.name}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className={styles.fileName}>{f.file.name}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className={`${styles.badge} ${badgeModifier(f.status)}`.trim()}>
|
||||||
|
{isBusy(f.status) ? <Spinner size={SpinnerSize.xSmall} className={styles.spinner} /> : undefined}
|
||||||
|
{STATUS_LABEL[f.status]}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{f.status === 'flowing' && f.flowTotal > 0 ? (
|
||||||
|
<span className={styles.progress}>
|
||||||
|
{f.flowDone}/{f.flowTotal} позиций
|
||||||
|
</span>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{f.supplierName ? <span className={styles.supplierTag}>{f.supplierName}</span> : undefined}
|
||||||
|
|
||||||
|
{f.status === 'done' && currencyOptions.length > 0 ? (
|
||||||
|
<select
|
||||||
|
className={styles.currency}
|
||||||
|
value={f.currencyCode}
|
||||||
|
onChange={(e) => onCurrencyChange(f.localId, e.currentTarget.value)}
|
||||||
|
aria-label={`Валюта КП ${f.file.name}`}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{currencyOptions.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{f.error ? <span className={styles.errorMsg}>{f.error}</span> : undefined}
|
||||||
|
|
||||||
|
{!disabled && !isBusy(f.status) ? (
|
||||||
|
<button type="button" className={styles.removeBtn} onClick={() => onRemove(f.localId)} title="Удалить файл">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
) : undefined}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
@import '../common/theme';
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 32px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Узкий экран — колонки в одну.
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.grid2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заголовок секции с разделительной линией снизу (как в эталоне).
|
||||||
|
.h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
color: $muted;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
margin: 0;
|
||||||
|
color: $muted;
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 0;
|
||||||
|
color: $danger;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Зона загрузки ---
|
||||||
|
|
||||||
|
.dropzone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 40px 24px;
|
||||||
|
border: 2px dashed $border-strong;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: $head-bg;
|
||||||
|
color: $muted;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $accent;
|
||||||
|
background: $accent-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzoneOver {
|
||||||
|
border-color: $accent;
|
||||||
|
background: $accent-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzoneDisabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $border-strong;
|
||||||
|
background: $head-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropIcon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: #a19f9d;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropText {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: $accent;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formats {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a19f9d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiddenInput {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileList {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileIcon {
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Курсы валют ---
|
||||||
|
|
||||||
|
.rateList {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rateItem {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Поставщики без КП ---
|
||||||
|
|
||||||
|
.supplierForm {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-width: 860px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplierName {
|
||||||
|
flex: 1 1 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplierComment {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplierList {
|
||||||
|
list-style: none;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 860px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplierItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: $head-bg;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplierItemName {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplierItemComment {
|
||||||
|
color: $muted;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: $muted;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { MessageBar, MessageBarType } from '@fluentui/react';
|
||||||
|
import { StepShell } from '../common/StepShell';
|
||||||
|
import { StepProps } from '../types/domain';
|
||||||
|
import { SuppliersWithoutKp } from './SuppliersWithoutKp';
|
||||||
|
import { CurrencyRatesForm } from './CurrencyRatesForm';
|
||||||
|
import { KpFileList } from './KpFileList';
|
||||||
|
import { UploadedKpList } from './UploadedKpList';
|
||||||
|
import { useKpProcessing } from './useKpProcessing';
|
||||||
|
import { persistStep2 } from './step2Persist';
|
||||||
|
import styles from './Step2LoadKp.module.scss';
|
||||||
|
|
||||||
|
const ACCEPTED_FORMATS = '.pdf,.docx,.doc';
|
||||||
|
|
||||||
|
// Шаг 2 «Загрузка КП»: заливка коммерческих предложений в библиотеку, обработка потоком (Syntex →
|
||||||
|
// поток по позициям → офферы), курсы валют, поставщики без КП. Конвейер — в useKpProcessing/kpPipeline;
|
||||||
|
// запись на «Далее» — в step2Persist. Компонент только компонует блоки и колбэки (стайл-гайд).
|
||||||
|
export function Step2LoadKp(props: StepProps): JSX.Element {
|
||||||
|
const { service, formData, onSave, onPrev, onNext, onStepComplete, isReadOnly } = props;
|
||||||
|
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const { kpFiles, isProcessing, duplicateWarning, addFiles, removeFile, setCurrency } = useKpProcessing(
|
||||||
|
service,
|
||||||
|
formData,
|
||||||
|
!!isReadOnly
|
||||||
|
);
|
||||||
|
|
||||||
|
const suppliers = formData.suppliers ?? [];
|
||||||
|
const withOffer = suppliers.filter((s) => s.hasOffer !== false); // поставщики с КП — не трогаем здесь
|
||||||
|
const noKp = suppliers.filter((s) => s.hasOffer === false); // поставщики без КП (блок ниже)
|
||||||
|
|
||||||
|
const rates = formData.currencyRates ?? [];
|
||||||
|
const currencyOptions = ['KZT', ...rates.filter((r) => r.currency !== 'KZT').map((r) => r.currency)];
|
||||||
|
|
||||||
|
// Постоянный список КП (из formData) минус файлы текущей сессии (они уже видны в KpFileList со статусом).
|
||||||
|
const uploadedKp = (formData.uploadedKpFiles ?? []).filter((u) => !kpFiles.some((f) => f.file.name === u.name));
|
||||||
|
const removeUploaded = (spId: number): void =>
|
||||||
|
onSave({ uploadedKpFiles: (formData.uploadedKpFiles ?? []).filter((u) => u.spId !== spId) });
|
||||||
|
|
||||||
|
const pickFiles = (list: FileList | null): void => {
|
||||||
|
if (list && list.length > 0) {
|
||||||
|
addFiles(Array.from(list));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = (event: React.DragEvent): void => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
if (!isReadOnly) {
|
||||||
|
pickFiles(event.dataTransfer.files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = (): void => {
|
||||||
|
if (isReadOnly) {
|
||||||
|
onStepComplete(2);
|
||||||
|
onNext?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isProcessing) {
|
||||||
|
return; // не уходим, пока файлы обрабатываются
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError(undefined);
|
||||||
|
persistStep2({ service, ctId: formData.ctId, kpFiles, formData })
|
||||||
|
.then((patch) => {
|
||||||
|
onSave(patch);
|
||||||
|
onStepComplete(2);
|
||||||
|
onNext?.();
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
setSaveError(e.message);
|
||||||
|
setSaving(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StepShell
|
||||||
|
title="Загрузка КП"
|
||||||
|
onPrev={onPrev}
|
||||||
|
onNext={handleNext}
|
||||||
|
nextLabel={saving ? 'Сохранение…' : 'Далее'}
|
||||||
|
nextDisabled={saving || isProcessing}
|
||||||
|
>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<div className={styles.grid2}>
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h3 className={styles.h3}>Загрузка коммерческих предложений</h3>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={[styles.dropzone, dragOver ? styles.dropzoneOver : '', isReadOnly ? styles.dropzoneDisabled : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isReadOnly) {
|
||||||
|
inputRef.current?.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!isReadOnly) {
|
||||||
|
setDragOver(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={onDrop}
|
||||||
|
>
|
||||||
|
<svg className={styles.dropIcon} viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M16 22V10M10 16l6-6 6 6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<rect x="4" y="24" width="24" height="4" rx="2" fill="currentColor" opacity=".2" />
|
||||||
|
</svg>
|
||||||
|
<div className={styles.dropText}>
|
||||||
|
Перетащите файлы или <span className={styles.link}>выберите на диске</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formats}>Поддерживаемые форматы: .pdf, .docx, .doc</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept={ACCEPTED_FORMATS}
|
||||||
|
className={styles.hiddenInput}
|
||||||
|
onChange={(event) => pickFiles(event.currentTarget.files)}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{duplicateWarning ? (
|
||||||
|
<MessageBar messageBarType={MessageBarType.warning}>{duplicateWarning}</MessageBar>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{kpFiles.length > 0 ? (
|
||||||
|
<KpFileList
|
||||||
|
files={kpFiles}
|
||||||
|
currencyOptions={currencyOptions}
|
||||||
|
onRemove={removeFile}
|
||||||
|
onCurrencyChange={setCurrency}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{uploadedKp.length > 0 ? (
|
||||||
|
<UploadedKpList files={uploadedKp} onRemove={isReadOnly ? undefined : removeUploaded} />
|
||||||
|
) : undefined}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h3 className={styles.h3}>Курсы валют</h3>
|
||||||
|
<CurrencyRatesForm rates={rates} onChange={(next) => onSave({ currencyRates: next })} readOnly={isReadOnly} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SuppliersWithoutKp
|
||||||
|
items={noKp.map((s) => ({ name: s.name, comment: s.comment ?? '' }))}
|
||||||
|
onChange={(items) =>
|
||||||
|
onSave({
|
||||||
|
suppliers: [
|
||||||
|
...withOffer,
|
||||||
|
...items.map((i) => ({ id: `nokp_${i.name}`, name: i.name, comment: i.comment, hasOffer: false }))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{saveError ? <p className={styles.error}>Не удалось сохранить Шаг 2: {saveError}</p> : undefined}
|
||||||
|
</div>
|
||||||
|
</StepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { DefaultButton, TextField } from '@fluentui/react';
|
||||||
|
import { NoKpSupplier } from '../types/domain';
|
||||||
|
import styles from './Step2LoadKp.module.scss';
|
||||||
|
|
||||||
|
interface SuppliersWithoutKpProps {
|
||||||
|
items: NoKpSupplier[]; // источник истины — formData.noKpSuppliers
|
||||||
|
onChange: (items: NoKpSupplier[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поставщики, не приславшие КП. Источник истины — formData (через onChange), запись в SP на «Далее».
|
||||||
|
export function SuppliersWithoutKp({ items, onChange, disabled }: SuppliersWithoutKpProps): JSX.Element {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
|
||||||
|
const add = (): void => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Дедуп по имени (без учёта регистра) — тот же поставщик не добавляется дважды.
|
||||||
|
if (items.some((i) => i.name.toLowerCase() === trimmed.toLowerCase())) {
|
||||||
|
setName('');
|
||||||
|
setComment('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange([...items, { name: trimmed, comment: comment.trim() }]);
|
||||||
|
setName('');
|
||||||
|
setComment('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (index: number): void => {
|
||||||
|
onChange(items.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h3 className={styles.h3}>Поставщики без КП</h3>
|
||||||
|
<p className={styles.hint}>Укажите поставщиков, которые не предоставили коммерческое предложение.</p>
|
||||||
|
|
||||||
|
<div className={styles.supplierForm}>
|
||||||
|
<TextField
|
||||||
|
className={styles.supplierName}
|
||||||
|
placeholder="Наименование поставщика"
|
||||||
|
value={name}
|
||||||
|
onChange={(_, value) => setName(value ?? '')}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className={styles.supplierComment}
|
||||||
|
placeholder="Комментарий (необязательно)"
|
||||||
|
value={comment}
|
||||||
|
onChange={(_, value) => setComment(value ?? '')}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<DefaultButton iconProps={{ iconName: 'Add' }} text="Добавить" onClick={add} disabled={disabled || !name.trim()} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<ul className={styles.supplierList}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<li key={`${item.name}-${index}`} className={styles.supplierItem}>
|
||||||
|
<span className={styles.supplierItemName}>{item.name}</span>
|
||||||
|
{item.comment ? <span className={styles.supplierItemComment}>{item.comment}</span> : undefined}
|
||||||
|
{!disabled ? (
|
||||||
|
<button type="button" className={styles.removeBtn} onClick={() => remove(index)} aria-label="Удалить">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
) : undefined}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : undefined}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
@import '../common/theme';
|
||||||
|
|
||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: #0078d4;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplier {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Icon, IconButton, Link } from '@fluentui/react';
|
||||||
|
import { UploadedKpFile } from '../types/domain';
|
||||||
|
import styles from './UploadedKpList.module.scss';
|
||||||
|
|
||||||
|
interface UploadedKpListProps {
|
||||||
|
files: UploadedKpFile[];
|
||||||
|
onRemove?: (spId: number) => void; // в read-only не передаём — кнопку удаления не показываем
|
||||||
|
}
|
||||||
|
|
||||||
|
// Постоянный список загруженных КП (из formData.uploadedKpFiles): переживает перезагрузку и вход по
|
||||||
|
// ссылке-уведомлению. Отличается от KpFileList — тот показывает файлы ТЕКУЩЕЙ сессии со статусами обработки.
|
||||||
|
// Пустой список не рендерим на уровне родителя (как KpFileList).
|
||||||
|
export function UploadedKpList({ files, onRemove }: UploadedKpListProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className={styles.root}>
|
||||||
|
<h4 className={styles.title}>Загруженные КП</h4>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{files.map((f) => (
|
||||||
|
<li key={f.spId} className={styles.row}>
|
||||||
|
<Icon iconName="TextDocument" className={styles.icon} />
|
||||||
|
{f.url ? (
|
||||||
|
<Link href={f.url} target="_blank" className={styles.name}>
|
||||||
|
{f.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className={styles.name}>{f.name}</span>
|
||||||
|
)}
|
||||||
|
{f.supplierName ? <span className={styles.supplier}>{f.supplierName}</span> : undefined}
|
||||||
|
<span className={styles.spacer} />
|
||||||
|
{onRemove ? (
|
||||||
|
<IconButton
|
||||||
|
iconProps={{ iconName: 'Delete' }}
|
||||||
|
title={`Убрать ${f.name} из списка`}
|
||||||
|
onClick={() => onRemove(f.spId)}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { CurrencyRate, SelectedPosition, Supplier, SupplierCriteria, SupplierOffer } from '../types/domain';
|
||||||
|
import { KpFile } from './kpTypes';
|
||||||
|
|
||||||
|
// Чистые сборщики доменных сущностей из обработанных файлов КП (без React, без сети).
|
||||||
|
|
||||||
|
// Поставщики с КП — по одному на файл с распознанным поставщиком. id = String(spId) для связи с офферами.
|
||||||
|
export function buildKpSuppliers(files: KpFile[]): Supplier[] {
|
||||||
|
return files
|
||||||
|
.filter((f) => f.supplierName)
|
||||||
|
.map((f) => ({
|
||||||
|
id: f.supplierSpId !== undefined ? String(f.supplierSpId) : f.localId,
|
||||||
|
spId: f.supplierSpId,
|
||||||
|
name: f.supplierName as string,
|
||||||
|
hasOffer: true,
|
||||||
|
currency: f.currencyCode
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Офферы из результатов потока (позиция × поставщик). Цена/кол-во — в валюте поставщика.
|
||||||
|
export function buildKpOffers(files: KpFile[]): SupplierOffer[] {
|
||||||
|
const offers: SupplierOffer[] = [];
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.supplierSpId === undefined || f.flowResults.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const supplierId = String(f.supplierSpId);
|
||||||
|
for (const fr of f.flowResults) {
|
||||||
|
offers.push({
|
||||||
|
supplierId,
|
||||||
|
positionId: fr.positionId,
|
||||||
|
itemIndex: 0,
|
||||||
|
supplierPosition: fr.flowResult.position,
|
||||||
|
unit: fr.flowResult.unit,
|
||||||
|
quantity: fr.flowResult.quantity ?? 0,
|
||||||
|
pricePerUnit: fr.flowResult.price ?? 0,
|
||||||
|
offerId: f.spId !== undefined ? String(f.spId) : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return offers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Критерии из результатов потока — один набор на поставщика (первый файл с любым критерием).
|
||||||
|
export function buildKpCriteria(files: KpFile[]): SupplierCriteria[] {
|
||||||
|
const criteria: SupplierCriteria[] = [];
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.supplierSpId === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const entry = f.flowResults.find(
|
||||||
|
(r) => r.flowResult.conditions || r.flowResult.deliveryType || r.flowResult.deadline || r.flowResult.sertificates
|
||||||
|
);
|
||||||
|
if (!entry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fr = entry.flowResult;
|
||||||
|
criteria.push({
|
||||||
|
supplierId: String(f.supplierSpId),
|
||||||
|
paymentType: fr.conditions,
|
||||||
|
deliveryType: fr.deliveryType,
|
||||||
|
deadline: fr.deadline,
|
||||||
|
sertificates: fr.sertificates
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return criteria;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строка оффера для записи в SP (как ожидает service.saveOffers).
|
||||||
|
export interface OfferSpRow {
|
||||||
|
positionSpId: number;
|
||||||
|
supplierSpId: number;
|
||||||
|
supplierPosition: string;
|
||||||
|
unit: string;
|
||||||
|
itemIndex: number;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
summa: number;
|
||||||
|
priceTenge: number;
|
||||||
|
summaTenge: number;
|
||||||
|
valutaSpId?: number;
|
||||||
|
offerSpId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Маппинг офферов formData → строки SP: разрешаем positionId (lineId → spId), считаем ₸ по курсу.
|
||||||
|
export function buildOffersForSp(
|
||||||
|
offers: SupplierOffer[],
|
||||||
|
suppliers: Supplier[],
|
||||||
|
positions: SelectedPosition[],
|
||||||
|
rates: CurrencyRate[]
|
||||||
|
): { positionSpIds: number[]; rows: OfferSpRow[] } {
|
||||||
|
const posByLineId: Record<string, { spId: number; name: string; unit: string }> = {};
|
||||||
|
positions.forEach((p) => {
|
||||||
|
if (p.spId !== undefined) {
|
||||||
|
posByLineId[p.lineId] = { spId: p.spId, name: p.name, unit: p.unit };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows: OfferSpRow[] = [];
|
||||||
|
for (const o of offers) {
|
||||||
|
const sup = suppliers.find((s) => s.id === o.supplierId);
|
||||||
|
const pos = posByLineId[o.positionId];
|
||||||
|
if (sup?.spId === undefined || pos === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const currency = sup.currency ?? 'KZT';
|
||||||
|
const rateObj = currency !== 'KZT' ? rates.find((r) => r.currency === currency) : undefined;
|
||||||
|
const rate = rateObj?.rate ?? 1;
|
||||||
|
const qty = o.quantity || 0;
|
||||||
|
const price = o.pricePerUnit || 0;
|
||||||
|
const summaTenge = qty * price * rate;
|
||||||
|
if (summaTenge <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
positionSpId: pos.spId,
|
||||||
|
supplierSpId: sup.spId,
|
||||||
|
supplierPosition: o.supplierPosition ?? pos.name,
|
||||||
|
unit: o.unit ?? pos.unit,
|
||||||
|
itemIndex: o.itemIndex,
|
||||||
|
quantity: qty,
|
||||||
|
price,
|
||||||
|
summa: qty * price,
|
||||||
|
priceTenge: price * rate,
|
||||||
|
summaTenge,
|
||||||
|
valutaSpId: rateObj?.spId,
|
||||||
|
offerSpId: o.offerId ? parseInt(o.offerId, 10) || undefined : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const positionSpIds = Array.from(new Set(rows.map((r) => r.positionSpId)));
|
||||||
|
return { positionSpIds, rows };
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { KpsCtFormService } from '../../services/KpsCtFormService';
|
||||||
|
import { SelectedPosition } from '../types/domain';
|
||||||
|
import { FlowPositionResult, PatchFile } from './kpTypes';
|
||||||
|
|
||||||
|
// Чистый конвейер обработки файла КП (без React). Мирроринг эталона Step2CommercialOffers:
|
||||||
|
// заливка → (extractWordText для Word) → поллинг Syntex (file_content) → поток по каждой позиции.
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 5000;
|
||||||
|
const MAX_POLL_RETRIES = 60; // ~5 минут
|
||||||
|
|
||||||
|
export interface PipelineDeps {
|
||||||
|
service: KpsCtFormService;
|
||||||
|
getPositions: () => SelectedPosition[];
|
||||||
|
isMounted: () => boolean;
|
||||||
|
patch: PatchFile;
|
||||||
|
incFlowDone: (localId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const isWordOrExcel = (name: string): boolean => /\.(doc|docx|xls|xlsx)$/i.test(name);
|
||||||
|
|
||||||
|
// Полный цикл одного файла. Бросает — вызывающий переводит файл в статус 'error'.
|
||||||
|
export async function processKpFile(file: File, localId: string, deps: PipelineDeps): Promise<void> {
|
||||||
|
const { service, patch } = deps;
|
||||||
|
|
||||||
|
// Уже загруженный (в этой СТ) файл с готовым разбором — не гоняем Syntex повторно.
|
||||||
|
let spId: number | undefined;
|
||||||
|
try {
|
||||||
|
const existing = await service.findExistingKpFile(file.name, file.size);
|
||||||
|
if (existing) {
|
||||||
|
spId = existing.spId;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* поиск не удался — грузим как обычно */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spId === undefined) {
|
||||||
|
const uploaded = await service.uploadKpFile(file);
|
||||||
|
spId = uploaded.spId;
|
||||||
|
patch(localId, { spId, url: uploaded.url, status: 'extracting' });
|
||||||
|
if (isWordOrExcel(file.name)) {
|
||||||
|
await service.extractWordText(spId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
patch(localId, { spId, status: 'extracting' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await pollSyntex(localId, spId, file.name, 0, deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поллинг: ждём, пока Syntex проставит file_content (в kps это единственная колонка модели).
|
||||||
|
async function pollSyntex(
|
||||||
|
localId: string,
|
||||||
|
fileSpId: number,
|
||||||
|
fileName: string,
|
||||||
|
retries: number,
|
||||||
|
deps: PipelineDeps
|
||||||
|
): Promise<void> {
|
||||||
|
const { service, isMounted, patch } = deps;
|
||||||
|
if (!isMounted()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (retries >= MAX_POLL_RETRIES) {
|
||||||
|
patch(localId, { status: 'error', error: 'Syntex не завершил обработку за 5 минут' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fields = await service.getKpItemFields(fileSpId);
|
||||||
|
if (fields.fileContent) {
|
||||||
|
await runFlow(localId, fileSpId, fileName, deps);
|
||||||
|
} else {
|
||||||
|
await wait(POLL_INTERVAL_MS);
|
||||||
|
await pollSyntex(localId, fileSpId, fileName, retries + 1, deps);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await wait(POLL_INTERVAL_MS);
|
||||||
|
await pollSyntex(localId, fileSpId, fileName, retries + 1, deps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызов потока по каждой выбранной позиции; сбой по одной позиции не останавливает остальные.
|
||||||
|
async function runFlow(localId: string, fileSpId: number, fileName: string, deps: PipelineDeps): Promise<void> {
|
||||||
|
const { service, getPositions, isMounted, patch, incFlowDone } = deps;
|
||||||
|
const positions = getPositions();
|
||||||
|
if (!isMounted()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
patch(localId, { status: 'flowing', flowTotal: positions.length, flowDone: 0, flowResults: [] });
|
||||||
|
|
||||||
|
const collected: FlowPositionResult[] = [];
|
||||||
|
await Promise.all(
|
||||||
|
positions.map(async (pos) => {
|
||||||
|
try {
|
||||||
|
const flowResult = await service.triggerKpFlow(fileName, fileSpId, pos.name);
|
||||||
|
collected.push({ positionId: pos.lineId, flowResult });
|
||||||
|
} catch {
|
||||||
|
/* сбой по одной позиции игнорируем */
|
||||||
|
}
|
||||||
|
if (isMounted()) {
|
||||||
|
incFlowDone(localId);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Поставщик — из результата потока (в kps отдельной колонки customer нет).
|
||||||
|
const supplierName = collected.find((r) => r.flowResult.supplier)?.flowResult.supplier;
|
||||||
|
if (supplierName) {
|
||||||
|
try {
|
||||||
|
const supplierSpId = await service.saveSupplier(supplierName);
|
||||||
|
patch(localId, { supplierName, supplierSpId });
|
||||||
|
} catch {
|
||||||
|
/* продолжаем без поставщика */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
patch(localId, { status: 'done', flowResults: collected });
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { FlowResult } from '../types/domain';
|
||||||
|
|
||||||
|
// Локальные типы обработки КП на Шаге 2 (стейт файла в процессе конвейера).
|
||||||
|
|
||||||
|
export type KpFileStatus = 'uploading' | 'extracting' | 'flowing' | 'done' | 'error';
|
||||||
|
|
||||||
|
// Результат потока по одной позиции (для последующей сборки офферов).
|
||||||
|
export interface FlowPositionResult {
|
||||||
|
positionId: string; // локальный id позиции (SelectedPosition.lineId)
|
||||||
|
flowResult: FlowResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Файл КП в процессе обработки. Живёт в локальном стейте Шага 2; готовые (done) попадают в formData.
|
||||||
|
export interface KpFile {
|
||||||
|
localId: string;
|
||||||
|
file: File;
|
||||||
|
status: KpFileStatus;
|
||||||
|
spId?: number; // Id файла в библиотеке «Коммерческие предложения» (offerId для офферов)
|
||||||
|
url?: string; // ссылка на залитый файл
|
||||||
|
supplierName?: string; // распознанный поставщик (Syntex customer / поток supplier)
|
||||||
|
supplierSpId?: number; // Id поставщика в «Поставщики»
|
||||||
|
currencyCode: string; // валюта КП (KZT по умолчанию)
|
||||||
|
flowDone: number; // сколько позиций уже обработал поток
|
||||||
|
flowTotal: number; // всего позиций
|
||||||
|
error?: string;
|
||||||
|
flowResults: FlowPositionResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Точечное обновление файла в стейте (передаётся в конвейер вместо прямого доступа к React-стейту).
|
||||||
|
export type PatchFile = (localId: string, patch: Partial<KpFile>) => void;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { CtFormData, Supplier, SupplierCriteria, SupplierOffer, UploadedKpFile } from '../types/domain';
|
||||||
|
import { KpsCtFormService } from '../../services/KpsCtFormService';
|
||||||
|
import { KpFile } from './kpTypes';
|
||||||
|
import { buildKpCriteria, buildKpOffers, buildKpSuppliers, buildOffersForSp } from './kpOffers';
|
||||||
|
|
||||||
|
// Чистая логика сохранения Шага 2 (без React) — вынесено из компонента ради читаемости (стайл-гайд).
|
||||||
|
// Порядок: курсы → поставщики без КП → офферы КП в SP → мерж КП-данных в formData.
|
||||||
|
export interface PersistStep2Input {
|
||||||
|
service: KpsCtFormService;
|
||||||
|
ctId?: number;
|
||||||
|
kpFiles: KpFile[];
|
||||||
|
formData: CtFormData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistStep2(input: PersistStep2Input): Promise<Partial<CtFormData>> {
|
||||||
|
const { service, ctId, kpFiles, formData } = input;
|
||||||
|
const doneFiles = kpFiles.filter((f) => f.status === 'done');
|
||||||
|
|
||||||
|
// 1) Курсы валют — первыми: их spId нужны как valuta в офферах.
|
||||||
|
const savedRates = await service.saveCurrencyRates(formData.currencyRates ?? []);
|
||||||
|
|
||||||
|
// 2) Поставщики без КП → «Не получены КП», привязка к СТ (ctId) для восстановления у согласующего.
|
||||||
|
const noKp = (formData.suppliers ?? []).filter((s) => s.hasOffer === false);
|
||||||
|
for (const s of noKp) {
|
||||||
|
await service.saveNoKpSupplier(s.name, s.comment ?? '', ctId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Сущности из обработанных КП.
|
||||||
|
const kpSuppliers = buildKpSuppliers(doneFiles);
|
||||||
|
const kpOffers = buildKpOffers(doneFiles);
|
||||||
|
const kpCriteria = buildKpCriteria(doneFiles);
|
||||||
|
|
||||||
|
// 4) Офферы → «Оценка поставщиков» (на Шаге 2, как эталон). Нужен ctId — позиции должны иметь spId.
|
||||||
|
if (ctId !== undefined && kpOffers.length > 0) {
|
||||||
|
const { positionSpIds, rows } = buildOffersForSp(kpOffers, kpSuppliers, formData.selectedPositions, savedRates);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await service.saveOffers(positionSpIds, rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Мерж КП-данных в formData: заменяем записи по поставщикам из этой загрузки.
|
||||||
|
const kpNames = new Set(kpSuppliers.map((s) => s.name));
|
||||||
|
const kpIds = new Set(kpSuppliers.map((s) => s.id));
|
||||||
|
const existing = formData.suppliers ?? [];
|
||||||
|
const suppliers: Supplier[] = [
|
||||||
|
...existing.filter((s) => s.hasOffer === false), // без-КП — управляются своим блоком, сохраняем
|
||||||
|
...existing.filter((s) => s.hasOffer !== false && !kpNames.has(s.name)), // прочие с-КП (напр. с Шага 3)
|
||||||
|
...kpSuppliers
|
||||||
|
];
|
||||||
|
const offers: SupplierOffer[] = [...(formData.offers ?? []).filter((o) => !kpIds.has(o.supplierId)), ...kpOffers];
|
||||||
|
const criteria: SupplierCriteria[] = [
|
||||||
|
...(formData.criteria ?? []).filter((c) => !kpIds.has(c.supplierId)),
|
||||||
|
...kpCriteria
|
||||||
|
];
|
||||||
|
const uploadedKpFiles = mergeUploaded(formData.uploadedKpFiles ?? [], doneFiles);
|
||||||
|
|
||||||
|
return { currencyRates: savedRates, suppliers, offers, criteria, uploadedKpFiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeUploaded(existing: UploadedKpFile[], doneFiles: KpFile[]): UploadedKpFile[] {
|
||||||
|
const bySpId = new Map<number, UploadedKpFile>();
|
||||||
|
existing.forEach((u) => bySpId.set(u.spId, u));
|
||||||
|
doneFiles.forEach((f) => {
|
||||||
|
if (f.spId !== undefined) {
|
||||||
|
bySpId.set(f.spId, { spId: f.spId, name: f.file.name, supplierName: f.supplierName, url: f.url });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(bySpId.values());
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { CtFormData } from '../types/domain';
|
||||||
|
import { KpsCtFormService } from '../../services/KpsCtFormService';
|
||||||
|
import { KpFile } from './kpTypes';
|
||||||
|
import { processKpFile } from './kpPipeline';
|
||||||
|
|
||||||
|
// Хук обработки КП на Шаге 2: держит стейт файлов и запускает конвейер (заливка → Syntex → поток).
|
||||||
|
// Логика конвейера — в kpPipeline.ts; здесь только React-стейт и колбэки (стайл-гайд: логика вне view).
|
||||||
|
|
||||||
|
let localIdCounter = 0;
|
||||||
|
|
||||||
|
export interface UseKpProcessingResult {
|
||||||
|
kpFiles: KpFile[];
|
||||||
|
isProcessing: boolean;
|
||||||
|
duplicateWarning?: string;
|
||||||
|
addFiles: (files: File[]) => void;
|
||||||
|
removeFile: (localId: string) => void;
|
||||||
|
setCurrency: (localId: string, currency: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKpProcessing(service: KpsCtFormService, formData: CtFormData, disabled: boolean): UseKpProcessingResult {
|
||||||
|
const [kpFiles, setKpFiles] = useState<KpFile[]>([]);
|
||||||
|
const [duplicateWarning, setDuplicateWarning] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const formDataRef = useRef(formData);
|
||||||
|
const kpFilesRef = useRef<KpFile[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
formDataRef.current = formData;
|
||||||
|
}, [formData]);
|
||||||
|
useEffect(() => {
|
||||||
|
kpFilesRef.current = kpFiles;
|
||||||
|
}, [kpFiles]);
|
||||||
|
useEffect(() => () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const patch = useCallback((localId: string, part: Partial<KpFile>) => {
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setKpFiles((prev) => prev.map((f) => (f.localId === localId ? { ...f, ...part } : f)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const incFlowDone = useCallback((localId: string) => {
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setKpFiles((prev) => prev.map((f) => (f.localId === localId ? { ...f, flowDone: f.flowDone + 1 } : f)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addFiles = useCallback(
|
||||||
|
(files: File[]) => {
|
||||||
|
if (disabled || files.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const known = new Set([
|
||||||
|
...kpFilesRef.current.map((f) => f.file.name),
|
||||||
|
...(formDataRef.current.uploadedKpFiles ?? []).map((f) => f.name)
|
||||||
|
]);
|
||||||
|
const fresh = files.filter((f) => !known.has(f.name));
|
||||||
|
const skipped = files.filter((f) => known.has(f.name));
|
||||||
|
setDuplicateWarning(skipped.length ? `Файл(ы) уже загружены: ${skipped.map((f) => f.name).join(', ')}` : undefined);
|
||||||
|
if (fresh.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: KpFile[] = fresh.map((file) => ({
|
||||||
|
localId: `kp_${++localIdCounter}`,
|
||||||
|
file,
|
||||||
|
status: 'uploading',
|
||||||
|
currencyCode: 'KZT',
|
||||||
|
flowDone: 0,
|
||||||
|
flowTotal: 0,
|
||||||
|
flowResults: []
|
||||||
|
}));
|
||||||
|
setKpFiles((prev) => [...prev, ...entries]);
|
||||||
|
|
||||||
|
const deps = {
|
||||||
|
service,
|
||||||
|
getPositions: () => formDataRef.current.selectedPositions,
|
||||||
|
isMounted: () => mountedRef.current,
|
||||||
|
patch,
|
||||||
|
incFlowDone
|
||||||
|
};
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
processKpFile(entry.file, entry.localId, deps).catch((err: Error) => {
|
||||||
|
patch(entry.localId, { status: 'error', error: err.message });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[service, disabled, patch, incFlowDone]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeFile = useCallback((localId: string) => {
|
||||||
|
setKpFiles((prev) => prev.filter((f) => f.localId !== localId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setCurrency = useCallback((localId: string, currency: string) => {
|
||||||
|
setKpFiles((prev) => prev.map((f) => (f.localId === localId ? { ...f, currencyCode: currency } : f)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isProcessing = kpFiles.some(
|
||||||
|
(f) => f.status === 'uploading' || f.status === 'extracting' || f.status === 'flowing'
|
||||||
|
);
|
||||||
|
|
||||||
|
return { kpFiles, isProcessing, duplicateWarning, addFiles, removeFile, setCurrency };
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { DefaultButton, PrimaryButton, TextField } from '@fluentui/react';
|
||||||
|
import styles from './Step3BuildCt.module.scss';
|
||||||
|
|
||||||
|
// Строка добавления в конце таблицы (по эталону): ссылки «+ Добавить позицию/доставку» → инлайн-форма.
|
||||||
|
// Держит только UI-стейт формы; запись — через колбэки матрицы (логика в useStep3Matrix).
|
||||||
|
interface AddExtraRowProps {
|
||||||
|
colSpan: number;
|
||||||
|
onAddPosition: (name: string, unit: string) => void;
|
||||||
|
onAddDelivery: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddExtraRow({ colSpan, onAddPosition, onAddDelivery }: AddExtraRowProps): JSX.Element {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [unit, setUnit] = useState('');
|
||||||
|
|
||||||
|
const reset = (): void => {
|
||||||
|
setShowForm(false);
|
||||||
|
setName('');
|
||||||
|
setUnit('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onAddPosition(name, unit);
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showForm) {
|
||||||
|
return (
|
||||||
|
<tr className={styles.addPositionRow}>
|
||||||
|
<td colSpan={colSpan}>
|
||||||
|
<div className={styles.addPosInlineForm}>
|
||||||
|
<TextField
|
||||||
|
className={styles.addInput}
|
||||||
|
placeholder="Наименование позиции"
|
||||||
|
ariaLabel="Наименование позиции"
|
||||||
|
value={name}
|
||||||
|
onChange={(_, val) => setName(val ?? '')}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className={styles.addInputSmall}
|
||||||
|
placeholder="Ед."
|
||||||
|
ariaLabel="Единица измерения"
|
||||||
|
value={unit}
|
||||||
|
onChange={(_, val) => setUnit(val ?? '')}
|
||||||
|
/>
|
||||||
|
<PrimaryButton iconProps={{ iconName: 'Add' }} text="Добавить" onClick={submit} disabled={!name.trim()} />
|
||||||
|
<DefaultButton iconProps={{ iconName: 'Cancel' }} text="Отмена" onClick={reset} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className={styles.addPositionRow}>
|
||||||
|
<td colSpan={colSpan}>
|
||||||
|
<div className={styles.addPositionLinks}>
|
||||||
|
<button type="button" className={styles.addPositionBtn} onClick={() => setShowForm(true)}>
|
||||||
|
+ Добавить позицию (Доп. расходы)
|
||||||
|
</button>
|
||||||
|
<button type="button" className={styles.addPositionBtn} onClick={onAddDelivery}>
|
||||||
|
+ Добавить доставку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { IconButton, MessageBar, MessageBarType, Spinner, SpinnerSize } from '@fluentui/react';
|
||||||
|
import styles from './Step3BuildCt.module.scss';
|
||||||
|
|
||||||
|
// Блок «Комментарий ИИ» (Шаг 3): показывается только если есть комментарий, идёт загрузка или ошибка.
|
||||||
|
// Кнопка удаления — лишь когда onDelete задан (в read-only её не передаём).
|
||||||
|
interface AiCommentBlockProps {
|
||||||
|
comment?: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
error?: string;
|
||||||
|
onDelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Комментарий ИИ приходит в Markdown (**жирный**, маркированные списки «- »). Рендерим лёгкую разметку
|
||||||
|
// в React-узлы (без dangerouslySetInnerHTML): жирный/списки/абзацы — сами звёздочки-маркеры не видны.
|
||||||
|
function renderInline(text: string, keyPrefix: string): React.ReactNode[] {
|
||||||
|
return text.split(/(\*\*[^*]+\*\*)/g).map((part, i) => {
|
||||||
|
const bold = /^\*\*([^*]+)\*\*$/.exec(part);
|
||||||
|
return bold ? <strong key={`${keyPrefix}-${i}`}>{bold[1]}</strong> : part;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComment(text: string): JSX.Element[] {
|
||||||
|
const blocks: JSX.Element[] = [];
|
||||||
|
let bullets: string[] = [];
|
||||||
|
const flushBullets = (): void => {
|
||||||
|
if (bullets.length === 0) return;
|
||||||
|
const at = blocks.length;
|
||||||
|
blocks.push(
|
||||||
|
<ul key={`ul-${at}`}>
|
||||||
|
{bullets.map((b, i) => (
|
||||||
|
<li key={i}>{renderInline(b, `li-${at}-${i}`)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
bullets = [];
|
||||||
|
};
|
||||||
|
text.split(/\r?\n/).forEach((raw) => {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) {
|
||||||
|
flushBullets();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bullet = /^[-*]\s+(.*)$/.exec(line); // «- » или «* » (но не «**жирный**» — там нет пробела)
|
||||||
|
if (bullet) {
|
||||||
|
bullets.push(bullet[1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flushBullets();
|
||||||
|
blocks.push(<p key={`p-${blocks.length}`}>{renderInline(line, `p-${blocks.length}`)}</p>);
|
||||||
|
});
|
||||||
|
flushBullets();
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiCommentBlock(props: AiCommentBlockProps): JSX.Element {
|
||||||
|
const { comment, isLoading, error, onDelete } = props;
|
||||||
|
return (
|
||||||
|
<div className={styles.aiCommentBlock}>
|
||||||
|
<div className={styles.aiCommentTitle}>
|
||||||
|
Комментарий ИИ
|
||||||
|
{comment && !isLoading && onDelete ? (
|
||||||
|
<IconButton
|
||||||
|
iconProps={{ iconName: 'Delete' }}
|
||||||
|
title="Удалить комментарий"
|
||||||
|
className={styles.aiCommentDeleteBtn}
|
||||||
|
onClick={onDelete}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className={styles.aiCommentBody}>
|
||||||
|
<Spinner size={SpinnerSize.small} label="Генерация комментария…" />
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
{error ? (
|
||||||
|
<div className={styles.aiCommentBody}>
|
||||||
|
<MessageBar messageBarType={MessageBarType.error} isMultiline={false}>
|
||||||
|
{error}
|
||||||
|
</MessageBar>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
{comment ? <div className={styles.aiCommentText}>{renderComment(comment)}</div> : undefined}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Supplier } from '../types/domain';
|
||||||
|
import { formatKzt } from '../utils/format';
|
||||||
|
import { Step3Matrix, subColsOf } from './useStep3Matrix';
|
||||||
|
import { MatrixRowView } from './MatrixRowView';
|
||||||
|
import { AddExtraRow } from './AddExtraRow';
|
||||||
|
import styles from './Step3BuildCt.module.scss';
|
||||||
|
|
||||||
|
const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во
|
||||||
|
|
||||||
|
interface ComparisonMatrixProps {
|
||||||
|
suppliers: Supplier[];
|
||||||
|
matrix: Step3Matrix;
|
||||||
|
isReadOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Таблица сравнения: слева фикс-колонки, справа по колонке(ам) на поставщика. Только вёрстка —
|
||||||
|
// вся логика в useStep3Matrix (§стайл-гайд: логика отделена от представления).
|
||||||
|
export function ComparisonMatrix({ suppliers, matrix, isReadOnly }: ComparisonMatrixProps): JSX.Element {
|
||||||
|
const { recommendedId, selectedId, supplierTotals, regularTotals, extraTotals } = matrix;
|
||||||
|
const regularRows = matrix.rows.filter((r) => !r.isExtra);
|
||||||
|
const extraRows = matrix.rows.filter((r) => r.isExtra);
|
||||||
|
// Полная ширина таблицы (фикс-колонки + суб-колонки всех поставщиков) — для строки добавления.
|
||||||
|
const totalColSpan = FIXED_COLS + suppliers.reduce((sum, s) => sum + subColsOf(s.currency ?? 'KZT'), 0);
|
||||||
|
|
||||||
|
// Строка итога: подпись + по каждому поставщику пустые под-колонки и ячейка суммы.
|
||||||
|
const totalsRow = (label: string, totals: Record<string, number>, highlightRecommended: boolean, rowCls: string): JSX.Element => (
|
||||||
|
<tr className={rowCls}>
|
||||||
|
<td colSpan={FIXED_COLS} className={styles.totalLabel}>
|
||||||
|
<strong>{label}</strong>
|
||||||
|
</td>
|
||||||
|
{suppliers.map((s) => {
|
||||||
|
const isSel = s.id === selectedId;
|
||||||
|
const total = totals[s.id] ?? 0;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={s.id}>
|
||||||
|
<td colSpan={subColsOf(s.currency ?? 'KZT') - 1} className={isSel ? styles.colSelected : undefined} />
|
||||||
|
<td
|
||||||
|
className={[styles.totalCell, highlightRecommended && s.id === recommendedId ? styles.totalRecommended : '', isSel ? styles.colSelected : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
|
<strong>{total > 0 ? formatKzt(total) : '—'}</strong>
|
||||||
|
</td>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tableScroll}>
|
||||||
|
<table className={styles.ctTable}>
|
||||||
|
<thead>
|
||||||
|
<tr className={styles.headerRow1}>
|
||||||
|
<th rowSpan={2} className={styles.colPosition}>Наименование</th>
|
||||||
|
<th rowSpan={2} className={styles.colUnit}>Ед. изм.</th>
|
||||||
|
<th rowSpan={2} className={styles.colQty}>Кол-во</th>
|
||||||
|
{suppliers.map((s) => {
|
||||||
|
const currency = s.currency ?? 'KZT';
|
||||||
|
const isSel = s.id === selectedId;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={s.id}
|
||||||
|
colSpan={subColsOf(currency)}
|
||||||
|
className={[styles.supplierGroupHeader, isSel ? styles.colSelected : ''].filter(Boolean).join(' ')}
|
||||||
|
>
|
||||||
|
<div className={styles.supplierHeader}>
|
||||||
|
<span className={styles.supplierNameText}>{s.name}</span>
|
||||||
|
{s.id === recommendedId ? <span className={styles.recommendedBadge}>Рекомендуется</span> : undefined}
|
||||||
|
{s.hasOffer === false ? <span className={styles.noOfferTag}>Без КП</span> : undefined}
|
||||||
|
{currency !== 'KZT' ? <span className={styles.currencyTag}>{currency}</span> : undefined}
|
||||||
|
<div className={styles.supplierActions}>
|
||||||
|
{!isReadOnly ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={[styles.selectBtn, isSel ? styles.selectBtnActive : ''].filter(Boolean).join(' ')}
|
||||||
|
onClick={() => matrix.selectSupplier(s.id)}
|
||||||
|
>
|
||||||
|
{isSel ? 'Выбран' : 'Выбрать'}
|
||||||
|
</button>
|
||||||
|
<button className={styles.deleteBtn} title="Удалить поставщика" onClick={() => matrix.removeSupplier(s.id)}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
isSel ? <span className={styles.selectedBadge}>Выбран</span> : undefined
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
<tr className={styles.headerRow2}>
|
||||||
|
{suppliers.map((s) => {
|
||||||
|
const isForeign = (s.currency ?? 'KZT') !== 'KZT';
|
||||||
|
const selCls = s.id === selectedId ? styles.colSelected : '';
|
||||||
|
const sub = (label: string, name = false): JSX.Element => (
|
||||||
|
<th className={[styles.subHeader, name ? styles.subHeaderName : '', selCls].filter(Boolean).join(' ')}>{label}</th>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={s.id}>
|
||||||
|
{sub('Наименование ТМЦ', true)}
|
||||||
|
{sub('Ед. изм.')}
|
||||||
|
{sub('Кол-во')}
|
||||||
|
{isForeign ? (
|
||||||
|
<>
|
||||||
|
{sub(`Цена, ${s.currency}`)}
|
||||||
|
{sub(`Сумма, ${s.currency}`)}
|
||||||
|
</>
|
||||||
|
) : undefined}
|
||||||
|
{sub('Цена, ₸')}
|
||||||
|
{sub('Сумма, ₸')}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{regularRows.map((row, index) => (
|
||||||
|
<MatrixRowView key={row.id} row={row} index={index} suppliers={suppliers} matrix={matrix} isReadOnly={isReadOnly} />
|
||||||
|
))}
|
||||||
|
{totalsRow('Итого, ₸', regularTotals, false, styles.subtotalRow)}
|
||||||
|
|
||||||
|
{extraRows.map((row, index) => (
|
||||||
|
<MatrixRowView key={row.id} row={row} index={regularRows.length + index} suppliers={suppliers} matrix={matrix} isReadOnly={isReadOnly} />
|
||||||
|
))}
|
||||||
|
{extraRows.length > 0 ? totalsRow('Сумма доп. расходов, ₸', extraTotals, false, styles.subtotalRow) : undefined}
|
||||||
|
|
||||||
|
{totalsRow('Общая сумма, ₸', supplierTotals, true, styles.totalRow)}
|
||||||
|
|
||||||
|
{!isReadOnly ? (
|
||||||
|
<AddExtraRow colSpan={totalColSpan} onAddPosition={matrix.addExtraPosition} onAddDelivery={matrix.addDelivery} />
|
||||||
|
) : undefined}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
@import '../common/theme';
|
||||||
|
|
||||||
|
.root {
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
background: #f0f6fc; // тон шапки как в эталоне (на шаг светлее $accent-bg)
|
||||||
|
border-bottom: 1px solid #c7e0f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.labelHead {
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: $head-bg;
|
||||||
|
border-right: 1px solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplierHead {
|
||||||
|
padding: 8px 14px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
background: #f0f6fc;
|
||||||
|
border-left: 1px solid $border;
|
||||||
|
border-bottom: 1px solid #c7e0f4;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $muted;
|
||||||
|
background: $head-bg;
|
||||||
|
border-right: 1px solid $border;
|
||||||
|
border-bottom: 1px solid $hover; // мягкая горизонтальная линия строк, как в эталоне
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text;
|
||||||
|
border-left: 1px solid $border;
|
||||||
|
border-bottom: 1px solid $hover;
|
||||||
|
vertical-align: top;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: #f8f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свободный ввод (paymentType/deadline/comment) — с аффордансом редактирования из эталона:
|
||||||
|
// пунктирное подчёркивание, синяя подсветка на hover/focus, без ручки ресайза.
|
||||||
|
.input {
|
||||||
|
:global(.ms-TextField-wrapper) {
|
||||||
|
border-bottom: 1px dashed $border-strong;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
border-radius: 2px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-bottom-color: $accent;
|
||||||
|
background: rgba(0, 120, 212, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within :global(.ms-TextField-wrapper) {
|
||||||
|
border-bottom-color: $accent;
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
background: rgba(0, 120, 212, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ms-TextField-field) {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
min-height: 26px;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
height: 30px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid $border-strong;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: $text;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $accent;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { TextField } from '@fluentui/react';
|
||||||
|
import { Supplier, SupplierCriteria } from '../types/domain';
|
||||||
|
import styles from './CriteriaTable.module.scss';
|
||||||
|
|
||||||
|
type CriteriaField = 'paymentType' | 'deliveryType' | 'deadline' | 'sertificates' | 'comment';
|
||||||
|
|
||||||
|
// Строки блока «Дополнительная информация». Для Choice-полей задаём варианты (options).
|
||||||
|
const ROWS: Array<{ key: CriteriaField; label: string; options?: string[] }> = [
|
||||||
|
{ key: 'paymentType', label: 'Условия оплаты' },
|
||||||
|
{ key: 'deliveryType', label: 'Условия поставки', options: ['DAP', 'FOB', 'DDP', 'EXW'] },
|
||||||
|
{ key: 'deadline', label: 'Сроки изготовления и доставки' },
|
||||||
|
{ key: 'sertificates', label: 'Сертификаты', options: ['Да', 'Нет'] },
|
||||||
|
{ key: 'comment', label: 'Примечание' }
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CriteriaTableProps {
|
||||||
|
suppliers: Supplier[];
|
||||||
|
criteria: SupplierCriteria[];
|
||||||
|
onChange: (criteria: SupplierCriteria[]) => void;
|
||||||
|
isReadOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Блок «Дополнительная информация»: строки-критерии × колонки-поставщики. Логика правок — локальная
|
||||||
|
// (иммутабельно через onChange в formData.criteria); сохранение в «Критерии» — на «Далее» (step3Persist).
|
||||||
|
export function CriteriaTable({ suppliers, criteria, onChange, isReadOnly }: CriteriaTableProps): JSX.Element {
|
||||||
|
const valueOf = (supplierId: string, field: CriteriaField): string => criteria.find((c) => c.supplierId === supplierId)?.[field] ?? '';
|
||||||
|
|
||||||
|
const update = (supplierId: string, field: CriteriaField, value: string): void => {
|
||||||
|
const exists = criteria.some((c) => c.supplierId === supplierId);
|
||||||
|
const next = exists
|
||||||
|
? criteria.map((c) => (c.supplierId === supplierId ? { ...c, [field]: value } : c))
|
||||||
|
: [...criteria, { supplierId, [field]: value }];
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderInput = (supplier: Supplier, row: { key: CriteriaField; options?: string[] }): JSX.Element => {
|
||||||
|
const value = valueOf(supplier.id, row.key);
|
||||||
|
if (isReadOnly) {
|
||||||
|
return <span>{value || '—'}</span>;
|
||||||
|
}
|
||||||
|
if (row.options) {
|
||||||
|
// Значение из потока может не совпасть с вариантами Choice — добавляем его в список, чтобы показать.
|
||||||
|
const options = value && row.options.indexOf(value) === -1 ? [value, ...row.options] : row.options;
|
||||||
|
return (
|
||||||
|
<select className={styles.select} value={value} onChange={(e) => update(supplier.id, row.key, e.target.value)} aria-label={`${row.key} ${supplier.name}`}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
borderless
|
||||||
|
multiline
|
||||||
|
autoAdjustHeight
|
||||||
|
className={styles.input}
|
||||||
|
value={value}
|
||||||
|
onChange={(_, v) => update(supplier.id, row.key, v ?? '')}
|
||||||
|
placeholder="—"
|
||||||
|
ariaLabel={`${row.key} ${supplier.name}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root}>
|
||||||
|
<h3 className={styles.title}>Дополнительная информация</h3>
|
||||||
|
<div className={styles.scroll}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={styles.labelHead} />
|
||||||
|
{suppliers.map((s) => (
|
||||||
|
<th key={s.id} className={styles.supplierHead}>
|
||||||
|
{s.name}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ROWS.map((row) => (
|
||||||
|
<tr key={row.key}>
|
||||||
|
<td className={styles.label}>{row.label}</td>
|
||||||
|
{suppliers.map((s) => (
|
||||||
|
<td key={s.id} className={styles.cell}>
|
||||||
|
{renderInput(s, row)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Supplier } from '../types/domain';
|
||||||
|
import { MatrixRow, Step3Matrix, subColsOf } from './useStep3Matrix';
|
||||||
|
import { OfferCells } from './OfferCells';
|
||||||
|
import styles from './Step3BuildCt.module.scss';
|
||||||
|
|
||||||
|
interface MatrixRowViewProps {
|
||||||
|
row: MatrixRow;
|
||||||
|
index: number;
|
||||||
|
suppliers: Supplier[];
|
||||||
|
matrix: Step3Matrix;
|
||||||
|
isReadOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Одна позиция матрицы: несколько <tr> при комплекте (bundle). Фикс-колонки (наименование/ед./кол-во)
|
||||||
|
// объединены rowSpan на первой под-строке; у поставщика с меньшим числом строк — пустые ячейки.
|
||||||
|
export function MatrixRowView({ row, index, suppliers, matrix, isReadOnly }: MatrixRowViewProps): JSX.Element {
|
||||||
|
const perSupplierItems = suppliers.map((s) => matrix.itemsFor(s.id, row.id));
|
||||||
|
const maxSubRows = Math.max(1, ...perSupplierItems.map((items) => items.length));
|
||||||
|
const rowClass = row.isExtra ? styles.extraRow : styles.row;
|
||||||
|
|
||||||
|
const subRows: JSX.Element[] = [];
|
||||||
|
for (let rowIdx = 0; rowIdx < maxSubRows; rowIdx++) {
|
||||||
|
subRows.push(
|
||||||
|
<tr key={rowIdx} className={rowClass}>
|
||||||
|
{rowIdx === 0 ? (
|
||||||
|
<>
|
||||||
|
<td className={styles.nameCell} rowSpan={maxSubRows}>
|
||||||
|
<span className={styles.idx}>{index + 1}.</span> {row.name}
|
||||||
|
{row.isExtra ? <span className={styles.extraBadge}>{row.isDelivery ? 'Доставка' : 'Доп. расходы'}</span> : undefined}
|
||||||
|
{row.isExtra && !isReadOnly ? (
|
||||||
|
<button className={styles.deleteExtraBtn} title="Удалить" onClick={() => matrix.removeExtraPosition(row.id)}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
) : undefined}
|
||||||
|
</td>
|
||||||
|
<td className={styles.unitCell} rowSpan={maxSubRows}>{row.unit}</td>
|
||||||
|
<td className={styles.qtyCell} rowSpan={maxSubRows}>{row.isExtra ? '—' : row.quantity}</td>
|
||||||
|
</>
|
||||||
|
) : undefined}
|
||||||
|
{suppliers.map((s, si) => {
|
||||||
|
const items = perSupplierItems[si];
|
||||||
|
const item = items[rowIdx];
|
||||||
|
const selected = s.id === matrix.selectedId;
|
||||||
|
if (rowIdx > 0 && item === undefined) {
|
||||||
|
// у этого поставщика меньше строк комплекта — пустая ячейка на всю ширину группы
|
||||||
|
return <td key={s.id} colSpan={subColsOf(s.currency ?? 'KZT')} className={[styles.cell, selected ? styles.colSelected : ''].filter(Boolean).join(' ')} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<OfferCells
|
||||||
|
key={s.id}
|
||||||
|
supplier={s}
|
||||||
|
row={row}
|
||||||
|
matrix={matrix}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
selected={selected}
|
||||||
|
item={item}
|
||||||
|
itemIndex={rowIdx}
|
||||||
|
itemsCount={items.length}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{subRows}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { TextField } from '@fluentui/react';
|
||||||
|
import { Supplier, SupplierOffer } from '../types/domain';
|
||||||
|
import { formatKzt, formatMoney } from '../utils/format';
|
||||||
|
import { MatrixRow, Step3Matrix } from './useStep3Matrix';
|
||||||
|
import styles from './Step3BuildCt.module.scss';
|
||||||
|
|
||||||
|
interface OfferCellsProps {
|
||||||
|
supplier: Supplier;
|
||||||
|
row: MatrixRow;
|
||||||
|
matrix: Step3Matrix;
|
||||||
|
isReadOnly: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
item?: SupplierOffer; // строка комплекта (может отсутствовать → значения по умолчанию)
|
||||||
|
itemIndex: number;
|
||||||
|
itemsCount: number; // сколько строк комплекта у этой ячейки (0 = одна пустая по умолчанию)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ячейки одного поставщика по одной строке комплекта: 5 колонок для ₸, 7 для валюты.
|
||||||
|
// Кнопки комплекта (+/×) — в колонке наименования (кроме доставки).
|
||||||
|
export function OfferCells(props: OfferCellsProps): JSX.Element {
|
||||||
|
const { supplier, row, matrix, isReadOnly, selected, item, itemIndex, itemsCount } = props;
|
||||||
|
|
||||||
|
const currency = supplier.currency ?? 'KZT';
|
||||||
|
const isForeign = currency !== 'KZT';
|
||||||
|
const rate = matrix.rateOf(currency);
|
||||||
|
|
||||||
|
const qty = item?.quantity ?? (itemIndex === 0 ? row.quantity : 0);
|
||||||
|
const price = item?.pricePerUnit ?? 0;
|
||||||
|
const km = item?.deliveryDistanceKm;
|
||||||
|
const sumCur = qty * price;
|
||||||
|
const priceKzt = price * rate;
|
||||||
|
const sumKzt = qty * priceKzt;
|
||||||
|
|
||||||
|
const nameVal = item?.supplierPosition ?? row.name;
|
||||||
|
const unitVal = item?.unit ?? row.unit;
|
||||||
|
const selCls = selected ? styles.colSelected : '';
|
||||||
|
const key = (field: string): string => `${supplier.id}::${row.id}::${itemIndex}::${field}`;
|
||||||
|
|
||||||
|
const cell = (content: React.ReactNode, extraCls = ''): JSX.Element => (
|
||||||
|
<td className={[styles.cell, extraCls, selCls].filter(Boolean).join(' ')}>{content}</td>
|
||||||
|
);
|
||||||
|
|
||||||
|
const numInput = (field: 'quantity' | 'pricePerUnit' | 'deliveryDistanceKm', value: string, placeholder: string): JSX.Element => (
|
||||||
|
<TextField
|
||||||
|
borderless
|
||||||
|
className={styles.input}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(_, v) => matrix.editNumber(supplier.id, row.id, itemIndex, field, v ?? '')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const showBundle = !isReadOnly && !row.isDelivery;
|
||||||
|
const isLast = itemIndex === Math.max(0, itemsCount - 1);
|
||||||
|
|
||||||
|
// Доставка: «км» под названием «Доставка» в широком столбце «Наименование ТМЦ» — там есть место,
|
||||||
|
// поэтому узкая колонка «Кол-во» больше не режет цифры (в неё кладём только рейсы).
|
||||||
|
const kmContent = (
|
||||||
|
<div className={styles.kmLine}>
|
||||||
|
{isReadOnly ? (
|
||||||
|
<span className={styles.kmReadonly}>{km !== undefined ? `${km} км` : '—'}</span>
|
||||||
|
) : (
|
||||||
|
<span className={styles.kmControl}>
|
||||||
|
{numInput('deliveryDistanceKm', matrix.raw[key('deliveryDistanceKm')] ?? (km !== undefined ? String(km) : ''), '0')}
|
||||||
|
<span className={styles.kmUnit}>км</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameContent = (
|
||||||
|
<>
|
||||||
|
<div className={styles.bundleName}>
|
||||||
|
{isReadOnly ? (
|
||||||
|
<span>{nameVal}</span>
|
||||||
|
) : (
|
||||||
|
<TextField borderless className={styles.input} value={nameVal} onChange={(_, v) => matrix.editText(supplier.id, row.id, itemIndex, 'supplierPosition', v ?? '')} />
|
||||||
|
)}
|
||||||
|
{showBundle ? (
|
||||||
|
<span className={styles.bundleBtns}>
|
||||||
|
{itemsCount > 1 ? (
|
||||||
|
<button className={styles.bundleRemoveBtn} title="Удалить строку" onClick={() => matrix.removeBundleItem(supplier.id, row.id, itemIndex)}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
) : undefined}
|
||||||
|
{isLast ? (
|
||||||
|
<button className={styles.bundleAddBtn} title="Добавить строку комплекта" onClick={() => matrix.addBundleItem(supplier.id, row.id)}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
) : undefined}
|
||||||
|
</span>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
{row.isDelivery ? kmContent : undefined}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const qtyContent = isReadOnly ? (
|
||||||
|
<span>{qty || '—'}</span>
|
||||||
|
) : (
|
||||||
|
numInput('quantity', matrix.raw[key('quantity')] ?? (qty ? String(qty) : ''), row.isDelivery ? 'рейсы' : '—')
|
||||||
|
);
|
||||||
|
|
||||||
|
const priceInput = numInput('pricePerUnit', matrix.raw[key('pricePerUnit')] ?? (price ? String(price) : ''), '0');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{cell(nameContent, styles.cellName)}
|
||||||
|
{cell(
|
||||||
|
isReadOnly ? (
|
||||||
|
<span>{unitVal}</span>
|
||||||
|
) : (
|
||||||
|
<TextField borderless className={styles.input} value={unitVal} onChange={(_, v) => matrix.editText(supplier.id, row.id, itemIndex, 'unit', v ?? '')} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{cell(qtyContent)}
|
||||||
|
{isForeign ? (
|
||||||
|
<>
|
||||||
|
{cell(isReadOnly ? <span>{formatMoney(price)}</span> : priceInput)}
|
||||||
|
{cell(<span>{formatMoney(sumCur)}</span>)}
|
||||||
|
{cell(<span>{priceKzt > 0 ? formatKzt(priceKzt) : '—'}</span>)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
cell(isReadOnly ? <span>{price ? formatKzt(price) : '—'}</span> : priceInput)
|
||||||
|
)}
|
||||||
|
{cell(<span>{sumKzt > 0 ? formatKzt(sumKzt) : '—'}</span>, styles.sumCell)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,598 @@
|
|||||||
|
@import '../common/theme';
|
||||||
|
|
||||||
|
.state {
|
||||||
|
margin: 0;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: $danger;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Комментарий ИИ ---
|
||||||
|
|
||||||
|
.aiCommentBlock {
|
||||||
|
border: 1px solid #c7e0f4;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f0f9ff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiCommentTitle {
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: #e8f2fc;
|
||||||
|
border-bottom: 1px solid #c7e0f4;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #004578;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiCommentDeleteBtn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
color: #a4262c;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiCommentBody {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aiCommentText {
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text;
|
||||||
|
line-height: 1.6;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
> :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Тулбар добавления поставщика ---
|
||||||
|
|
||||||
|
.supplierToolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addInput {
|
||||||
|
flex: 0 1 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currencySelect {
|
||||||
|
height: 32px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid $border-strong;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: $text;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $accent;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.currencyTag {
|
||||||
|
font-size: 11px;
|
||||||
|
background: #e8f2fc;
|
||||||
|
color: #004578;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noOfferTag {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: $danger-bg;
|
||||||
|
color: $danger;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Таблица сравнения ---
|
||||||
|
|
||||||
|
.tableScroll {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctTable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerRow1 th {
|
||||||
|
background: $accent-bg;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
border-bottom: 1px solid #c7e0f4;
|
||||||
|
border-right: 1px solid $border;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerRow2 th {
|
||||||
|
background: #e8f2fc;
|
||||||
|
padding: 5px 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 11px;
|
||||||
|
color: $muted;
|
||||||
|
border-bottom: 2px solid #c7e0f4;
|
||||||
|
border-right: 1px solid $border;
|
||||||
|
position: sticky;
|
||||||
|
top: 38px; // высота headerRow1 (приблизительно)
|
||||||
|
z-index: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colPosition {
|
||||||
|
min-width: 220px;
|
||||||
|
text-align: left !important;
|
||||||
|
position: sticky !important;
|
||||||
|
left: 0;
|
||||||
|
z-index: 4 !important;
|
||||||
|
background: $accent-bg !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colUnit {
|
||||||
|
min-width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colQty {
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplierGroupHeader {
|
||||||
|
border-left: 2px solid #c7e0f4 !important;
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colSelected {
|
||||||
|
background: #fffde7 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subHeader {
|
||||||
|
border-left: 1px solid $border;
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
border-left: 2px solid #c7e0f4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subHeaderName {
|
||||||
|
min-width: 150px;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplierHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplierNameText {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendedBadge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: $accent;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplierActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectBtn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border: 1px solid $accent;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: $accent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $accent-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectBtnActive {
|
||||||
|
background: $accent;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteBtn {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $border;
|
||||||
|
color: $muted;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $danger-bg;
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedBadge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: $accent;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Тело ---
|
||||||
|
|
||||||
|
.row {
|
||||||
|
&:hover td {
|
||||||
|
background: $hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid $border;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameCell {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
z-index: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idx {
|
||||||
|
color: $muted;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unitCell {
|
||||||
|
text-align: center;
|
||||||
|
color: $muted;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qtyCell {
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-right: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
text-align: right;
|
||||||
|
border-left: 1px solid $border;
|
||||||
|
min-width: 90px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellName {
|
||||||
|
text-align: left;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sumCell {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
:global(.ms-TextField-field) {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
min-height: 26px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellName .input {
|
||||||
|
:global(.ms-TextField-field) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Итого ---
|
||||||
|
|
||||||
|
.subtotalRow td {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #f0f6fc; // светло-синий подытог
|
||||||
|
border-top: 1px solid #c7e0f4;
|
||||||
|
border-bottom: 1px solid #c7e0f4;
|
||||||
|
text-align: right;
|
||||||
|
color: #004578;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalRow td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: $accent-bg;
|
||||||
|
border-top: 2px solid $accent;
|
||||||
|
text-align: right;
|
||||||
|
color: #004578;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalLabel {
|
||||||
|
text-align: left !important;
|
||||||
|
color: $text !important;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
background: $accent-bg;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalCell {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 700;
|
||||||
|
border-left: 1px solid #c7e0f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalRecommended {
|
||||||
|
background: #c7e0f4 !important;
|
||||||
|
color: #003a6c !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Доп. расходы / доставка ---
|
||||||
|
|
||||||
|
// Строка добавления доп. позиции/доставки в конце таблицы (ссылки + инлайн-форма).
|
||||||
|
.addPositionRow td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid $border;
|
||||||
|
background: #ffffff;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ссылки добавления — вертикально (одна под другой).
|
||||||
|
.addPositionLinks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addPositionBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: $accent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 2px 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.addPosInlineForm {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addInputSmall {
|
||||||
|
flex: 0 0 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extraRow {
|
||||||
|
td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid $border;
|
||||||
|
vertical-align: middle;
|
||||||
|
background: #fffbf5; // кремовый фон доп.строки
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover td {
|
||||||
|
background: #fff6e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameCell {
|
||||||
|
background: #fffbf5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.extraBadge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff4ce;
|
||||||
|
color: #7d5900;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteExtraBtn {
|
||||||
|
margin-left: 6px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $border;
|
||||||
|
color: $muted;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $danger-bg;
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Доставка: «км» выводится под названием «Доставка» в широком столбце «Наименование ТМЦ»
|
||||||
|
// (не в узкой колонке «Кол-во») — места достаточно, цифры не режутся. Подпись не нужна:
|
||||||
|
// строка и так «Доставка», хватает суффикса «км».
|
||||||
|
.kmLine {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kmControl {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
width: 84px; // компактно, как аннотация под «Доставка» (не второе название)
|
||||||
|
|
||||||
|
:global(.ms-TextField) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kmUnit {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kmReadonly {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $muted;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Комплекты (bundle) ---
|
||||||
|
|
||||||
|
.bundleName {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
> *:first-child {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bundleBtns {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bundleAddBtn {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $accent;
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #106ebe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bundleRemoveBtn {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $border;
|
||||||
|
color: $muted;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $danger-bg;
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { DefaultButton, Dialog, DialogFooter, DialogType, PrimaryButton, TextField } from '@fluentui/react';
|
||||||
|
import { StepShell } from '../common/StepShell';
|
||||||
|
import { StepProps } from '../types/domain';
|
||||||
|
import { useStep3Matrix } from './useStep3Matrix';
|
||||||
|
import { useAiComment, AiCommentPayload } from './useAiComment';
|
||||||
|
import { ComparisonMatrix } from './ComparisonMatrix';
|
||||||
|
import { CriteriaTable } from './CriteriaTable';
|
||||||
|
import { AiCommentBlock } from './AiCommentBlock';
|
||||||
|
import { InlineApprovalChain } from '../Step4Approval/InlineApprovalChain';
|
||||||
|
import styles from './Step3BuildCt.module.scss';
|
||||||
|
|
||||||
|
// Шаг 3 «Формирование СТ» (§12): контейнер — тулбары (поставщики, доп. расходы) + матрица + комментарий ИИ.
|
||||||
|
// Логика — в useStep3Matrix/useAiComment, таблица — в ComparisonMatrix (§стайл-гайд: логика вне вёрстки).
|
||||||
|
export function Step3BuildCt(props: StepProps): JSX.Element {
|
||||||
|
const { formData, onPrev, isReadOnly } = props;
|
||||||
|
const positions = formData.selectedPositions;
|
||||||
|
const suppliers = formData.suppliers;
|
||||||
|
|
||||||
|
const matrix = useStep3Matrix({
|
||||||
|
service: props.service,
|
||||||
|
formData,
|
||||||
|
onSave: props.onSave,
|
||||||
|
onStepComplete: props.onStepComplete,
|
||||||
|
onNext: props.onNext,
|
||||||
|
isReadOnly
|
||||||
|
});
|
||||||
|
|
||||||
|
// Собираем payload для потока ИИ из текущей матрицы (номер/проект добавит сам хук из шапки СТ).
|
||||||
|
const buildAiPayload = (): AiCommentPayload => ({
|
||||||
|
positions: [
|
||||||
|
...positions.map((p) => ({ name: p.name, unit: p.unit, quantity: p.quantity })),
|
||||||
|
...(formData.step3ExtraPositions ?? []).map((e) => ({ name: e.materialName, unit: e.unit || '—', quantity: 0 }))
|
||||||
|
],
|
||||||
|
suppliers: suppliers
|
||||||
|
.map((s) => ({ name: s.name, grandTotal: matrix.supplierTotals[s.id] ?? 0 }))
|
||||||
|
.filter((s) => s.grandTotal > 0),
|
||||||
|
recommendedSupplier: suppliers.find((s) => s.id === matrix.selectedId)?.name
|
||||||
|
});
|
||||||
|
|
||||||
|
const ai = useAiComment({ service: props.service, ctId: formData.ctId, buildPayload: buildAiPayload });
|
||||||
|
|
||||||
|
// «Далее»: на редактируемой СТ перед переходом предлагаем получить комментарий ИИ (как эталон).
|
||||||
|
// Диалог показываем один раз (нет комментария и не идёт загрузка); дальше кнопка сохраняет.
|
||||||
|
const handleNextClick = (): void => {
|
||||||
|
if (!isReadOnly && formData.ctId && !ai.comment && !ai.isLoading) {
|
||||||
|
ai.openPrompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
matrix.handleNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StepShell
|
||||||
|
title="Формирование СТ"
|
||||||
|
onPrev={onPrev}
|
||||||
|
onNext={handleNextClick}
|
||||||
|
nextLabel={matrix.saving ? 'Сохранение…' : 'Далее'}
|
||||||
|
nextDisabled={matrix.saving || ai.isLoading}
|
||||||
|
>
|
||||||
|
{positions.length === 0 ? (
|
||||||
|
<p className={styles.state}>Позиции не выбраны — вернитесь на шаг «Выбор позиций».</p>
|
||||||
|
) : (
|
||||||
|
<div className={styles.body}>
|
||||||
|
{!isReadOnly ? (
|
||||||
|
<div className={styles.supplierToolbar}>
|
||||||
|
<TextField
|
||||||
|
className={styles.addInput}
|
||||||
|
placeholder="Наименование поставщика"
|
||||||
|
value={matrix.supplierName}
|
||||||
|
onChange={(_, value) => matrix.setSupplierName(value ?? '')}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
matrix.addSupplier();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className={styles.currencySelect}
|
||||||
|
value={matrix.supplierCurrency}
|
||||||
|
onChange={(e) => matrix.setSupplierCurrency(e.target.value)}
|
||||||
|
aria-label="Валюта поставщика"
|
||||||
|
>
|
||||||
|
{matrix.currencyOptions.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<DefaultButton
|
||||||
|
iconProps={{ iconName: 'Add' }}
|
||||||
|
text="Добавить поставщика"
|
||||||
|
onClick={matrix.addSupplier}
|
||||||
|
disabled={!matrix.supplierName.trim()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{suppliers.length === 0 ? (
|
||||||
|
<p className={styles.state}>Добавьте поставщиков, чтобы заполнить цены.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ComparisonMatrix suppliers={suppliers} matrix={matrix} isReadOnly={!!isReadOnly} />
|
||||||
|
|
||||||
|
<CriteriaTable
|
||||||
|
suppliers={suppliers}
|
||||||
|
criteria={formData.criteria ?? []}
|
||||||
|
onChange={(c) => props.onSave({ criteria: c })}
|
||||||
|
isReadOnly={!!isReadOnly}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ai.comment || ai.isLoading || ai.error ? (
|
||||||
|
<AiCommentBlock
|
||||||
|
comment={ai.comment}
|
||||||
|
isLoading={ai.isLoading}
|
||||||
|
error={ai.error}
|
||||||
|
onDelete={isReadOnly ? undefined : ai.deleteComment}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{props.isNotificationLink && isReadOnly ? (
|
||||||
|
<InlineApprovalChain
|
||||||
|
service={props.service}
|
||||||
|
formData={formData}
|
||||||
|
onSave={props.onSave}
|
||||||
|
onStepComplete={props.onStepComplete}
|
||||||
|
userDisplayName={props.userDisplayName}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{matrix.saveError ? <p className={styles.error}>Не удалось сохранить: {matrix.saveError}</p> : undefined}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</StepShell>
|
||||||
|
|
||||||
|
{ai.showPrompt ? (
|
||||||
|
<Dialog
|
||||||
|
hidden={false}
|
||||||
|
onDismiss={ai.closePrompt}
|
||||||
|
dialogContentProps={{
|
||||||
|
type: DialogType.normal,
|
||||||
|
title: 'Комментарий от ИИ',
|
||||||
|
subText: 'Получить комментарий от ИИ перед переходом на следующий шаг?'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogFooter>
|
||||||
|
<PrimaryButton
|
||||||
|
iconProps={{ iconName: 'Lightbulb' }}
|
||||||
|
text="Получить"
|
||||||
|
onClick={() => {
|
||||||
|
ai.closePrompt();
|
||||||
|
ai.requestComment().catch(() => undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DefaultButton
|
||||||
|
text="Пропустить"
|
||||||
|
onClick={() => {
|
||||||
|
ai.closePrompt();
|
||||||
|
matrix.handleNext();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
) : undefined}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { CtFormData, CurrencyRate, ExtraPosition, SelectedPosition, Supplier, SupplierCriteria, SupplierOffer } from '../types/domain';
|
||||||
|
import { KpsCtFormService } from '../../services/KpsCtFormService';
|
||||||
|
|
||||||
|
// Чистая логика сохранения Шага 3 в SharePoint (без React) — вынесено из хука ради читаемости.
|
||||||
|
// Порядок: поставщики → доп. позиции → офферы → winSupplier. Возвращает патч formData с новыми spId.
|
||||||
|
export interface PersistStep3Input {
|
||||||
|
service: KpsCtFormService;
|
||||||
|
ctId: number;
|
||||||
|
suppliers: Supplier[];
|
||||||
|
offers: SupplierOffer[];
|
||||||
|
positions: SelectedPosition[];
|
||||||
|
extras: ExtraPosition[];
|
||||||
|
rates: CurrencyRate[];
|
||||||
|
criteria: SupplierCriteria[];
|
||||||
|
selectedId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowMeta {
|
||||||
|
spId: number;
|
||||||
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
isDelivery: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistStep3(input: PersistStep3Input): Promise<Partial<CtFormData>> {
|
||||||
|
const { service, ctId, suppliers, offers, positions, extras, rates, criteria, selectedId } = input;
|
||||||
|
|
||||||
|
// 1) Поставщики → «Поставщики» (проставляем spId; push без индексного присваивания после await).
|
||||||
|
// Без-КП (hasOffer:false) заводим в справочник только если по ним введены цены — иначе они остаются
|
||||||
|
// лишь в «Не получены КП» (как в эталоне). Поставщики с КП заводим всегда.
|
||||||
|
const hasPrices = (supplierId: string): boolean =>
|
||||||
|
offers.some((o) => o.supplierId === supplierId && (o.quantity || 0) * (o.pricePerUnit || 0) > 0);
|
||||||
|
const savedSuppliers: Supplier[] = [];
|
||||||
|
for (const s of suppliers) {
|
||||||
|
const promote = s.hasOffer !== false || hasPrices(s.id);
|
||||||
|
savedSuppliers.push(promote && s.spId === undefined ? { ...s, spId: await service.saveSupplier(s.name) } : s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Доп. позиции → «Позиции СТ» (проставляем spId).
|
||||||
|
const savedExtras: ExtraPosition[] = [];
|
||||||
|
for (const e of extras) {
|
||||||
|
savedExtras.push(e.spId === undefined ? { ...e, spId: await service.createExtraPosition(ctId, e.materialName, e.unit) } : e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метаданные строк по локальному id (обычные позиции + доп.) — для маппинга офферов.
|
||||||
|
const rowMeta: Record<string, RowMeta> = {};
|
||||||
|
positions.forEach((p) => {
|
||||||
|
if (p.spId !== undefined) {
|
||||||
|
rowMeta[p.lineId] = { spId: p.spId, name: p.name, unit: p.unit, isDelivery: false };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
savedExtras.forEach((e) => {
|
||||||
|
if (e.spId !== undefined) {
|
||||||
|
rowMeta[e.id] = { spId: e.spId, name: e.materialName, unit: e.unit, isDelivery: !!e.isDelivery };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3) Офферы → «Оценка поставщиков».
|
||||||
|
const posSpIds = Object.keys(rowMeta).map((id) => rowMeta[id].spId);
|
||||||
|
const toSave = offers.map((o) => buildOfferRow(o, savedSuppliers, rowMeta, rates)).filter((r): r is OfferRow => r !== undefined);
|
||||||
|
await service.saveOffers(posSpIds, toSave);
|
||||||
|
|
||||||
|
// 4) Критерии → «Критерии» (только по поставщикам с spId и хотя бы одним заполненным полем).
|
||||||
|
const criteriaRows = criteria
|
||||||
|
.map((c) => {
|
||||||
|
const sup = savedSuppliers.find((s) => s.id === c.supplierId);
|
||||||
|
if (sup?.spId === undefined || !(c.paymentType || c.deliveryType || c.deadline || c.sertificates || c.comment)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
supplierSpId: sup.spId,
|
||||||
|
paymentType: c.paymentType ?? '',
|
||||||
|
deliveryType: c.deliveryType ?? '',
|
||||||
|
deadline: c.deadline ?? '',
|
||||||
|
sertificates: c.sertificates ?? '',
|
||||||
|
comment: c.comment ?? ''
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((r): r is CriteriaRow => r !== undefined);
|
||||||
|
await service.saveCriteria(ctId, criteriaRows);
|
||||||
|
|
||||||
|
// 5) Поставщик-победитель → winSupplier в шапке.
|
||||||
|
const winSup = savedSuppliers.find((s) => s.id === selectedId);
|
||||||
|
let winSupplierId: number | undefined;
|
||||||
|
if (winSup?.spId !== undefined) {
|
||||||
|
await service.setWinSupplier(ctId, winSup.spId);
|
||||||
|
winSupplierId = winSup.spId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { suppliers: savedSuppliers, step3ExtraPositions: savedExtras, recommendedSupplierId: selectedId, winSupplierId };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OfferRow {
|
||||||
|
positionSpId: number;
|
||||||
|
supplierSpId: number;
|
||||||
|
itemIndex: number;
|
||||||
|
supplierPosition: string;
|
||||||
|
unit: string;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
summa: number;
|
||||||
|
priceTenge: number;
|
||||||
|
summaTenge: number;
|
||||||
|
valutaSpId?: number;
|
||||||
|
deliveryDistanceKm?: number;
|
||||||
|
offerSpId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CriteriaRow {
|
||||||
|
supplierSpId: number;
|
||||||
|
paymentType: string;
|
||||||
|
deliveryType: string;
|
||||||
|
deadline: string;
|
||||||
|
sertificates: string;
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOfferRow(
|
||||||
|
o: SupplierOffer,
|
||||||
|
savedSuppliers: Supplier[],
|
||||||
|
rowMeta: Record<string, { spId: number; name: string; unit: string; isDelivery: boolean }>,
|
||||||
|
rates: CurrencyRate[]
|
||||||
|
): OfferRow | undefined {
|
||||||
|
const sup = savedSuppliers.find((s) => s.id === o.supplierId);
|
||||||
|
const row = rowMeta[o.positionId];
|
||||||
|
if (sup?.spId === undefined || row === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const currency = sup.currency ?? 'KZT';
|
||||||
|
const rateObj = currency !== 'KZT' ? rates.find((r) => r.currency === currency) : undefined;
|
||||||
|
const rate = rateObj?.rate ?? 1;
|
||||||
|
const qty = o.quantity || 0;
|
||||||
|
const price = o.pricePerUnit || 0;
|
||||||
|
const summaTenge = qty * price * rate;
|
||||||
|
if (summaTenge <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
positionSpId: row.spId,
|
||||||
|
supplierSpId: sup.spId,
|
||||||
|
itemIndex: o.itemIndex,
|
||||||
|
supplierPosition: o.supplierPosition ?? row.name,
|
||||||
|
unit: o.unit ?? row.unit,
|
||||||
|
quantity: qty,
|
||||||
|
price,
|
||||||
|
summa: qty * price,
|
||||||
|
priceTenge: price * rate,
|
||||||
|
summaTenge,
|
||||||
|
valutaSpId: rateObj?.spId,
|
||||||
|
deliveryDistanceKm: row.isDelivery ? o.deliveryDistanceKm : undefined,
|
||||||
|
offerSpId: o.offerId ? parseInt(o.offerId, 10) || undefined : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { KpsCtFormService } from '../../services/KpsCtFormService';
|
||||||
|
|
||||||
|
// Логика блока «Комментарий ИИ» на Шаге 3: стейт комментария + вызов потока (по образцу эталона).
|
||||||
|
// Комментарий хранится в SP (колонка `comment` шапки), поэтому переживает переходы шагов —
|
||||||
|
// в formData его не держим (стайл-гайд: логика вне вёрстки, состояние ближе к источнику).
|
||||||
|
|
||||||
|
// Полезная нагрузка, которую собирает Шаг 3 (номер/проект добавляет сам хук из шапки СТ).
|
||||||
|
export interface AiCommentPayload {
|
||||||
|
positions: Array<{ name: string; unit: string; quantity: number }>;
|
||||||
|
suppliers: Array<{ name: string; grandTotal: number }>;
|
||||||
|
recommendedSupplier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAiCommentArgs {
|
||||||
|
service: KpsCtFormService;
|
||||||
|
ctId?: number;
|
||||||
|
buildPayload: () => AiCommentPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAiCommentResult {
|
||||||
|
comment?: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
error?: string;
|
||||||
|
showPrompt: boolean;
|
||||||
|
openPrompt: () => void;
|
||||||
|
closePrompt: () => void;
|
||||||
|
requestComment: () => Promise<void>;
|
||||||
|
deleteComment: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAiComment(args: UseAiCommentArgs): UseAiCommentResult {
|
||||||
|
const { service, ctId } = args;
|
||||||
|
const [comment, setComment] = useState<string | undefined>(undefined);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
const [showPrompt, setShowPrompt] = useState(false);
|
||||||
|
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const headerRef = useRef<{ number?: number; project?: string }>({});
|
||||||
|
const buildRef = useRef(args.buildPayload);
|
||||||
|
buildRef.current = args.buildPayload; // всегда актуальная сборка payload (без пересоздания колбэков)
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// На маунте подтягиваем шапку: номер/проект — в payload; сохранённый ранее комментарий — показать.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ctId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
service
|
||||||
|
.loadCtHeader(ctId)
|
||||||
|
.then((h) => {
|
||||||
|
headerRef.current = { number: h.number, project: h.project };
|
||||||
|
if (h.comment && mountedRef.current) {
|
||||||
|
setComment(h.comment);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [ctId, service]);
|
||||||
|
|
||||||
|
const requestComment = useCallback(async () => {
|
||||||
|
if (!ctId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const response = await service.triggerAiComment({
|
||||||
|
ctId,
|
||||||
|
ctNumber: headerRef.current.number,
|
||||||
|
projectName: headerRef.current.project,
|
||||||
|
...buildRef.current()
|
||||||
|
});
|
||||||
|
if (response) {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setComment(response);
|
||||||
|
}
|
||||||
|
// Персист в шапку СТ ЖДЁМ и НЕ глотаем: раньше сбой MERGE проглатывался, коммент был виден в
|
||||||
|
// сессии, но не сохранялся → при перезагрузке пропадал («не сохраняется/не читается»).
|
||||||
|
try {
|
||||||
|
await service.updateCtComment(ctId, response);
|
||||||
|
} catch (persistErr) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[AI] не удалось сохранить комментарий в шапку СТ', persistErr);
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setError('Комментарий получен, но не сохранён в СТ — при перезагрузке он пропадёт. Попробуйте ещё раз.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Поток не вернул тело — подождём и перечитаем: он мог записать комментарий в SP сам.
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 5000));
|
||||||
|
const saved = await service.loadCtHeader(ctId).then((h) => h.comment).catch(() => undefined);
|
||||||
|
if (saved && mountedRef.current) {
|
||||||
|
setComment(saved);
|
||||||
|
} else if (mountedRef.current) {
|
||||||
|
setError('Поток не вернул комментарий. Попробуйте ещё раз через несколько секунд.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setError((e as Error).message ?? 'Ошибка получения комментария ИИ');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [ctId, service]);
|
||||||
|
|
||||||
|
const deleteComment = useCallback(() => {
|
||||||
|
setComment(undefined);
|
||||||
|
if (ctId) {
|
||||||
|
service.updateCtComment(ctId, '').catch((e) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[AI] не удалось очистить комментарий в шапке СТ', e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [ctId, service]);
|
||||||
|
|
||||||
|
const openPrompt = useCallback(() => setShowPrompt(true), []);
|
||||||
|
const closePrompt = useCallback(() => setShowPrompt(false), []);
|
||||||
|
|
||||||
|
return { comment, isLoading, error, showPrompt, openPrompt, closePrompt, requestComment, deleteComment };
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { CtFormData, CurrencyRate, FormStep, SupplierOffer } from '../types/domain';
|
||||||
|
import { KpsCtFormService } from '../../services/KpsCtFormService';
|
||||||
|
import { persistStep3 } from './step3Persist';
|
||||||
|
|
||||||
|
// Вся логика матрицы Шага 3 (офферы, курсы, доп. позиции, комплекты, итоги, сохранение) — отдельно от вёрстки.
|
||||||
|
|
||||||
|
// Единая строка матрицы: обычная позиция или доп. расход/доставка.
|
||||||
|
export interface MatrixRow {
|
||||||
|
id: string; // lineId (обычная) или extra id (доп.)
|
||||||
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
quantity: number; // кол-во заявки (для доп./доставки = 0)
|
||||||
|
isExtra: boolean;
|
||||||
|
isDelivery: boolean;
|
||||||
|
spId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Число под-колонок поставщика: KZT = 5, валюта = 7 (+ Цена/Сумма в валюте).
|
||||||
|
export function subColsOf(currency: string): number {
|
||||||
|
return currency !== 'KZT' ? 7 : 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
type NumberField = 'quantity' | 'pricePerUnit' | 'deliveryDistanceKm';
|
||||||
|
|
||||||
|
interface UseStep3MatrixArgs {
|
||||||
|
service: KpsCtFormService;
|
||||||
|
formData: CtFormData;
|
||||||
|
onSave: (partial: Partial<CtFormData>) => void;
|
||||||
|
onStepComplete: (step: FormStep) => void;
|
||||||
|
onNext?: () => void;
|
||||||
|
isReadOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Step3Matrix {
|
||||||
|
rows: MatrixRow[];
|
||||||
|
currencyOptions: string[];
|
||||||
|
supplierName: string;
|
||||||
|
setSupplierName: (v: string) => void;
|
||||||
|
supplierCurrency: string;
|
||||||
|
setSupplierCurrency: (v: string) => void;
|
||||||
|
raw: Record<string, string>;
|
||||||
|
saving: boolean;
|
||||||
|
saveError?: string;
|
||||||
|
rateOf: (currency: string) => number;
|
||||||
|
itemsFor: (supplierId: string, rowId: string) => SupplierOffer[];
|
||||||
|
supplierTotals: Record<string, number>; // общий итог по поставщику (₸)
|
||||||
|
regularTotals: Record<string, number>; // итог по обычным позициям
|
||||||
|
extraTotals: Record<string, number>; // итог по доп. расходам
|
||||||
|
recommendedId?: string;
|
||||||
|
selectedId?: string;
|
||||||
|
addSupplier: () => void;
|
||||||
|
removeSupplier: (id: string) => void;
|
||||||
|
selectSupplier: (id: string) => void;
|
||||||
|
addExtraPosition: (name: string, unit: string) => void;
|
||||||
|
addDelivery: () => void;
|
||||||
|
removeExtraPosition: (id: string) => void;
|
||||||
|
addBundleItem: (supplierId: string, rowId: string) => void;
|
||||||
|
removeBundleItem: (supplierId: string, rowId: string, itemIndex: number) => void;
|
||||||
|
editNumber: (supplierId: string, rowId: string, itemIndex: number, field: NumberField, v: string) => void;
|
||||||
|
editText: (supplierId: string, rowId: string, itemIndex: number, field: 'supplierPosition' | 'unit', v: string) => void;
|
||||||
|
handleNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStep3Matrix(args: UseStep3MatrixArgs): Step3Matrix {
|
||||||
|
const { service, formData, onSave, onStepComplete, onNext, isReadOnly } = args;
|
||||||
|
const positions = formData.selectedPositions;
|
||||||
|
const suppliers = formData.suppliers;
|
||||||
|
const offers = formData.offers;
|
||||||
|
const extras = formData.step3ExtraPositions ?? [];
|
||||||
|
|
||||||
|
const [supplierName, setSupplierName] = useState('');
|
||||||
|
const [supplierCurrency, setSupplierCurrency] = useState('KZT');
|
||||||
|
const [raw, setRaw] = useState<Record<string, string>>({}); // сырой ввод чисел (чтобы не терять «10.»)
|
||||||
|
const [rates, setRates] = useState<CurrencyRate[]>(formData.currencyRates);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
// Курсы валют грузим один раз, если ещё не в formData.
|
||||||
|
useEffect(() => {
|
||||||
|
if (formData.currencyRates.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
service
|
||||||
|
.getCurrencyRates()
|
||||||
|
.then((r) => {
|
||||||
|
setRates(r);
|
||||||
|
onSave({ currencyRates: r });
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rows = useMemo<MatrixRow[]>(
|
||||||
|
() => [
|
||||||
|
...positions.map((p) => ({ id: p.lineId, name: p.name, unit: p.unit, quantity: p.quantity, isExtra: false, isDelivery: false, spId: p.spId })),
|
||||||
|
...extras.map((e) => ({ id: e.id, name: e.materialName, unit: e.unit, quantity: 0, isExtra: true, isDelivery: !!e.isDelivery, spId: e.spId }))
|
||||||
|
],
|
||||||
|
[positions, extras]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rateOf = (currency: string): number => (currency === 'KZT' ? 1 : rates.find((r) => r.currency === currency)?.rate ?? 1);
|
||||||
|
|
||||||
|
// Строки комплекта одной ячейки (по возрастанию itemIndex).
|
||||||
|
const itemsFor = (supplierId: string, rowId: string): SupplierOffer[] =>
|
||||||
|
offers.filter((o) => o.supplierId === supplierId && o.positionId === rowId).sort((a, b) => a.itemIndex - b.itemIndex);
|
||||||
|
|
||||||
|
// Итоги по поставщику (₸): отдельно обычные позиции, отдельно доп. расходы, и общий.
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const extraIds = new Set(rows.filter((r) => r.isExtra).map((r) => r.id));
|
||||||
|
const regular: Record<string, number> = {};
|
||||||
|
const extra: Record<string, number> = {};
|
||||||
|
const grand: Record<string, number> = {};
|
||||||
|
suppliers.forEach((s) => {
|
||||||
|
const rate = rateOf(s.currency ?? 'KZT');
|
||||||
|
let r = 0;
|
||||||
|
let e = 0;
|
||||||
|
offers.forEach((o) => {
|
||||||
|
if (o.supplierId !== s.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = (o.quantity || 0) * (o.pricePerUnit || 0) * rate;
|
||||||
|
if (extraIds.has(o.positionId)) {
|
||||||
|
e += value;
|
||||||
|
} else {
|
||||||
|
r += value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
regular[s.id] = r;
|
||||||
|
extra[s.id] = e;
|
||||||
|
grand[s.id] = r + e;
|
||||||
|
});
|
||||||
|
return { regular, extra, grand };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [suppliers, offers, rates, rows]);
|
||||||
|
|
||||||
|
const supplierTotals = totals.grand;
|
||||||
|
const regularTotals = totals.regular;
|
||||||
|
const extraTotals = totals.extra;
|
||||||
|
|
||||||
|
const recommendedId = useMemo<string | undefined>(() => {
|
||||||
|
const withTotal = suppliers.map((s) => ({ id: s.id, total: supplierTotals[s.id] ?? 0 })).filter((t) => t.total > 0);
|
||||||
|
if (withTotal.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const min = Math.min(...withTotal.map((t) => t.total));
|
||||||
|
return withTotal.find((t) => t.total === min)?.id;
|
||||||
|
}, [suppliers, supplierTotals]);
|
||||||
|
|
||||||
|
const selectedId = formData.recommendedSupplierId ?? recommendedId;
|
||||||
|
const currencyOptions = useMemo(() => ['KZT', ...rates.map((r) => r.currency)], [rates]);
|
||||||
|
|
||||||
|
const makeOffer = (supplierId: string, rowId: string, itemIndex: number, patch?: Partial<SupplierOffer>): SupplierOffer => {
|
||||||
|
const row = rows.find((r) => r.id === rowId);
|
||||||
|
return {
|
||||||
|
supplierId,
|
||||||
|
positionId: rowId,
|
||||||
|
itemIndex,
|
||||||
|
supplierPosition: row?.name,
|
||||||
|
unit: row?.unit,
|
||||||
|
quantity: itemIndex === 0 ? row?.quantity ?? 0 : 0, // доп. строки комплекта — кол-во с нуля
|
||||||
|
pricePerUnit: 0,
|
||||||
|
...patch
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertOffer = (supplierId: string, rowId: string, itemIndex: number, patch: Partial<SupplierOffer>): void => {
|
||||||
|
const exists = offers.some((o) => o.supplierId === supplierId && o.positionId === rowId && o.itemIndex === itemIndex);
|
||||||
|
const next = exists
|
||||||
|
? offers.map((o) => (o.supplierId === supplierId && o.positionId === rowId && o.itemIndex === itemIndex ? { ...o, ...patch } : o))
|
||||||
|
: [...offers, makeOffer(supplierId, rowId, itemIndex, patch)];
|
||||||
|
onSave({ offers: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseNum = (v: string): number => {
|
||||||
|
const n = parseFloat(v.replace(',', '.'));
|
||||||
|
return isNaN(n) ? 0 : n;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSupplier = (): void => {
|
||||||
|
const trimmed = supplierName.trim();
|
||||||
|
if (!trimmed || suppliers.some((s) => s.name.toLowerCase() === trimmed.toLowerCase())) {
|
||||||
|
setSupplierName('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave({ suppliers: [...suppliers, { id: `sup_${trimmed}`, name: trimmed, currency: supplierCurrency, hasOffer: true }] });
|
||||||
|
setSupplierName('');
|
||||||
|
setSupplierCurrency('KZT');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSupplier = (id: string): void => {
|
||||||
|
onSave({
|
||||||
|
suppliers: suppliers.filter((s) => s.id !== id),
|
||||||
|
offers: offers.filter((o) => o.supplierId !== id),
|
||||||
|
recommendedSupplierId: formData.recommendedSupplierId === id ? undefined : formData.recommendedSupplierId
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectSupplier = (id: string): void => onSave({ recommendedSupplierId: id });
|
||||||
|
|
||||||
|
const addExtraPosition = (name: string, unit: string): void => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave({ step3ExtraPositions: [...extras, { id: `extra_pos_${Date.now()}`, materialName: trimmed, unit: unit.trim() }] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDelivery = (): void => {
|
||||||
|
onSave({ step3ExtraPositions: [...extras, { id: `extra_pos_${Date.now()}`, materialName: 'Доставка', unit: 'рейс', isDelivery: true }] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeExtraPosition = (id: string): void => {
|
||||||
|
onSave({ step3ExtraPositions: extras.filter((e) => e.id !== id), offers: offers.filter((o) => o.positionId !== id) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Добавить строку комплекта. Если ячейка пуста — материализуем базовую (0) и добавляем следующую.
|
||||||
|
const addBundleItem = (supplierId: string, rowId: string): void => {
|
||||||
|
const items = itemsFor(supplierId, rowId);
|
||||||
|
const base = items.length === 0 ? [makeOffer(supplierId, rowId, 0)] : [];
|
||||||
|
const nextIndex = items.length === 0 ? 1 : items.length;
|
||||||
|
onSave({ offers: [...offers, ...base, makeOffer(supplierId, rowId, nextIndex)] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeBundleItem = (supplierId: string, rowId: string, itemIndex: number): void => {
|
||||||
|
const remaining = itemsFor(supplierId, rowId)
|
||||||
|
.filter((o) => o.itemIndex !== itemIndex)
|
||||||
|
.map((o, i) => ({ ...o, itemIndex: i })); // переиндексация, чтобы не было дыр
|
||||||
|
const others = offers.filter((o) => !(o.supplierId === supplierId && o.positionId === rowId));
|
||||||
|
onSave({ offers: [...others, ...remaining] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const editNumber = (supplierId: string, rowId: string, itemIndex: number, field: NumberField, v: string): void => {
|
||||||
|
setRaw((prev) => ({ ...prev, [`${supplierId}::${rowId}::${itemIndex}::${field}`]: v }));
|
||||||
|
upsertOffer(supplierId, rowId, itemIndex, { [field]: parseNum(v) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const editText = (supplierId: string, rowId: string, itemIndex: number, field: 'supplierPosition' | 'unit', v: string): void =>
|
||||||
|
upsertOffer(supplierId, rowId, itemIndex, { [field]: v });
|
||||||
|
|
||||||
|
const handleNext = (): void => {
|
||||||
|
if (isReadOnly) {
|
||||||
|
onStepComplete(3);
|
||||||
|
onNext?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.ctId) {
|
||||||
|
setSaveError('Сначала создайте СТ на Шаге 1 (кнопка «Создать и далее»).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError(undefined);
|
||||||
|
persistStep3({ service, ctId: formData.ctId, suppliers, offers, positions, extras, rates, criteria: formData.criteria ?? [], selectedId })
|
||||||
|
.then((patch) => {
|
||||||
|
onSave(patch);
|
||||||
|
onStepComplete(3);
|
||||||
|
onNext?.();
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
setSaveError(e.message);
|
||||||
|
setSaving(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
currencyOptions,
|
||||||
|
supplierName,
|
||||||
|
setSupplierName,
|
||||||
|
supplierCurrency,
|
||||||
|
setSupplierCurrency,
|
||||||
|
raw,
|
||||||
|
saving,
|
||||||
|
saveError,
|
||||||
|
rateOf,
|
||||||
|
itemsFor,
|
||||||
|
supplierTotals,
|
||||||
|
regularTotals,
|
||||||
|
extraTotals,
|
||||||
|
recommendedId,
|
||||||
|
selectedId,
|
||||||
|
addSupplier,
|
||||||
|
removeSupplier,
|
||||||
|
selectSupplier,
|
||||||
|
addExtraPosition,
|
||||||
|
addDelivery,
|
||||||
|
removeExtraPosition,
|
||||||
|
addBundleItem,
|
||||||
|
removeBundleItem,
|
||||||
|
editNumber,
|
||||||
|
editText,
|
||||||
|
handleNext
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { DefaultButton, Dialog, DialogFooter, DialogType, MessageBar, MessageBarType, PrimaryButton, TextField } from '@fluentui/react';
|
||||||
|
import { Pill } from '../common/Pill';
|
||||||
|
import { APPROVER_STATUS_META, CtFormData, FormStep } from '../types/domain';
|
||||||
|
import { formatDateTime } from '../utils/format';
|
||||||
|
import { KpsCtFormService } from '../../services/KpsCtFormService';
|
||||||
|
import { useStep4Approval } from './useStep4Approval';
|
||||||
|
import styles from './Step4Approval.module.scss';
|
||||||
|
|
||||||
|
interface InlineApprovalChainProps {
|
||||||
|
service: KpsCtFormService;
|
||||||
|
formData: CtFormData;
|
||||||
|
onSave: (partial: Partial<CtFormData>) => void;
|
||||||
|
onStepComplete: (step: FormStep) => void;
|
||||||
|
userDisplayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инлайн-цепочка согласования на Шаге 3 — для согласующего, открывшего СТ по ссылке-уведомлению (read-only,
|
||||||
|
// как в эталоне). Переиспользует useStep4Approval (сам грузит цепочку из SP): только просмотр статусов +
|
||||||
|
// «Согласовать/Отклонить» своей строки + диалог. Правка цепочки/отправка — это Шаг 4, здесь их нет.
|
||||||
|
export function InlineApprovalChain(props: InlineApprovalChainProps): JSX.Element | null {
|
||||||
|
// Инлайн-блок только читает цепочку из SP и даёт проголосовать — сид новой цепочки здесь запрещён.
|
||||||
|
const a = useStep4Approval({ ...props, canSeed: false });
|
||||||
|
if (a.chain.length === 0) {
|
||||||
|
return null; // цепочка ещё грузится из SP или её нет — блок не показываем
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h3 className={styles.title}>Цепочка согласования</h3>
|
||||||
|
<ol className={styles.list}>
|
||||||
|
{a.chain.map((member) => {
|
||||||
|
const meta = APPROVER_STATUS_META[member.status];
|
||||||
|
return (
|
||||||
|
<li key={member.rowId} className={styles.row}>
|
||||||
|
<span className={styles.num}>{member.order}</span>
|
||||||
|
<span className={styles.name}>{member.displayName}</span>
|
||||||
|
{member.isInitiator ? <Pill kind="neutral">Инициатор</Pill> : undefined}
|
||||||
|
<span className={styles.spacer} />
|
||||||
|
{a.showStatuses ? <Pill kind={meta.kind}>{meta.label}</Pill> : undefined}
|
||||||
|
{member.comment ? <span className={styles.comment}>«{member.comment}»</span> : undefined}
|
||||||
|
{member.decidedAt ? <span className={styles.date}>{formatDateTime(member.decidedAt)}</span> : undefined}
|
||||||
|
|
||||||
|
{a.isOnApproval && a.currentUserRowId === member.rowId ? (
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<PrimaryButton iconProps={{ iconName: 'CheckMark' }} text="Согласовать" onClick={() => a.openAction(member, 'approve')} />
|
||||||
|
<DefaultButton iconProps={{ iconName: 'Cancel' }} text="Отклонить" className={styles.rejectBtn} onClick={() => a.openAction(member, 'reject')} />
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{a.error ? (
|
||||||
|
<MessageBar messageBarType={MessageBarType.error} isMultiline={false}>{a.error}</MessageBar>
|
||||||
|
) : undefined}
|
||||||
|
{a.notifyWarning ? (
|
||||||
|
<MessageBar messageBarType={MessageBarType.warning}>{a.notifyWarning}</MessageBar>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{a.action ? (
|
||||||
|
<Dialog
|
||||||
|
hidden={false}
|
||||||
|
onDismiss={a.closeAction}
|
||||||
|
dialogContentProps={{
|
||||||
|
type: DialogType.normal,
|
||||||
|
title: a.action.kind === 'approve' ? 'Согласование' : 'Отклонение',
|
||||||
|
subText: a.action.kind === 'approve' ? 'Подтвердите согласование СТ. Комментарий необязателен.' : 'Укажите причину отклонения (обязательно).'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="Комментарий"
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
value={a.action.comment}
|
||||||
|
onChange={(_, v) => a.setActionComment(v ?? '')}
|
||||||
|
required={a.action.kind === 'reject'}
|
||||||
|
placeholder={a.action.kind === 'reject' ? 'Причина отклонения…' : 'Комментарий (необязательно)…'}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={a.submitAction}
|
||||||
|
disabled={a.sending || (a.action.kind === 'reject' && !a.action.comment.trim())}
|
||||||
|
text={a.action.kind === 'approve' ? 'Согласовать' : 'Отклонить'}
|
||||||
|
/>
|
||||||
|
<DefaultButton onClick={a.closeAction} text="Отмена" disabled={a.sending} />
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
) : undefined}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
@import '../common/theme';
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
color: $muted;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 860px; // цепочка не на всю ширину — как в эталоне (.chainList)
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $head-bg;
|
||||||
|
color: $muted;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
color: $muted;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
color: $muted;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rejectBtn {
|
||||||
|
color: $danger !important;
|
||||||
|
border-color: $danger !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $danger-bg !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Поиск согласующего ---
|
||||||
|
|
||||||
|
.searchBox {
|
||||||
|
position: relative;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions {
|
||||||
|
list-style: none;
|
||||||
|
margin: 2px 0 0;
|
||||||
|
padding: 4px 0;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid $border-strong;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionName {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionEmail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Футер ---
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid $border;
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
DefaultButton,
|
||||||
|
Dialog,
|
||||||
|
DialogFooter,
|
||||||
|
DialogType,
|
||||||
|
IconButton,
|
||||||
|
MessageBar,
|
||||||
|
MessageBarType,
|
||||||
|
PrimaryButton,
|
||||||
|
Spinner,
|
||||||
|
SpinnerSize,
|
||||||
|
TextField
|
||||||
|
} from '@fluentui/react';
|
||||||
|
import { StepShell } from '../common/StepShell';
|
||||||
|
import { Pill } from '../common/Pill';
|
||||||
|
import { APPROVER_STATUS_META, StepProps } from '../types/domain';
|
||||||
|
import { formatDateTime } from '../utils/format';
|
||||||
|
import { useStep4Approval } from './useStep4Approval';
|
||||||
|
import styles from './Step4Approval.module.scss';
|
||||||
|
|
||||||
|
// Шаг 4 «Согласование» (§12): цепочка согласования. Логика — в useStep4Approval.
|
||||||
|
// Оповещение согласующих в Teams (4c) — через notify; замена участника — через диалог openReplace.
|
||||||
|
export function Step4Approval(props: StepProps): JSX.Element {
|
||||||
|
const { service, formData, onSave, onPrev, onStepComplete, userDisplayName } = props;
|
||||||
|
const a = useStep4Approval({ service, formData, onSave, onStepComplete, userDisplayName });
|
||||||
|
const status = formData.status;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StepShell title="Согласование" onPrev={onPrev}>
|
||||||
|
<div className={styles.body}>
|
||||||
|
{status === 'approved' ? (
|
||||||
|
<MessageBar messageBarType={MessageBarType.success}>СТ согласована всеми участниками.</MessageBar>
|
||||||
|
) : undefined}
|
||||||
|
{status === 'rejected' ? (
|
||||||
|
<MessageBar messageBarType={MessageBarType.error}>СТ отклонена. Требуется корректировка и повторная отправка.</MessageBar>
|
||||||
|
) : undefined}
|
||||||
|
{status === 'on_approval' ? (
|
||||||
|
<MessageBar messageBarType={MessageBarType.info} isMultiline={false}>СТ находится на согласовании.</MessageBar>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h3 className={styles.title}>Цепочка согласования</h3>
|
||||||
|
|
||||||
|
{a.chain.length === 0 ? (
|
||||||
|
<p className={styles.empty}>Добавьте участников для согласования.</p>
|
||||||
|
) : (
|
||||||
|
<ol className={styles.list}>
|
||||||
|
{a.chain.map((member, index) => {
|
||||||
|
const meta = APPROVER_STATUS_META[member.status];
|
||||||
|
// Заменить можно не-инициатора со статусом pending, кроме того, чья очередь идёт сейчас
|
||||||
|
// (до отправки и во время согласования) — как в эталоне.
|
||||||
|
const canReplace =
|
||||||
|
!member.isInitiator &&
|
||||||
|
member.status === 'pending' &&
|
||||||
|
!(a.isOnApproval && a.currentUserRowId === member.rowId);
|
||||||
|
return (
|
||||||
|
<li key={member.rowId} className={styles.row}>
|
||||||
|
<span className={styles.num}>{member.order}</span>
|
||||||
|
<span className={styles.name}>{member.displayName}</span>
|
||||||
|
{member.isInitiator ? <Pill kind="neutral">Инициатор</Pill> : undefined}
|
||||||
|
<span className={styles.spacer} />
|
||||||
|
{a.showStatuses ? <Pill kind={meta.kind}>{meta.label}</Pill> : undefined}
|
||||||
|
{member.comment ? <span className={styles.comment}>«{member.comment}»</span> : undefined}
|
||||||
|
{member.decidedAt ? <span className={styles.date}>{formatDateTime(member.decidedAt)}</span> : undefined}
|
||||||
|
|
||||||
|
{a.canEdit && !member.isInitiator ? (
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<IconButton iconProps={{ iconName: 'Up' }} title="Выше" disabled={index === 0} onClick={() => a.moveApprover(member.rowId, 'up')} />
|
||||||
|
<IconButton iconProps={{ iconName: 'Down' }} title="Ниже" disabled={index === a.chain.length - 1} onClick={() => a.moveApprover(member.rowId, 'down')} />
|
||||||
|
<IconButton iconProps={{ iconName: 'Delete' }} title="Удалить" onClick={() => a.removeApprover(member.rowId)} />
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{a.isOnApproval && a.currentUserRowId === member.rowId ? (
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<PrimaryButton iconProps={{ iconName: 'CheckMark' }} text="Согласовать" onClick={() => a.openAction(member, 'approve')} />
|
||||||
|
<DefaultButton iconProps={{ iconName: 'Cancel' }} text="Отклонить" className={styles.rejectBtn} onClick={() => a.openAction(member, 'reject')} />
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{canReplace ? (
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<IconButton iconProps={{ iconName: 'Switch' }} title="Заменить согласующего" onClick={() => a.openReplace(member)} />
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{a.canEdit ? (
|
||||||
|
<div className={styles.searchBox}>
|
||||||
|
<TextField
|
||||||
|
placeholder="Поиск согласующего (имя)…"
|
||||||
|
value={a.query}
|
||||||
|
onChange={(_, v) => a.setQuery(v ?? '')}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{a.suggestions.length > 0 ? (
|
||||||
|
<ul className={styles.suggestions}>
|
||||||
|
{a.suggestions.map((s) => (
|
||||||
|
<li key={s.spId} className={styles.suggestion} onClick={() => a.addApprover(s)}>
|
||||||
|
<span className={styles.suggestionName}>{s.displayName}</span>
|
||||||
|
<span className={styles.suggestionEmail}>{s.email}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className={styles.footer}>
|
||||||
|
{a.error ? (
|
||||||
|
<MessageBar messageBarType={MessageBarType.error} isMultiline={false}>{a.error}</MessageBar>
|
||||||
|
) : undefined}
|
||||||
|
{a.notifyWarning ? (
|
||||||
|
<MessageBar messageBarType={MessageBarType.warning}>{a.notifyWarning}</MessageBar>
|
||||||
|
) : undefined}
|
||||||
|
{a.canEdit ? (
|
||||||
|
<PrimaryButton
|
||||||
|
iconProps={a.sending ? undefined : { iconName: 'Send' }}
|
||||||
|
onClick={a.sendForApproval}
|
||||||
|
disabled={a.chain.length === 0 || a.sending}
|
||||||
|
>
|
||||||
|
{a.sending ? <Spinner size={SpinnerSize.small} /> : 'Отправить на согласование'}
|
||||||
|
</PrimaryButton>
|
||||||
|
) : undefined}
|
||||||
|
{(status === 'on_approval' || status === 'rejected') && a.currentUserIsInitiator ? (
|
||||||
|
<DefaultButton iconProps={{ iconName: 'Refresh' }} text="Перезапустить согласование" onClick={a.restart} disabled={a.sending} />
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{a.action ? (
|
||||||
|
<Dialog
|
||||||
|
hidden={false}
|
||||||
|
onDismiss={a.closeAction}
|
||||||
|
dialogContentProps={{
|
||||||
|
type: DialogType.normal,
|
||||||
|
title: a.action.kind === 'approve' ? 'Согласование' : 'Отклонение',
|
||||||
|
subText: a.action.kind === 'approve' ? 'Подтвердите согласование СТ. Комментарий необязателен.' : 'Укажите причину отклонения (обязательно).'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="Комментарий"
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
value={a.action.comment}
|
||||||
|
onChange={(_, v) => a.setActionComment(v ?? '')}
|
||||||
|
required={a.action.kind === 'reject'}
|
||||||
|
placeholder={a.action.kind === 'reject' ? 'Причина отклонения…' : 'Комментарий (необязательно)…'}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={a.submitAction}
|
||||||
|
disabled={a.sending || (a.action.kind === 'reject' && !a.action.comment.trim())}
|
||||||
|
text={a.action.kind === 'approve' ? 'Согласовать' : 'Отклонить'}
|
||||||
|
/>
|
||||||
|
<DefaultButton onClick={a.closeAction} text="Отмена" disabled={a.sending} />
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{a.replace ? (
|
||||||
|
<Dialog
|
||||||
|
hidden={false}
|
||||||
|
onDismiss={a.closeReplace}
|
||||||
|
dialogContentProps={{
|
||||||
|
type: DialogType.normal,
|
||||||
|
title: 'Заменить согласующего',
|
||||||
|
subText: `Текущий: ${a.replace.name}. Найдите нового участника.`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.searchBox}>
|
||||||
|
<TextField
|
||||||
|
placeholder="Поиск участника (имя)…"
|
||||||
|
value={a.replace.query}
|
||||||
|
onChange={(_, v) => a.setReplaceQuery(v ?? '')}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{a.replace.suggestions.length > 0 ? (
|
||||||
|
<ul className={styles.suggestions}>
|
||||||
|
{a.replace.suggestions.map((s) => (
|
||||||
|
<li key={s.spId} className={styles.suggestion} onClick={() => a.selectReplace(s)}>
|
||||||
|
<span className={styles.suggestionName}>{s.displayName}</span>
|
||||||
|
<span className={styles.suggestionEmail}>{s.email}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<PrimaryButton onClick={a.submitReplace} disabled={!a.replace.selected || a.sending} text="Заменить" />
|
||||||
|
<DefaultButton onClick={a.closeReplace} text="Отмена" disabled={a.sending} />
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
</StepShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Approver, CtFormData, FormStep } from '../types/domain';
|
||||||
|
import { KpsCtFormService } from '../../services/KpsCtFormService';
|
||||||
|
|
||||||
|
// Логика Шага 4 (цепочка согласования) — отдельно от вёрстки. Оповещение согласующих — через notify (4c).
|
||||||
|
|
||||||
|
// Уникальный ключ СТРОКИ цепочки (счётчик на время жизни страницы) — один человек может быть в цепочке
|
||||||
|
// несколько раз. Загруженные из SP строки получают `sp_<id>` в сервисе; локальные — `row_<n>` отсюда.
|
||||||
|
let rowIdCounter = 0;
|
||||||
|
function makeRowId(): string {
|
||||||
|
rowIdCounter += 1;
|
||||||
|
return `row_${rowIdCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSuggestion {
|
||||||
|
spId: number;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionDialog {
|
||||||
|
rowId: string; // строка цепочки, по которой голосуем (не userId — человек может быть в цепочке дважды)
|
||||||
|
name: string;
|
||||||
|
kind: 'approve' | 'reject';
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplaceDialog {
|
||||||
|
rowId: string; // строка цепочки, которую меняем
|
||||||
|
spApprovalId?: number; // запись в SP (есть в фазе согласования; до отправки — нет)
|
||||||
|
name: string; // текущее имя — для заголовка диалога
|
||||||
|
query: string;
|
||||||
|
suggestions: UserSuggestion[];
|
||||||
|
selected?: UserSuggestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseStep4ApprovalArgs {
|
||||||
|
service: KpsCtFormService;
|
||||||
|
formData: CtFormData;
|
||||||
|
onSave: (partial: Partial<CtFormData>) => void;
|
||||||
|
onStepComplete: (step: FormStep) => void;
|
||||||
|
userDisplayName: string;
|
||||||
|
// Разрешено ли засевать новую цепочку (инициатор + матрица) при пустой цепочке. Инлайн-блок на Шаге 3
|
||||||
|
// (согласующий по ссылке) сид НЕ запускает — только читает из SP. По умолчанию — да (Шаг 4).
|
||||||
|
canSeed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Step4Approval {
|
||||||
|
chain: Approver[];
|
||||||
|
canEdit: boolean; // до отправки — цепочку можно менять
|
||||||
|
isOnApproval: boolean; // активная фаза голосования
|
||||||
|
showStatuses: boolean;
|
||||||
|
currentUserIsInitiator: boolean;
|
||||||
|
currentUserRowId?: string; // rowId строки, чья очередь = текущий пользователь
|
||||||
|
query: string;
|
||||||
|
setQuery: (q: string) => void;
|
||||||
|
suggestions: UserSuggestion[];
|
||||||
|
addApprover: (u: UserSuggestion) => void;
|
||||||
|
removeApprover: (rowId: string) => void;
|
||||||
|
moveApprover: (rowId: string, dir: 'up' | 'down') => void;
|
||||||
|
sending: boolean;
|
||||||
|
error?: string;
|
||||||
|
notifyWarning?: string; // голос/отправка прошли, но флоу-оповещение не удалось
|
||||||
|
sendForApproval: () => void;
|
||||||
|
action?: ActionDialog;
|
||||||
|
openAction: (approver: Approver, kind: 'approve' | 'reject') => void;
|
||||||
|
closeAction: () => void;
|
||||||
|
setActionComment: (c: string) => void;
|
||||||
|
submitAction: () => void;
|
||||||
|
replace?: ReplaceDialog;
|
||||||
|
openReplace: (approver: Approver) => void;
|
||||||
|
closeReplace: () => void;
|
||||||
|
setReplaceQuery: (q: string) => void;
|
||||||
|
selectReplace: (u: UserSuggestion) => void;
|
||||||
|
submitReplace: () => void;
|
||||||
|
restart: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStep4Approval(args: UseStep4ApprovalArgs): Step4Approval {
|
||||||
|
const { service, formData, onSave, onStepComplete, userDisplayName, canSeed = true } = args;
|
||||||
|
const chain = formData.approvers;
|
||||||
|
const status = formData.status;
|
||||||
|
const isOnApprovalPhase = status === 'on_approval' || status === 'approved' || status === 'rejected';
|
||||||
|
const canEdit = !isOnApprovalPhase;
|
||||||
|
const isOnApproval = status === 'on_approval';
|
||||||
|
|
||||||
|
const [query, setQueryState] = useState('');
|
||||||
|
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
const [action, setAction] = useState<ActionDialog | undefined>(undefined);
|
||||||
|
const [replace, setReplace] = useState<ReplaceDialog | undefined>(undefined);
|
||||||
|
const [notifyWarning, setNotifyWarning] = useState<string | undefined>(undefined);
|
||||||
|
const searchTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
const replaceTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
|
// Некритичное оповещение согласующего: сбой флоу НЕ должен валить голос/отправку (§7).
|
||||||
|
const notify = async (spApprovalId: number): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await service.triggerApprovalNotification(spApprovalId);
|
||||||
|
} catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[Step4] triggerApprovalNotification failed for spId', spApprovalId, e);
|
||||||
|
setNotifyWarning('Действие выполнено, но уведомление в Teams не удалось отправить. Проверьте Power Automate.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Восстановление/инициализация цепочки один раз при монтировании.
|
||||||
|
useEffect(() => {
|
||||||
|
if (chain.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isOnApprovalPhase && formData.ctId) {
|
||||||
|
service
|
||||||
|
.loadApprovalChain(formData.ctId)
|
||||||
|
.then((loaded) => {
|
||||||
|
if (loaded.length > 0) {
|
||||||
|
onSave({ approvers: loaded });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
} else if (canSeed && canEdit && service.currentUserSpId > 0) {
|
||||||
|
const me = service.currentUserSpId;
|
||||||
|
const initiator: Approver = { rowId: makeRowId(), userId: me, displayName: userDisplayName, order: 1, status: 'pending', isInitiator: true };
|
||||||
|
// Автозаполнение из «Матрица согласования КПС» (инициатор + участники по порядку); при ошибке — только инициатор.
|
||||||
|
service
|
||||||
|
.loadApprovalMatrix()
|
||||||
|
.then((matrix) => {
|
||||||
|
const rest: Approver[] = matrix
|
||||||
|
.filter((m) => m.userId !== me)
|
||||||
|
.map((m, i): Approver => ({ rowId: makeRowId(), userId: m.userId, displayName: m.displayName, order: i + 2, status: 'pending' }));
|
||||||
|
onSave({ approvers: [initiator, ...rest] });
|
||||||
|
})
|
||||||
|
.catch(() => onSave({ approvers: [initiator] }));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setQuery = (q: string): void => {
|
||||||
|
setQueryState(q);
|
||||||
|
setSuggestions([]);
|
||||||
|
if (searchTimer.current) {
|
||||||
|
clearTimeout(searchTimer.current);
|
||||||
|
}
|
||||||
|
if (q.trim().length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchTimer.current = setTimeout(() => {
|
||||||
|
// Показываем всех найденных — фильтра «уже в цепочке» нет: один человек может быть добавлен
|
||||||
|
// в цепочку несколько раз (строки различаются по rowId).
|
||||||
|
service
|
||||||
|
.searchUsers(q.trim())
|
||||||
|
.then((res) => setSuggestions(res))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reindex = (list: Approver[]): Approver[] => list.map((m, i) => ({ ...m, order: i + 1 }));
|
||||||
|
|
||||||
|
const addApprover = (u: UserSuggestion): void => {
|
||||||
|
onSave({ approvers: reindex([...chain, { rowId: makeRowId(), userId: u.spId, displayName: u.displayName, email: u.email, order: chain.length + 1, status: 'pending' }]) });
|
||||||
|
setQueryState('');
|
||||||
|
setSuggestions([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeApprover = (rowId: string): void => {
|
||||||
|
onSave({ approvers: reindex(chain.filter((m) => m.rowId !== rowId || m.isInitiator)) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveApprover = (rowId: string, dir: 'up' | 'down'): void => {
|
||||||
|
const idx = chain.findIndex((m) => m.rowId === rowId);
|
||||||
|
const swap = dir === 'up' ? idx - 1 : idx + 1;
|
||||||
|
if (idx === -1 || swap < 0 || swap >= chain.length || chain[swap].isInitiator) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = [...chain];
|
||||||
|
[next[idx], next[swap]] = [next[swap], next[idx]];
|
||||||
|
onSave({ approvers: reindex(next) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendForApproval = (): void => {
|
||||||
|
if (!formData.ctId || chain.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true);
|
||||||
|
setError(undefined);
|
||||||
|
setNotifyWarning(undefined);
|
||||||
|
const ctId = formData.ctId;
|
||||||
|
const activated: Approver[] = chain.map((m) => ({ ...m, status: m.isInitiator ? 'recommends' : 'pending' }));
|
||||||
|
const winSpId = formData.suppliers.find((s) => s.id === formData.recommendedSupplierId)?.spId;
|
||||||
|
(async () => {
|
||||||
|
const saved = await service.saveApprovalChain(ctId, activated);
|
||||||
|
await service.updateCtStatus(ctId, 'on_approval', winSpId);
|
||||||
|
onSave({ approvers: saved, status: 'on_approval' });
|
||||||
|
const firstApprover = saved.find((m) => !m.isInitiator && m.spApprovalId);
|
||||||
|
if (firstApprover?.spApprovalId) {
|
||||||
|
await notify(firstApprover.spApprovalId);
|
||||||
|
}
|
||||||
|
onStepComplete(4);
|
||||||
|
})()
|
||||||
|
.then(() => setSending(false))
|
||||||
|
.catch((e: Error) => {
|
||||||
|
setError(e.message);
|
||||||
|
setSending(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAction = (approver: Approver, kind: 'approve' | 'reject'): void =>
|
||||||
|
setAction({ rowId: approver.rowId, name: approver.displayName, kind, comment: '' });
|
||||||
|
const closeAction = (): void => setAction(undefined);
|
||||||
|
const setActionComment = (c: string): void => setAction((prev) => (prev ? { ...prev, comment: c } : prev));
|
||||||
|
|
||||||
|
const submitAction = (): void => {
|
||||||
|
if (!action || !formData.ctId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const member = chain.find((m) => m.rowId === action.rowId);
|
||||||
|
if (!member?.spApprovalId) {
|
||||||
|
setError('Не найден ID записи согласования');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.kind === 'reject' && !action.comment.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true);
|
||||||
|
setError(undefined);
|
||||||
|
setNotifyWarning(undefined);
|
||||||
|
const ctId = formData.ctId;
|
||||||
|
const decision = action.kind === 'approve' ? 'approved' : 'rejected';
|
||||||
|
const comment = action.comment.trim();
|
||||||
|
const spId = member.spApprovalId;
|
||||||
|
(async () => {
|
||||||
|
await service.submitApprovalStep(spId, decision, comment);
|
||||||
|
const updated: Approver[] = chain.map((m) =>
|
||||||
|
m.rowId === action.rowId ? { ...m, status: decision, comment, decidedAt: new Date().toISOString() } : m
|
||||||
|
);
|
||||||
|
if (decision === 'approved') {
|
||||||
|
// Уведомить проголосовавшего (поток видит «Согласовано») и следующего по очереди.
|
||||||
|
await notify(spId);
|
||||||
|
const nextPending = updated.find((m) => m.status === 'pending' && !m.isInitiator);
|
||||||
|
if (nextPending?.spApprovalId) {
|
||||||
|
await notify(nextPending.spApprovalId);
|
||||||
|
}
|
||||||
|
const allApproved = updated.every((m) => m.status === 'approved' || m.status === 'recommends');
|
||||||
|
if (allApproved) {
|
||||||
|
await service.updateCtStatus(ctId, 'approved');
|
||||||
|
onSave({ approvers: updated, status: 'approved' });
|
||||||
|
} else {
|
||||||
|
onSave({ approvers: updated });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Отклонение — процесс остановлен: удалить оставшихся pending из SP и цепочки, уведомить инициатора.
|
||||||
|
// Уведомляем через spId ОТКЛОНИВШЕГО (его статус уже "Отклонено" — поток определяет ветку по этой строке),
|
||||||
|
// а не через строку инициатора: там статус всегда "Рекомендую" и ни под одно условие потока не подходит.
|
||||||
|
const others = updated.filter((m) => m.status === 'pending' && !m.isInitiator && m.spApprovalId);
|
||||||
|
if (others.length > 0) {
|
||||||
|
await service.deleteApprovalMembers(others.map((m) => m.spApprovalId as number));
|
||||||
|
}
|
||||||
|
const finalChain = updated.filter((m) => m.status !== 'pending' || m.isInitiator);
|
||||||
|
await service.updateCtStatus(ctId, 'rejected');
|
||||||
|
onSave({ approvers: finalChain, status: 'rejected' });
|
||||||
|
await notify(spId);
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.then(() => {
|
||||||
|
setSending(false);
|
||||||
|
setAction(undefined);
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
setError(e.message);
|
||||||
|
setSending(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openReplace = (approver: Approver): void =>
|
||||||
|
setReplace({
|
||||||
|
rowId: approver.rowId,
|
||||||
|
spApprovalId: approver.spApprovalId,
|
||||||
|
name: approver.displayName,
|
||||||
|
query: '',
|
||||||
|
suggestions: [],
|
||||||
|
selected: undefined
|
||||||
|
});
|
||||||
|
const closeReplace = (): void => setReplace(undefined);
|
||||||
|
|
||||||
|
const setReplaceQuery = (q: string): void => {
|
||||||
|
setReplace((prev) => (prev ? { ...prev, query: q, suggestions: [], selected: undefined } : prev));
|
||||||
|
if (replaceTimer.current) {
|
||||||
|
clearTimeout(replaceTimer.current);
|
||||||
|
}
|
||||||
|
if (q.trim().length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replaceTimer.current = setTimeout(() => {
|
||||||
|
service
|
||||||
|
.searchUsers(q.trim())
|
||||||
|
.then((res) => {
|
||||||
|
// Без фильтра «уже в цепочке» — заменить можно на кого угодно (в т.ч. уже присутствующего).
|
||||||
|
setReplace((prev) => (prev ? { ...prev, suggestions: res } : prev));
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectReplace = (u: UserSuggestion): void =>
|
||||||
|
setReplace((prev) => (prev ? { ...prev, selected: u, query: u.displayName, suggestions: [] } : prev));
|
||||||
|
|
||||||
|
// Замена персоны на записи: до отправки (нет spApprovalId) — только локально, при отправке цепочка пишется
|
||||||
|
// заново; в фазе согласования — MERGE aprooverId на записи «Согласование КПС».
|
||||||
|
const submitReplace = (): void => {
|
||||||
|
if (!replace?.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetRowId = replace.rowId;
|
||||||
|
const spApprovalId = replace.spApprovalId;
|
||||||
|
const picked = replace.selected;
|
||||||
|
setSending(true);
|
||||||
|
setError(undefined);
|
||||||
|
(async () => {
|
||||||
|
if (spApprovalId) {
|
||||||
|
await service.replaceApprovalMember(spApprovalId, picked.spId);
|
||||||
|
}
|
||||||
|
const updated: Approver[] = chain.map((m) =>
|
||||||
|
m.rowId === targetRowId ? { ...m, userId: picked.spId, displayName: picked.displayName, email: picked.email } : m
|
||||||
|
);
|
||||||
|
onSave({ approvers: updated });
|
||||||
|
})()
|
||||||
|
.then(() => {
|
||||||
|
setSending(false);
|
||||||
|
setReplace(undefined);
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
setError(e.message);
|
||||||
|
setSending(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const restart = (): void => {
|
||||||
|
if (!formData.ctId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true);
|
||||||
|
setError(undefined);
|
||||||
|
const ctId = formData.ctId;
|
||||||
|
const reset: Approver[] = chain.map((m) => ({ ...m, status: 'pending', comment: undefined, decidedAt: undefined }));
|
||||||
|
(async () => {
|
||||||
|
await service.updateCtStatus(ctId, 'formed');
|
||||||
|
onSave({ approvers: reset, status: 'formed' });
|
||||||
|
})()
|
||||||
|
.then(() => setSending(false))
|
||||||
|
.catch((e: Error) => {
|
||||||
|
setError(e.message);
|
||||||
|
setSending(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentUserIsInitiator = chain.some((m) => m.displayName === userDisplayName && m.isInitiator);
|
||||||
|
const currentUserMember = chain.find(
|
||||||
|
(m) =>
|
||||||
|
m.displayName === userDisplayName &&
|
||||||
|
m.status === 'pending' &&
|
||||||
|
!m.isInitiator &&
|
||||||
|
chain.filter((p) => p.order < m.order).every((p) => p.status !== 'pending')
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chain,
|
||||||
|
canEdit,
|
||||||
|
isOnApproval,
|
||||||
|
showStatuses: isOnApprovalPhase,
|
||||||
|
currentUserIsInitiator,
|
||||||
|
currentUserRowId: currentUserMember?.rowId,
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
suggestions,
|
||||||
|
addApprover,
|
||||||
|
removeApprover,
|
||||||
|
moveApprover,
|
||||||
|
sending,
|
||||||
|
error,
|
||||||
|
notifyWarning,
|
||||||
|
sendForApproval,
|
||||||
|
action,
|
||||||
|
openAction,
|
||||||
|
closeAction,
|
||||||
|
setActionComment,
|
||||||
|
submitAction,
|
||||||
|
replace,
|
||||||
|
openReplace,
|
||||||
|
closeReplace,
|
||||||
|
setReplaceQuery,
|
||||||
|
selectReplace,
|
||||||
|
submitReplace,
|
||||||
|
restart
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
@import './theme';
|
||||||
|
|
||||||
|
.chain {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid $border;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $hover;
|
||||||
|
color: $muted;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
color: $muted;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
color: $muted;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './ApprovalChain.module.scss';
|
||||||
|
import { Pill } from './Pill';
|
||||||
|
import { Approver, APPROVER_STATUS_META } from '../types/domain';
|
||||||
|
import { formatDateTime } from '../utils/format';
|
||||||
|
|
||||||
|
interface ApprovalChainProps {
|
||||||
|
approvers: Approver[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цепочка согласования (эталон): номер, имя, роль, статус-пилюля, комментарий, дата.
|
||||||
|
// Общая для Шага 3 (внизу таблицы) и Шага 4.
|
||||||
|
export function ApprovalChain({ approvers }: ApprovalChainProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className={styles.chain}>
|
||||||
|
<h3 className={styles.title}>Цепочка согласования</h3>
|
||||||
|
{approvers.length === 0 ? (
|
||||||
|
<p className={styles.empty}>Цепочка согласования ещё не сформирована.</p>
|
||||||
|
) : (
|
||||||
|
<ol className={styles.list}>
|
||||||
|
{approvers.map((approver, index) => {
|
||||||
|
const meta = APPROVER_STATUS_META[approver.status];
|
||||||
|
return (
|
||||||
|
<li key={approver.rowId} className={styles.row}>
|
||||||
|
<span className={styles.num}>{approver.order || index + 1}</span>
|
||||||
|
<span className={styles.name}>{approver.displayName}</span>
|
||||||
|
{approver.isInitiator ? <Pill kind="neutral">Инициатор</Pill> : undefined}
|
||||||
|
<span className={styles.spacer} />
|
||||||
|
<Pill kind={meta.kind}>{meta.label}</Pill>
|
||||||
|
{approver.comment ? <span className={styles.comment}>«{approver.comment}»</span> : undefined}
|
||||||
|
<span className={styles.date}>{formatDateTime(approver.decidedAt)}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@import './theme';
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: $accent-bg;
|
||||||
|
border-left: 4px solid $accent;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: $accent;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: $accent;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Icon } from '@fluentui/react';
|
||||||
|
import styles from './FormHeader.module.scss';
|
||||||
|
import { Pill } from './Pill';
|
||||||
|
import { PillKind } from '../types/domain';
|
||||||
|
|
||||||
|
interface FormHeaderProps {
|
||||||
|
title: string;
|
||||||
|
statusLabel: string;
|
||||||
|
statusKind: PillKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Общая шапка формы (эталон): синяя плашка с иконкой, титулом и статус-пилюлей.
|
||||||
|
export function FormHeader({ title, statusLabel, statusKind }: FormHeaderProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Icon iconName="TextDocument" className={styles.icon} />
|
||||||
|
<span className={styles.title}>{title}</span>
|
||||||
|
<Pill kind={statusKind}>{statusLabel}</Pill>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
@import './theme';
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Мягкие пилюли: светлый фон + цветной текст ---
|
||||||
|
|
||||||
|
.neutral {
|
||||||
|
background: #f3f2f1;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent {
|
||||||
|
background: $accent-bg;
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
background: $ok-bg;
|
||||||
|
color: $ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background: $danger-bg;
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Залитые пилюли (solid): насыщенный фон + белый текст ---
|
||||||
|
|
||||||
|
.solid {
|
||||||
|
color: #ffffff;
|
||||||
|
|
||||||
|
&.neutral {
|
||||||
|
background: $muted;
|
||||||
|
}
|
||||||
|
&.accent {
|
||||||
|
background: $accent;
|
||||||
|
}
|
||||||
|
&.ok {
|
||||||
|
background: $ok;
|
||||||
|
}
|
||||||
|
&.danger {
|
||||||
|
background: $danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './Pill.module.scss';
|
||||||
|
import { PillKind } from '../types/domain';
|
||||||
|
|
||||||
|
interface PillProps {
|
||||||
|
kind: PillKind;
|
||||||
|
solid?: boolean; // залитая пилюля (напр. «РЕКОМЕНДУЕТСЯ», «Выбран»)
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Единая статус-пилюля формы: цвет по kind, залитая/мягкая по solid.
|
||||||
|
export function Pill({ kind, solid, children }: PillProps): JSX.Element {
|
||||||
|
const className = [styles.pill, styles[kind], solid ? styles.solid : ''].filter(Boolean).join(' ');
|
||||||
|
return <span className={className}>{children}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
.stepShell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #323130;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #edebe9;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка «Далее» прижата вправо даже когда «Назад» отсутствует.
|
||||||
|
.spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { DefaultButton, PrimaryButton } from '@fluentui/react';
|
||||||
|
import styles from './StepShell.module.scss';
|
||||||
|
|
||||||
|
export interface StepShellProps {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onPrev?: () => void;
|
||||||
|
onNext?: () => void;
|
||||||
|
nextLabel?: string;
|
||||||
|
nextDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Общий каркас шага: заголовок, контент и панель навигации (§4 — блок повторяется на всех шагах).
|
||||||
|
export function StepShell(props: StepShellProps): JSX.Element {
|
||||||
|
const { title, children, onPrev, onNext, nextLabel = 'Далее', nextDisabled } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.stepShell}>
|
||||||
|
<h2 className={styles.header}>{title}</h2>
|
||||||
|
<div className={styles.content}>{children}</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
{onPrev ? <DefaultButton text="Назад" onClick={onPrev} /> : <span className={styles.spacer} />}
|
||||||
|
{onNext ? <PrimaryButton text={nextLabel} disabled={nextDisabled} onClick={onNext} /> : undefined}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Единая палитра формы (эталонный дизайн СТ). Импортируется в module.scss шагов:
|
||||||
|
// @import '../common/theme';
|
||||||
|
// Только переменные — партиал (имя с "_") в отдельный css не компилируется.
|
||||||
|
|
||||||
|
$accent: #0078d4; // основной синий (титул, текущий шаг, акценты)
|
||||||
|
$accent-bg: #eff6fc; // светло-синий фон (шапка, текущий шаг)
|
||||||
|
$ok: #107c10; // зелёный (согласовано/завершено)
|
||||||
|
$ok-bg: #dff6dd; // светло-зелёный фон пилюль/баннеров
|
||||||
|
$danger: #a4262c; // красный (ошибки/отклонено)
|
||||||
|
$danger-bg: #fde7e9;
|
||||||
|
|
||||||
|
$text: #323130; // основной текст
|
||||||
|
$muted: #605e5c; // вторичный текст
|
||||||
|
$border: #edebe9; // тонкие границы
|
||||||
|
$border-strong: #c8c6c4; // границы посильнее (неактивные кружки)
|
||||||
|
|
||||||
|
$head-bg: #faf9f8; // фон шапок таблиц
|
||||||
|
$hover: #f3f2f1; // ховер строк/кнопок
|
||||||
|
$supplier-bg: #fffdf0; // кремовая панель поставщика (Шаг 3)
|
||||||
|
$supplier-border: #f2e9b8;
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
CtFormData,
|
||||||
|
EMPTY_FORM_DATA,
|
||||||
|
FIRST_STEP,
|
||||||
|
FormStep,
|
||||||
|
LAST_STEP
|
||||||
|
} from '../types/domain';
|
||||||
|
import { loadFormState, saveFormState, sessionKey, writeCtIdToUrl } from '../utils/session';
|
||||||
|
|
||||||
|
// То, что сериализуем в sessionStorage. highestStep не храним — он производный от completedSteps.
|
||||||
|
interface PersistedState {
|
||||||
|
formData: CtFormData;
|
||||||
|
currentStep: FormStep;
|
||||||
|
completedSteps: FormStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseKpsCtFormResult {
|
||||||
|
formData: CtFormData;
|
||||||
|
currentStep: FormStep;
|
||||||
|
completedSteps: FormStep[];
|
||||||
|
highestStep: FormStep; // максимально доступный шаг (§5.5)
|
||||||
|
updateFormData: (partial: Partial<CtFormData>) => void; // точечное иммутабельное обновление (§5.1)
|
||||||
|
replaceFormData: (data: CtFormData) => void; // полная замена (загрузка из SharePoint)
|
||||||
|
goToStep: (step: FormStep) => void;
|
||||||
|
next: () => void;
|
||||||
|
prev: () => void;
|
||||||
|
markStepComplete: (step: FormStep) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Весь стейт многошаговой формы живёт здесь (§5.1). Шаги получают срез данных и колбэки.
|
||||||
|
export function useKpsCtForm(initialCtId?: number): UseKpsCtFormResult {
|
||||||
|
const [state, setState] = useState<PersistedState>(() => readInitialState(initialCtId));
|
||||||
|
const { formData, currentStep, completedSteps } = state;
|
||||||
|
|
||||||
|
// Производный: доступен следующий шаг после максимального завершённого (§5.5).
|
||||||
|
const highestStep = useMemo<FormStep>(() => {
|
||||||
|
const maxCompleted = completedSteps.reduce((max, step) => Math.max(max, step), 0);
|
||||||
|
return clampStep(Math.max(FIRST_STEP, maxCompleted + 1));
|
||||||
|
}, [completedSteps]);
|
||||||
|
|
||||||
|
// Персист при каждом изменении (§5.4). Ключ зависит от ctId (он часть state).
|
||||||
|
useEffect(() => {
|
||||||
|
saveFormState<PersistedState>(sessionKey(state.formData.ctId), state);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
// Как только сущность создана — фиксируем id в URL, чтобы перезагрузка продолжила форму (§5.4).
|
||||||
|
useEffect(() => {
|
||||||
|
if (formData.ctId) {
|
||||||
|
writeCtIdToUrl(formData.ctId);
|
||||||
|
}
|
||||||
|
}, [formData.ctId]);
|
||||||
|
|
||||||
|
const updateFormData = useCallback((partial: Partial<CtFormData>) => {
|
||||||
|
setState(prev => ({ ...prev, formData: { ...prev.formData, ...partial } }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const replaceFormData = useCallback((data: CtFormData) => {
|
||||||
|
setState(prev => ({ ...prev, formData: data }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToStep = useCallback((step: FormStep) => {
|
||||||
|
setState(prev => ({ ...prev, currentStep: clampStep(step) }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const next = useCallback(() => {
|
||||||
|
setState(prev => ({ ...prev, currentStep: clampStep(prev.currentStep + 1) }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const prev = useCallback(() => {
|
||||||
|
setState(prevState => ({ ...prevState, currentStep: clampStep(prevState.currentStep - 1) }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markStepComplete = useCallback((step: FormStep) => {
|
||||||
|
setState(prev => {
|
||||||
|
if (prev.completedSteps.indexOf(step) !== -1) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return { ...prev, completedSteps: [...prev.completedSteps, step] };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
formData,
|
||||||
|
currentStep,
|
||||||
|
completedSteps,
|
||||||
|
highestStep,
|
||||||
|
updateFormData,
|
||||||
|
replaceFormData,
|
||||||
|
goToStep,
|
||||||
|
next,
|
||||||
|
prev,
|
||||||
|
markStepComplete
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Стартовое состояние: из сессии, если есть; иначе пустая форма (с ctId из URL, если задан).
|
||||||
|
function readInitialState(initialCtId?: number): PersistedState {
|
||||||
|
const persisted = loadFormState<PersistedState>(sessionKey(initialCtId));
|
||||||
|
if (persisted) {
|
||||||
|
return persisted;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
formData: initialCtId ? { ...EMPTY_FORM_DATA, ctId: initialCtId } : EMPTY_FORM_DATA,
|
||||||
|
currentStep: FIRST_STEP,
|
||||||
|
completedSteps: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampStep(step: number): FormStep {
|
||||||
|
const bounded = Math.min(LAST_STEP, Math.max(FIRST_STEP, step));
|
||||||
|
return bounded as FormStep;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { KpsCtFormService } from '../../services/KpsCtFormService';
|
||||||
|
import { PurchaseRequestDetail } from '../types/domain';
|
||||||
|
import { RequestState } from './useRequests';
|
||||||
|
|
||||||
|
// Состояние деталей по каждой заявке: ключ — id заявки.
|
||||||
|
export type DetailsByRequestId = Record<string, RequestState<PurchaseRequestDetail>>;
|
||||||
|
|
||||||
|
export interface UsePurchaseRequestDetailsResult {
|
||||||
|
details: DetailsByRequestId;
|
||||||
|
loadDetail: (requestId: string) => void; // грузит лениво, один раз на id (кэш)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ленивая подгрузка деталей заявки при раскрытии строки (§8: loading/error, отмена на unmount).
|
||||||
|
export function usePurchaseRequestDetails(service: KpsCtFormService): UsePurchaseRequestDetailsResult {
|
||||||
|
const [details, setDetails] = useState<DetailsByRequestId>({});
|
||||||
|
|
||||||
|
// Уже запрошенные id — чтобы повторное раскрытие не дёргало API снова.
|
||||||
|
const requestedRef = useRef<Set<string>>(new Set());
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadDetail = useCallback(
|
||||||
|
(requestId: string): void => {
|
||||||
|
if (requestedRef.current.has(requestId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestedRef.current.add(requestId);
|
||||||
|
setDetails((prev) => ({ ...prev, [requestId]: { isLoading: true } }));
|
||||||
|
|
||||||
|
service
|
||||||
|
.getPurchaseRequestDetail(requestId)
|
||||||
|
.then((data) => {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setDetails((prev) => ({ ...prev, [requestId]: { isLoading: false, data } }));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
// Даём возможность повторить при следующем раскрытии.
|
||||||
|
requestedRef.current.delete(requestId);
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setDetails((prev) => ({ ...prev, [requestId]: { isLoading: false, error } }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[service]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { details, loadDetail };
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect, useState, DependencyList } from 'react';
|
||||||
|
|
||||||
|
// Явно обрабатываем loading/error/empty (§8): data приходит только по успеху.
|
||||||
|
export interface RequestState<T> {
|
||||||
|
data?: T;
|
||||||
|
isLoading: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружает данные и отменяет setState после unmount, чтобы не было обновления
|
||||||
|
// размонтированного компонента (§8). deps — как у useEffect.
|
||||||
|
export function useRequests<T>(loader: () => Promise<T>, deps: DependencyList): RequestState<T> {
|
||||||
|
const [state, setState] = useState<RequestState<T>>({ isLoading: true });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setState({ isLoading: true });
|
||||||
|
|
||||||
|
const run = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const data = await loader();
|
||||||
|
if (!cancelled) {
|
||||||
|
setState({ data, isLoading: false });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setState({ error: error as Error, isLoading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// run() сам ловит ошибки внутрь state; .catch — чтобы промис не «висел» необработанным.
|
||||||
|
run().catch(() => undefined);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// Зависимости передаёт вызывающий код — он отвечает за их полноту.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// Единый источник истины по модели данных формы: доменные типы, статусы-union и метки шагов.
|
||||||
|
// Скелет перенесён из эталонной модели СТ (§12 гайда). Содержимое адаптируется под предметку
|
||||||
|
// на этапе замены заглушек — помечено TODO.
|
||||||
|
|
||||||
|
import type { KpsCtFormService } from '../../services/KpsCtFormService';
|
||||||
|
|
||||||
|
// --- Статусы (только union, чтобы опечатка ловилась компилятором, §5.6) ---
|
||||||
|
|
||||||
|
// Жизненный цикл сущности (СТ).
|
||||||
|
export type CtStatus =
|
||||||
|
| 'draft' // черновик, идёт заполнение
|
||||||
|
| 'kp_loading' // идёт загрузка КП
|
||||||
|
| 'kp_loaded' // КП загружены
|
||||||
|
| 'formed' // СТ сформирована
|
||||||
|
| 'on_approval' // отправлена на согласование
|
||||||
|
| 'approved' // согласована
|
||||||
|
| 'rejected'; // отклонена
|
||||||
|
|
||||||
|
// Статусы, при которых форма доступна только для чтения (§5.5).
|
||||||
|
export const READ_ONLY_STATUSES: ReadonlyArray<CtStatus> = ['on_approval', 'approved', 'rejected'];
|
||||||
|
|
||||||
|
// Вид пилюли-статуса (цветовая семантика единая по форме).
|
||||||
|
export type PillKind = 'neutral' | 'accent' | 'ok' | 'danger';
|
||||||
|
|
||||||
|
// Метка + цвет статуса сущности для шапки формы.
|
||||||
|
export const CT_STATUS_META: Record<CtStatus, { label: string; kind: PillKind }> = {
|
||||||
|
draft: { label: 'Черновик', kind: 'neutral' },
|
||||||
|
kp_loading: { label: 'Загрузка КП', kind: 'accent' },
|
||||||
|
kp_loaded: { label: 'КП загружены', kind: 'accent' },
|
||||||
|
formed: { label: 'Сформирована', kind: 'accent' },
|
||||||
|
on_approval: { label: 'На согласовании', kind: 'accent' },
|
||||||
|
approved: { label: 'Согласована', kind: 'ok' },
|
||||||
|
rejected: { label: 'Отклонена', kind: 'danger' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Статус участника цепочки согласования.
|
||||||
|
export type ApproverStatus =
|
||||||
|
| 'pending' // ждёт своей очереди/решения
|
||||||
|
| 'recommends' // инициатор поставил «Рекомендую»
|
||||||
|
| 'approved' // согласовал
|
||||||
|
| 'rejected' // отклонил
|
||||||
|
| 'skipped'; // пропущен
|
||||||
|
|
||||||
|
// Метка + цвет статуса согласующего для цепочки (Шаги 3–4).
|
||||||
|
export const APPROVER_STATUS_META: Record<ApproverStatus, { label: string; kind: PillKind }> = {
|
||||||
|
pending: { label: 'Ожидает', kind: 'neutral' },
|
||||||
|
recommends: { label: 'Рекомендую', kind: 'ok' },
|
||||||
|
approved: { label: 'Согласовано', kind: 'ok' },
|
||||||
|
rejected: { label: 'Отклонено', kind: 'danger' },
|
||||||
|
skipped: { label: 'Пропущен', kind: 'neutral' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Шаги формы ---
|
||||||
|
|
||||||
|
// Номер шага. На него опираются Breadcrumbs и хук формы.
|
||||||
|
export type FormStep = 1 | 2 | 3 | 4;
|
||||||
|
|
||||||
|
export interface FormStepMeta {
|
||||||
|
step: FormStep;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метки шагов. TODO: адаптировать под предметку при замене заглушек.
|
||||||
|
export const FORM_STEPS: ReadonlyArray<FormStepMeta> = [
|
||||||
|
{ step: 1, label: 'Выбор позиций' },
|
||||||
|
{ step: 2, label: 'Загрузка КП' },
|
||||||
|
{ step: 3, label: 'Формирование СТ' },
|
||||||
|
{ step: 4, label: 'Согласование' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FIRST_STEP: FormStep = 1;
|
||||||
|
export const LAST_STEP: FormStep = 4;
|
||||||
|
|
||||||
|
// --- Доменные сущности (TODO: поля под свою предметку) ---
|
||||||
|
|
||||||
|
// Заявка на закупку из внешнего API TNSU (GET /inventory/api/purchase-requests).
|
||||||
|
// Источник записей для Шага 1. Поля — «как отдаёт API» (форма подтверждена по факту 2026-07-10).
|
||||||
|
export interface PurchaseRequest {
|
||||||
|
id: string; // GUID заявки
|
||||||
|
number: string; // номер, напр. "PR-00002" / "PR-2026-00004"
|
||||||
|
createdAt: string; // ISO datetime с оффсетом
|
||||||
|
projectName: string; // проект (ключ бизнес-ограничения «один проект» на Шаге 1)
|
||||||
|
vehicleName: string; // предмет закупки
|
||||||
|
estimatedAmount: number; // ориентировочная сумма
|
||||||
|
status: string; // машинный статус: "on_coordination" | "on_final_approval" | … (полный набор пока неизвестен)
|
||||||
|
statusLabel: string; // человекочитаемый статус для UI
|
||||||
|
initiatorFullName: string; // инициатор
|
||||||
|
currentApproverFullName: string; // текущий согласующий
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строка (позиция) заявки — из деталей заявки. Единица, по которой торгуемся в СТ.
|
||||||
|
export interface PurchaseRequestLine {
|
||||||
|
id: string; // GUID строки
|
||||||
|
lineNo: number; // порядковый номер в заявке
|
||||||
|
code: string; // код строки, напр. "PR-00002-01"
|
||||||
|
name: string; // наименование позиции
|
||||||
|
catalogNumber: string; // каталожный номер
|
||||||
|
quantity: number;
|
||||||
|
unit: string; // ед. измерения ("компл", "шт")
|
||||||
|
estimatedUnitPrice?: number; // ориентировочная цена за единицу (API заполняет не всегда)
|
||||||
|
estimatedAmount?: number; // ориентировочная сумма по строке (может отсутствовать)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Детали заявки (GET /inventory/api/purchase-requests/{id}): шапка + строки (lines).
|
||||||
|
// Внимание: НЕ супермножество PurchaseRequest — деталь даёт createdByFullName вместо
|
||||||
|
// initiator/currentApprover из списка. Форма подтверждена по факту 2026-07-10.
|
||||||
|
export interface PurchaseRequestDetail {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
status: string;
|
||||||
|
statusLabel: string;
|
||||||
|
defectActId: string; // акт дефектов — основание заявки
|
||||||
|
defectActNumber: string;
|
||||||
|
projectId: string; // GUID проекта (ключ фиксации «один проект»)
|
||||||
|
projectCode: string; // код проекта, напр. "БК0000001"
|
||||||
|
projectName: string;
|
||||||
|
vehicleId: string;
|
||||||
|
vehicleName: string;
|
||||||
|
vehicleGroupName: string;
|
||||||
|
stateNumber: string; // гос-/инвентарный номер техники
|
||||||
|
vinCode: string;
|
||||||
|
description: string;
|
||||||
|
estimatedAmount: number;
|
||||||
|
hasServiceNoteAttachment: boolean;
|
||||||
|
createdByFullName: string;
|
||||||
|
createdAt: string;
|
||||||
|
lines: PurchaseRequestLine[];
|
||||||
|
canEdit: boolean;
|
||||||
|
canSubmit: boolean;
|
||||||
|
canCancel: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Участник цепочки согласования (строка «Согласование СТ»).
|
||||||
|
export interface Approver {
|
||||||
|
rowId: string; // уникальный ключ СТРОКИ цепочки (не человека) — один человек может быть в цепочке
|
||||||
|
// несколько раз; по нему идут ключи React и операции выше/ниже/удалить/заменить/голосовать.
|
||||||
|
// Загруженные из SP: `sp_<spApprovalId>`; локально добавленные: `row_<n>`.
|
||||||
|
spApprovalId?: number; // Id строки в «Согласование СТ» (после сохранения)
|
||||||
|
userId: number; // ID пользователя SharePoint (aproover)
|
||||||
|
displayName: string;
|
||||||
|
email?: string;
|
||||||
|
order: number; // порядок в цепочке (order0)
|
||||||
|
status: ApproverStatus;
|
||||||
|
comment?: string;
|
||||||
|
decidedAt?: string; // aproovDate — когда принято решение (ISO)
|
||||||
|
isInitiator?: boolean; // инициатор маршрута (первый в цепочке)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Позиция, выбранная на Шаге 1 = строка заявки + контекст родителя и проекта.
|
||||||
|
// Снимок нужных полей, чтобы Шаги 2–4 (КП, СТ, согласование) не тянули деталь заново.
|
||||||
|
export interface SelectedPosition {
|
||||||
|
spId?: number; // Id строки в списке «Позиции СТ» (после createCt) — связь с офферами Шага 3
|
||||||
|
// из строки заявки (PurchaseRequestLine)
|
||||||
|
lineId: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
catalogNumber: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
estimatedUnitPrice?: number;
|
||||||
|
estimatedAmount?: number;
|
||||||
|
// родительская потребность
|
||||||
|
requestId: string;
|
||||||
|
requestNumber: string;
|
||||||
|
vehicleName: string;
|
||||||
|
// проект — все выбранные позиции из одного проекта (бизнес-ограничение Шага 1)
|
||||||
|
projectId: string;
|
||||||
|
projectCode: string;
|
||||||
|
projectName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поставщик (Шаги 2–3). Единый список: с КП (hasOffer≠false) и без КП (hasOffer===false).
|
||||||
|
export interface Supplier {
|
||||||
|
id: string; // локальный клиентский id
|
||||||
|
spId?: number; // Id в списке «Поставщики» (после сохранения)
|
||||||
|
name: string;
|
||||||
|
currency?: string; // валюта КП запоминается на поставщике для Шага 3
|
||||||
|
hasOffer?: boolean; // false = поставщик без КП (добавлен на Шаге 2); отсутствие/true = с КП
|
||||||
|
comment?: string; // комментарий для поставщика без КП
|
||||||
|
}
|
||||||
|
|
||||||
|
// Лёгкий DTO для UI-блока «Поставщики без КП» на Шаге 2 (маппится в Supplier с hasOffer:false).
|
||||||
|
export interface NoKpSupplier {
|
||||||
|
name: string;
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Курс валюты (Шаг 2). Читается/пишется в список «Курс Валют».
|
||||||
|
export interface CurrencyRate {
|
||||||
|
currency: string; // код валюты (code)
|
||||||
|
rate: number; // курс к тенге (course)
|
||||||
|
name?: string; // отображаемое имя валюты (Title), напр. «Китайский юань»
|
||||||
|
spId?: number; // Id в «Курс Валют» — для записи lookup valuta в оффер и upsert курса
|
||||||
|
courseDate?: string; // дата курса (courseDate) — когда курс получен/введён (ISO)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Предложение поставщика по позиции (ячейка матрицы Шага 3). Пишется в «Оценка поставщиков».
|
||||||
|
// pricePerUnit — в валюте поставщика (для KZT = ₸); пересчёт в ₸ по курсу на лету.
|
||||||
|
export interface SupplierOffer {
|
||||||
|
supplierId: string; // локальный id поставщика (formData.suppliers[].id)
|
||||||
|
positionId: string; // локальный id строки (SelectedPosition.lineId или ExtraPosition.id)
|
||||||
|
itemIndex: number; // строка комплекта (bundle): 0 — базовая, 1+ — доп. строки одной позиции
|
||||||
|
supplierPosition?: string; // наименование ТМЦ у поставщика
|
||||||
|
unit?: string;
|
||||||
|
quantity: number;
|
||||||
|
pricePerUnit: number; // цена за ед. в валюте
|
||||||
|
deliveryDistanceKm?: number; // километраж — только для строки доставки (Шаг 3, доп. расходы)
|
||||||
|
offerId?: string; // Id файла КП в библиотеке (lookup offer → «Оценка поставщиков»), Шаг 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Результат потока обработки КП (Power Automate) по одной позиции. Форма подтверждена по эталону
|
||||||
|
// (responsev2.predictionOutput.text → JSON). Все поля опциональны — поток заполняет что распознал.
|
||||||
|
export interface FlowResult {
|
||||||
|
position?: string; // наименование позиции у поставщика
|
||||||
|
unit?: string;
|
||||||
|
conditions?: string; // условия оплаты (→ критерий paymentType)
|
||||||
|
deliveryType?: string; // условия поставки (→ критерий deliveryType)
|
||||||
|
deadline?: string; // сроки (→ критерий deadline)
|
||||||
|
sertificates?: string; // сертификаты (→ критерий sertificates)
|
||||||
|
cost?: number; // сумма по позиции (в валюте поставщика)
|
||||||
|
price?: number; // цена за единицу (в валюте поставщика)
|
||||||
|
quantity?: number;
|
||||||
|
supplier?: string; // имя поставщика, если поток его распознал
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поля со строки файла КП в библиотеке (поллинг на Шаге 2). В kps Syntex-модель заполняет только
|
||||||
|
// file_content (распознанный текст); поставщик/цены/критерии извлекает поток (см. FlowResult).
|
||||||
|
export interface SyntexFields {
|
||||||
|
fileContent?: string; // непустой = Syntex закончил разбор файла (пусто — ещё обрабатывается)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загруженный файл КП (снимок для formData: связь с библиотекой + распознанный поставщик).
|
||||||
|
export interface UploadedKpFile {
|
||||||
|
spId: number; // Id строки файла в библиотеке «Коммерческие предложения» (offerId для офферов)
|
||||||
|
name: string;
|
||||||
|
supplierName?: string;
|
||||||
|
url?: string; // ссылка на файл в библиотеке (для открытия/скачивания)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Критерии поставщика (Шаг 3, блок «Дополнительная информация»). Пишется в список «Критерии».
|
||||||
|
export interface SupplierCriteria {
|
||||||
|
supplierId: string; // локальный id поставщика (formData.suppliers[].id)
|
||||||
|
paymentType?: string; // Условия оплаты (текст)
|
||||||
|
deliveryType?: string; // Условия поставки (Choice: DAP/FOB/DDP/EXW)
|
||||||
|
deadline?: string; // Сроки изготовления и доставки (текст)
|
||||||
|
sertificates?: string; // Сертификаты (Choice: Да/Нет)
|
||||||
|
comment?: string; // Примечание (текст)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Доп. позиция Шага 3 (не из заявки): доп. расходы или доставка. Пишется в «Позиции СТ».
|
||||||
|
export interface ExtraPosition {
|
||||||
|
id: string; // локальный id (extra_pos_N)
|
||||||
|
materialName: string;
|
||||||
|
unit: string;
|
||||||
|
isDelivery?: boolean; // строка доставки (кол-во = рейсы, есть километраж)
|
||||||
|
spId?: number; // Id строки в «Позиции СТ» (после сохранения)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Единый объект данных формы (один источник истины, §5.1) ---
|
||||||
|
|
||||||
|
export interface CtFormData {
|
||||||
|
ctId?: number; // id сущности в SharePoint (появляется после Шага 1)
|
||||||
|
status: CtStatus;
|
||||||
|
|
||||||
|
// Шаг 1 — выбор позиций
|
||||||
|
selectedProject?: string;
|
||||||
|
lockedProjectId?: string; // проект зафиксирован на первом выборе, остальные блокируются
|
||||||
|
selectedPositions: SelectedPosition[];
|
||||||
|
|
||||||
|
// Шаг 2 — загрузка КП
|
||||||
|
suppliers: Supplier[]; // единый список поставщиков (с КП и без КП — по флагу hasOffer)
|
||||||
|
currencyRates: CurrencyRate[];
|
||||||
|
uploadedKpFiles?: UploadedKpFile[]; // файлы КП, залитые в библиотеку (Шаг 2)
|
||||||
|
|
||||||
|
// Шаг 3 — формирование СТ
|
||||||
|
offers: SupplierOffer[]; // предложения поставщиков (матрица цен)
|
||||||
|
step3ExtraPositions: ExtraPosition[]; // доп. расходы / доставка (Шаг 3)
|
||||||
|
criteria: SupplierCriteria[]; // критерии поставщиков («Дополнительная информация»)
|
||||||
|
recommendedSupplierId?: string; // выбранный поставщик (локальный id)
|
||||||
|
// TODO: step3PositionQtyOverrides
|
||||||
|
|
||||||
|
// Шаг 4 — согласование
|
||||||
|
approvers: Approver[];
|
||||||
|
winSupplierId?: number; // выбранный поставщик (lookup в SP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустая форма — стартовое состояние новой сущности.
|
||||||
|
export const EMPTY_FORM_DATA: CtFormData = {
|
||||||
|
status: 'draft',
|
||||||
|
selectedPositions: [],
|
||||||
|
suppliers: [],
|
||||||
|
currencyRates: [],
|
||||||
|
offers: [],
|
||||||
|
step3ExtraPositions: [],
|
||||||
|
criteria: [],
|
||||||
|
approvers: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Единый контракт props для всех шагов (§5.2) ---
|
||||||
|
// import type у сервиса: тип стирается при компиляции, поэтому цикла импортов в рантайме нет.
|
||||||
|
|
||||||
|
export interface StepProps {
|
||||||
|
service: KpsCtFormService;
|
||||||
|
formData: CtFormData;
|
||||||
|
onSave: (partial: Partial<CtFormData>) => void; // = updateFormData
|
||||||
|
onNext?: () => void;
|
||||||
|
onPrev?: () => void;
|
||||||
|
onStepComplete: (step: FormStep) => void;
|
||||||
|
isReadOnly?: boolean; // форма на согласовании/согласована/отклонена
|
||||||
|
isRestrictedView?: boolean; // урезанный доступ (напр. скрытие цен)
|
||||||
|
userDisplayName: string;
|
||||||
|
isNotificationLink?: boolean; // открыто по ссылке из уведомления
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Форматирование значений для UI. Держим в одном месте, чтобы формат был единым по форме.
|
||||||
|
|
||||||
|
// Сумма в тенге: 320000 → "320 000 ₸". Тенге — валюта заявок TNSU.
|
||||||
|
// В позициях сумма/цена может прийти пустой (API заполняет не всегда) — тогда «—».
|
||||||
|
// typeof-проверка ловит и undefined, и null (если API вернёт его в рантайме).
|
||||||
|
export function formatKzt(amount: number | undefined): string {
|
||||||
|
if (typeof amount !== 'number') {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
return `${amount.toLocaleString('ru-RU')} ₸`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Денежный формат без знака валюты (цена/сумма в валюте поставщика). 0/пусто → «—».
|
||||||
|
export function formatMoney(amount: number | undefined): string {
|
||||||
|
if (typeof amount !== 'number' || amount <= 0) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
return amount.toLocaleString('ru-RU', { maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO-дата → "02.06.2026" (без времени). Пусто/битую дату показываем как «—».
|
||||||
|
export function formatDate(iso: string | undefined): string {
|
||||||
|
if (!iso) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO-дата → "02.06.2026, 11:33". Пусто/битую дату показываем как «—».
|
||||||
|
export function formatDateTime(iso: string | undefined): string {
|
||||||
|
if (!iso) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
return date.toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Персист формы и чтение id сущности из URL (§5.4).
|
||||||
|
// Ключ sessionStorage: `kps_ct_${ctId}`. Пока сущность не создана — временный ключ 'new'.
|
||||||
|
|
||||||
|
const SESSION_PREFIX = 'kps_ct';
|
||||||
|
const NEW_ENTITY_KEY = `${SESSION_PREFIX}_new`;
|
||||||
|
|
||||||
|
// Обычная ссылка и ссылка из уведомления различаются РЕГИСТРОМ параметра (§5.4).
|
||||||
|
const CT_ID_PARAM = 'ctId'; // обычная ссылка
|
||||||
|
const NOTIFICATION_CT_ID_PARAM = 'ctid'; // ссылка из уведомления (Power Automate/Teams)
|
||||||
|
|
||||||
|
export interface UrlEntityInfo {
|
||||||
|
ctId?: number;
|
||||||
|
isNotificationLink: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionKey(ctId?: number): string {
|
||||||
|
return ctId ? `${SESSION_PREFIX}_${ctId}` : NEW_ENTITY_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читает id сущности из URL. Параметр в нижнем регистре => ссылка из уведомления.
|
||||||
|
export function readEntityFromUrl(): UrlEntityInfo {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const notificationRaw = params.get(NOTIFICATION_CT_ID_PARAM);
|
||||||
|
const normalRaw = params.get(CT_ID_PARAM);
|
||||||
|
|
||||||
|
if (notificationRaw) {
|
||||||
|
return { ctId: toId(notificationRaw), isNotificationLink: true };
|
||||||
|
}
|
||||||
|
return { ctId: normalRaw ? toId(normalRaw) : undefined, isNotificationLink: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кладёт ctId в URL без перезагрузки — чтобы перезагрузка/шаринг продолжали ту же форму (§5.4).
|
||||||
|
export function writeCtIdToUrl(ctId: number): void {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set(CT_ID_PARAM, String(ctId));
|
||||||
|
window.history.replaceState(undefined, '', url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadFormState<T>(key: string): T | undefined {
|
||||||
|
const raw = window.sessionStorage.getItem(key);
|
||||||
|
if (!raw) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
// Битый JSON в сессии не должен ронять форму — начинаем с чистого состояния.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveFormState<T>(key: string, state: T): void {
|
||||||
|
window.sessionStorage.setItem(key, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toId(raw: string): number | undefined {
|
||||||
|
const id = Number(raw);
|
||||||
|
return Number.isFinite(id) && id > 0 ? id : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
define([], function() {
|
||||||
|
return {
|
||||||
|
"PropertyPaneDescription": "Description",
|
||||||
|
"BasicGroupName": "Group Name",
|
||||||
|
"DescriptionFieldLabel": "Description Field",
|
||||||
|
"AppLocalEnvironmentSharePoint": "The app is running on your local environment as SharePoint web part",
|
||||||
|
"AppLocalEnvironmentTeams": "The app is running on your local environment as Microsoft Teams app",
|
||||||
|
"AppLocalEnvironmentOffice": "The app is running on your local environment in office.com",
|
||||||
|
"AppLocalEnvironmentOutlook": "The app is running on your local environment in Outlook",
|
||||||
|
"AppSharePointEnvironment": "The app is running on SharePoint page",
|
||||||
|
"AppTeamsTabEnvironment": "The app is running in Microsoft Teams",
|
||||||
|
"AppOfficeEnvironment": "The app is running in office.com",
|
||||||
|
"AppOutlookEnvironment": "The app is running in Outlook",
|
||||||
|
"UnknownEnvironment": "The app is running in an unknown environment"
|
||||||
|
}
|
||||||
|
});
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
declare interface IKpsCtFormWebPartStrings {
|
||||||
|
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 'KpsCtFormWebPartStrings' {
|
||||||
|
const strings: IKpsCtFormWebPartStrings;
|
||||||
|
export = strings;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 249 B |
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./node_modules/@microsoft/spfx-web-build-rig/profiles/default/tsconfig-base.json"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user