feat: working version

This commit is contained in:
Тимур Городецкий
2026-07-27 17:50:31 +05:00
commit 8a79c259b4
86 changed files with 29298 additions and 0 deletions
+321
View File
@@ -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
View File
@@ -0,0 +1,10 @@
node_modules/
lib/
lib-commonjs/
dist/
temp/
release/
jest-output/
*.sppkg
.heft/
sharepoint/solution/
+22
View File
@@ -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
+23
View File
@@ -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"
]
}
]
}
+14
View File
@@ -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
View File
@@ -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"
}
}
+109
View File
@@ -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`.
- Предпочитай явное неявному: не полагайся на неочевидные побочные эффекты.
- Структура папок предсказуемая и одинаковая по всему проекту.
- Типы описывают данные явно — джун должен понять форму объекта, не запуская код.
- Сложную бизнес-логику сопровождай коротким «почему» в комментарии.
- Не оставляй мёртвый/закомментированный код — удаляй.
КОНТЕКСТ
- Код пишется для корпоративного портала
- Основной приоритет: читаемость, стабильность, предсказуемость
+335
View File
@@ -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`.
+77
View File
@@ -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
![version](https://img.shields.io/badge/version-1.22.2-green.svg)
## 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/)
+18
View File
@@ -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"
}
}
+7
View File
@@ -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 -->"
}
+50
View File
@@ -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"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@microsoft/spfx-web-build-rig"
}
+4
View File
@@ -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"
}
+6
View File
@@ -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"
}
+7
View File
@@ -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"]
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
"cdnBasePath": "<!-- PATH TO CDN -->"
}
+282
View File
@@ -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 ""
+20036
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -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>
);
}
@@ -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;
}
+16
View File
@@ -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
View File
@@ -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

+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./node_modules/@microsoft/spfx-web-build-rig/profiles/default/tsconfig-base.json"
}