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