объединение CT, KPS, OID и дашборда в один портал

Перенос, декомпозиция и перекраска форм CT/KPS/OID на @tnsu/ui-kit,
config-driven дашборд-реестр по трём типам документов. Из отслеживания
исключены артефакты сборки (lib-commonjs/jest-output/release) — забыты
в .gitignore ранее.
This commit is contained in:
Тимур Городецкий
2026-08-28 16:21:34 +05:00
parent baf8fa357d
commit 109053e3af
210 changed files with 27016 additions and 11730 deletions
+3
View File
@@ -1,6 +1,9 @@
node_modules/ node_modules/
lib/ lib/
lib-commonjs/
dist/ dist/
release/
jest-output/
temp/ temp/
*.sppkg *.sppkg
.heft/ .heft/
+174
View File
@@ -0,0 +1,174 @@
# CT — перенос в ct-forms-portal (Фаза 3)
Порядок (утверждён): **Этап 1 перенос 1:1 → Этап 2 декомпозиция под структуру KPS → Этап 3 перекраска на ui-kit.**
Правило: сохранить 100% поведения; build после каждого этапа. Эталон структуры — `forms/kps/`.
Связано: `docs/REPAINT-MAP.md` (перекраска), `docs/BEHAVIORS.md`.
Источник: `ct-create-form/src/webparts/ctCreateForm/`. CT = 6953 стр, god-компоненты.
## Этап 1 — перенос 1:1 — ✅ ЗАВЕРШЁН (heft build зелёный)
1. ✅ Скопированы `components/` и `services/` → `forms/ct/{components,services}/` (17 файлов, 1:1).
2. ✅ Правки для сборки в портале (минимум, поведение не менялось):
- `CtService`: конструктор `(context, siteUrl?)`, `baseUrl = siteUrl ?? absoluteUrl` (как KPS).
`kpFolder` тоже параметризован через `siteUrl` (было жёстко на `context.pageContext.web.serverRelativeUrl`).
- `ICtCreateFormProps` + `siteUrl?`; `CtCreateForm` → `new CtService(context, siteUrl)`.
- Web-part-специфичных зависимостей (loc strings) в components/services не было — ничего убирать не пришлось.
3. ✅ Обвязка портала:
- `ctDocumentConfig.ts`: `inPortal: true` + `buildInPortalParams: (row) => ({ [row.status==='Черновик'?'ctId':'ctid']: row.id })`
(сверено с `useCtForm.ts` — читает именно `ctId`/`ctid`, совпало 1:1). siteUrl уже `/sites/sed`.
- `CtFormsPortal.tsx`: ветка `form==='ct'` → `<CtCreateForm .../>` + «← К реестру» (по образцу KPS-ветки).
4. ✅ `heft build` зелёный.
**Открытие при переносе:** CT уже частично зависел от `@tnsu/ui-kit@^0.4.0` — 3 файла
(`Step1PositionSelection.tsx`, `Step2CommercialOffers.tsx`, `CurrencyRatesForm.tsx`) уже используют
`Button`/`Alert` из кита. API совпадает 1:1 с текущим `0.11.0` (variant="secondary"/"accent", Alert
variant="err") — конфликтов не было, работает как есть. Ускорит Этап 3.
## Этап 2 — декомпозиция (карта резки god-компонентов)
**Статус:** [x] Step2CommercialOffers ✅ | [x] Step4Approval ✅ | [~] Step3ComparisonTable (в работе)
### [~] Step3ComparisonTable (2217) — В РАБОТЕ, по чекпоинтам
- ✅ **CP1 согласование** (build ок): `useStep3Approval.ts` (голосование/делегат, 1:1) + `InlineApprovalChain.tsx`
(вью цепочки + диалоги решение/делегат + баннер notifyWarning; хелперы APPROVAL_STATUS_*). Контейнер 2217→1918.
- ✅ **CP2 AI/PDF** (build ок): `useAiComment.ts` (aiComment/isAiLoading/aiError/isPdfLoading/showAiPromptDialog +
handleAiComment/handleGeneratePdfReport/handleDeleteAiComment + loadCtComment-effect + decodeHtmlEntities,
buildPayload передаётся аргументом из контейнера — матричные данные не копируются в хук) + `AiCommentBlock.tsx`
(только блок отображения, диалог-промпт остался в контейнере — как у KPS). PDF-ошибка идёт через колбэк
`onPdfError` в общий `saveError` (сохранили родной баннер).
- ✅ **CP3 хелперы/типы** (build ок): типы (ExtraSupplier/ExtraPosition/PositionRow/CellEdit/EditableOffers) →
`matrixTypes.ts`; константы (SUPPLIER_STATUS_OPTIONS/DELIVERY_TYPE_OPTIONS) + чистые ф-ции
(formatKzt/Num/Qty/round2Str/getSubCols/computeOffer/buildOfferKey) → `matrixHelpers.ts`.
`extraIdCounter` (мутабельный) оставлен в контейнере — уедет вместе с CP4.
- **Контейнер после CP1-3: 2217 → 1735 строк (−22%).**
- ✅ **CP4 ядро матрицы** (build ок): весь стейт/рефы(~15)/эффекты(loadCriteria/loadOffers/refs-sync/beforeunload/
unmount-persist)/мемо(suppliersWithOffer/allPositions/supplierCurrencies/computedOffers/minTotalByPosition/
recommendedSupplierId)/все update*/add*/delete*-хендлеры/handleSave → `useStep3Matrix.ts` (856, перенос
ВЕРБАТИМ). В контейнере — **деструктуризация** результата, поэтому render (984) НЕ менялся. `CriteriaEntry`
переехал в matrixTypes (общий). approval+ai хуки остались в контейнере (берут значения из деструктуризации).
- **Контейнер: 2217 → 984 строки (−56%). Логика матрицы вся в хуке. Этап 2 для CT ЗАВЕРШЁН.**
- ✅ **CP4 happy-path проверен в workbench**: полный проход по всем 4 шагам (создание СТ → загрузка КП → матрица →
согласование) прошёл без ошибок. Основной риск (грубый регресс/краш) снят.
⚠️ Не проверены детально: bundle-строки (комплект), перевод ед. изм. (тумблер), доп.расходы/логистика,
F5 (beforeunload-persist), критерии/AI-комментарий, делегат/замена в согласовании. Смотреть при случае,
не блокирует дальнейшую работу.
- ⏭️ **CP4 матрица-ядро** (САМЫЙ РИСК): ~15 рефов + эффекты (loadCriteria/loadOffers/beforeunload/unmount-persist) +
мемо (suppliersWithOffer/allPositions/supplierCurrencies/computedOffers/minTotalByPosition/recommendedSupplierId) +
все update*/add*/delete*-хендлеры + handleSave → `useStep3Matrix.ts` (перенос ВЕРБАТИМ, не переписывать!).
⚠️ Клубок рефов; компилятор не ловит регресс поведения. ЖЕЛАТЕЛЬНО делать при возможности workbench-теста
(после провижининга списков). Рендер таблицы (thead/tbody/итоги/критерии) дробить на ComparisonMatrix/
MatrixRowView/OfferCells/CriteriaTable/AddExtraRow — на Этапе 3 вместе с перекраской.
Метод: чисто механический перенос блоков (логику не трогать), Fluent оставляем (перекраска — Этап 3),
build после каждого god-компонента.
**Уточнённая граница Этап2/Этап3:** Этап 2 = вынос ЛОГИКИ (хуки/persist), «раз-god-ивание». Финальное
дробление оставшейся ВЬЮХИ на под-компоненты (диалоги/списки) + размеры <200 — на Этапе 3 вместе с
перекраской (диалоги всё равно → ui-kit Dialog), чтобы не трогать файлы дважды.
### ✅ Step4Approval (886) — СДЕЛАНО (build ок)
886 → `useStep4Approval.ts` (вся логика: chain-ops, add/delete/replace, handleActionConfirm, send/incore/restart,
производные флаги + удобные методы selectAddSuggestion/cancelAddRow/openReplace/…) + view `Step4Approval.tsx`
(~370, только разметка + helpers STATUS_LABELS/getStatusClass/formatDate; Fluent). Вьюху добьём на Этапе 3.
### ✅ Step2CommercialOffers (597) — СДЕЛАНО (build ок, 2 чекпоинта)
597 → контейнер `Step2CommercialOffers.tsx` (~155) + `kpTypes.ts` + `useKpProcessing.ts` (конвейер Syntex/Flow)
+ `step2Persist.ts` (handleSave→функция, возвращает patch) + вьюхи `SuppliersWithoutKp.tsx` (владеет add-стейтом)
/ `UploadedKpList.tsx`. Одно семантич. уточнение: в persist `formDataRef.current`→`formData` (в момент save совпадают).
### Step3ComparisonTable.tsx (2217) → по образцу `Step3BuildCt/*` + отдать approval в Step4
| Секции CT (строки) | Куда (аналог KPS) |
|---|---|
| 25–179 типы/хелперы/формат | `types/domain.ts`, `utils/format.ts`, `useStep3Matrix.ts` |
| 196–435 стейт матрицы | `useStep3Matrix.ts` |
| 285–394 approval-хендлеры | → Step4 `useStep4Approval.ts` |
| 396–405, 947–1017 AI/PDF | `useAiComment.ts` + `AiCommentBlock.tsx` |
| 439–604 useEffect загрузка/persist | `step3Persist.ts` + `useStep3Matrix.ts` |
| 606–706 useMemo производные | `useStep3Matrix.ts` |
| 708–789 редактируемые ячейки | `OfferCells.tsx` |
| 791–945 хендлеры строк | `AddExtraRow.tsx` + `useStep3Matrix.ts` |
| 1019–1193 handleSave | `step3Persist.ts` |
| 1197–1239 тулбар + тумблер ед.изм. | `Step3BuildCt.tsx` (+ доменное `unitConversion`) |
| 1241–1337 thead | `ComparisonMatrix.tsx` |
| 1339–1806 tbody + итоги | `MatrixRowView.tsx` + `OfferCells.tsx` + `ComparisonMatrix.tsx` |
| 1807–1864 инлайн-добавление | `AddExtraRow.tsx` |
| 1865–2029 критерии + AI + PDF | `CriteriaTable.tsx` + `AiCommentBlock.tsx` |
| 2030–2114 инлайн-цепочка (Teams) | → Step4 `InlineApprovalChain.tsx` |
| 2115–2211 диалоги (делегирование/решение) | ui-kit `Dialog` в `Step4Approval.tsx` |
### Step4Approval.tsx (886) → `useStep4Approval.ts` + `Step4Approval.tsx` + `InlineApprovalChain.tsx`
(стейт+манипуляции цепочкой 94–352, handleActionConfirm/Send/Incore/Restart 239–474 → хук; render 476–763; диалоги 764–885 → ui-kit Dialog)
### Step2CommercialOffers.tsx (597) → `useKpProcessing.ts`/`kpPipeline.ts`/`kpOffers.ts`/`step2Persist.ts` + view (`Step2LoadKp`,`KpFileList`,`CurrencyRatesForm`,`SuppliersWithoutKp`,`UploadedKpList`)
### CtService.ts (1532) — ввести `siteUrl?` (крит.) + при желании обобщённые CRUD-примитивы как в KPS. Создать `common/*` (StepShell/FormHeader/Pill) + единый `StepProps`.
## Доменные отличия CT (НЕ терять при декомпозиции!)
- Шаг 1 — из **SP-списка** (`fetchRequests(days)`), не из TNSU API (KPS).
- Колонки позиций: **disciplineCode (марка), smrType (SMR), agskCode**, uniqueCode, organization.
- `CT.number` — строковый, `generateCtNumber(projectName)` (per-project).
- `utils/unitConversion.ts` — пересчёт в ед. изм. заявки (тумблер) — нет в KPS.
- `SUPPLIER_STATUS_OPTIONS` (Завод/Дистрибьютор…) — нет в KPS.
- CurrencyRate/SupplierOffer — иные поля (rateToKzt; deliveryCostKzt/additionalCostsKzt/totalWithExtraKzt/pricePerUnitKzt).
- `getCurrentUserCompany()==='astel-k'` → `isRestrictedView` (скрытие цен).
## Этап 3 — перекраска (карта A/B/C из REPAINT-MAP)
Fluent в CT: PrimaryButton/DefaultButton→Button, TextField→TextInput/TextArea, MessageBar→Alert,
IconButton/Icon, Spinner→Spinner, Dialog→ui-kit Dialog, Checkbox→Checkbox, статусы→Pill/Badge.
Особые: **Toggle** (пересчёт ед.изм.) и **NormalPeoplePicker** (делегирование) — прямых аналогов в ките нет:
Toggle → ui-kit Toggle (есть!); people picker → TextInput + searchUsers()+suggestions (как KPS Step4).
**✅✅ Этап 3 ЗАВЕРШЁН (механическая перекраска) — Fluent полностью удалён из CT (0 импортов @fluentui
во всей `forms/ct/`), heft build ожидается пользователем.**
- Все 12 файлов с Fluent переведены на `@tnsu/ui-kit`: RequestsTable/FileUploadArea/UploadedKpList
(Checkbox/IconButton), SuppliersWithoutKp/CurrencyRatesForm/Step1PositionSelection/
Step2CommercialOffers/AiCommentBlock (TextInput/Spinner/Alert/IconButton/FormField),
useStep3Approval/InlineApprovalChain/Step4Approval (делегат/замена — NormalPeoplePicker заменён
на паттерн KPS: query+debounce(300ms)+suggestions+select, `useStep4Approval`/`useStep3Approval`
получили `handleAddQueryChange(val)`/`setDelegateQuery(val)` вместо Fluent-сигнатуры (event, val)),
и главный контейнер `Step3ComparisonTable.tsx` (984 строки, вся матрица/критерии/футер/AI-диалог).
- Правило замен применено: `TextField`→`TextInput`/`TextArea` (multiline→TextArea rows=1, автовысоты
нет — принято как в KPS `CriteriaTable`); без `className`→обёрнуто в `<div>`; `Toggle` без `label`→
обёрнут в `<label>` вручную (span рядом); иконки Fluent→спрайт (Delete→trash, Cancel→close,
Add→plus, Accept/CheckMark→check, ChevronLeft→arrow-left, PDF→document, Forward→chevron-right,
AddFriend/Switch→plus/swap, DoubleChevron*→chevron-left/right повторно, Lightbulb→без иконки).
- SCSS: все `:global(.ms-TextField-*)`-селекторы (таргетили Fluent DOM) заменены на `input`/`textarea`
(таргетят нативный тег внутри обёртки ui-kit — рабочий приём, т.к. хэши классов ui-kit непредсказуемы).
- ⚠️ **Осталось на будущее (сознательно НЕ делали сейчас):** финальное дробление `Step3ComparisonTable.tsx`
render на под-компоненты `ComparisonMatrix`/`OfferCells`/`MatrixRowView`/`AddExtraRow`/`CriteriaTable`
по образцу KPS `Step3BuildCt/*`. Причина отказа сейчас: KPS-хук `useStep3Matrix` даёт чистый API
(`matrix.rows`/`matrix.rateOf`/`matrix.editNumber`...), а CT-хук `useStep3Matrix` (856 строк, CP4)
перенесён ВЕРБАТИМ с сырыми именами (`updateEdit`/`updatePriceKzt`...) и НЕ верифицирован в runtime —
редизайн его публичного API сейчас добавил бы риск поверх уже непроверенного риска. Делать этот шаг
ТОЛЬКО после workbench-теста CP4 (см. ниже), отдельным заходом, вместе с рефакторингом API хука под
форму KPS.
- ✅ **Workbench happy-path пройден** (все 4 шага, создание→согласование, без ошибок) — основной риск CP4+перекраски
снят. Не проверены детально: bundle-строки, Toggle перевода ед.изм., доп.расходы/логистика, критерии/AI,
делегат/замена в согласовании, F5-persist. Смотреть по возможности, не блокирует дальнейшую работу.
- Отдельно найден и починен степпер (`Breadcrumbs.tsx`) — жил вне грепа по `@fluentui` (свой хендрол на
Fluent-синих литералах без Fluent-импорта), переведён на адаптер `Stepper`+`FormHeader`+`Badge` (как KPS).
- ✅✅ **Полный проход по литералам-цветам ЗАВЕРШЁН** (весь набор Fluent-палитры, не только синий):
создан мост `forms/ct/components/common/_theme.scss` (точное зеркало `forms/kps/.../common/_theme.scss`,
тот же маппинг: $accent/$accent-bg/$accent-border/$accent-dark/$accent-hover ← Fluent-синий,
$text/$muted/$muted-2/$border/$border-strong/$head-bg/$hover/$surface ← Fluent-серый,
$ok/$ok-bg/$danger/$danger-bg/$warn-fg/$warn-bg ← Fluent-статусы). Импорт `@import '../common/theme'`
добавлен в 9 файлов (Breadcrumbs, CtCreateForm, RequestsTable, Step1PositionSelection, CurrencyRatesForm,
FileUploadArea, Step2CommercialOffers, Step3ComparisonTable, Step4Approval) — bulk sed-заменой всех
Fluent hex-литералов на семантические переменные (~150+ подстановок). Один найденный баг при массовой
замене: в `CtCreateForm.module.scss` есть SPFx-плейсхолдер темы `"[theme:bodyText, default: #hex]"` —
это НЕ SCSS-код, а строка, которую парсит SPFx-лоадер во время сборки; sed случайно подставил туда
`$text` вместо литерала — исправлено обратно на реальный hex (`#111827`, эквивалент `$gray-900`).
Оставлены как есть (сознательно, вне палитры Fluent): dark-theme цвета (`#1b1a19`/`#252423`),
один `#fefefe` (незначим).
- Отдельно найден и починен степпер (`Breadcrumbs.tsx`) — жил вне грепа по `@fluentui` (свой хендрол на
Fluent-синих литералах без Fluent-импорта), переведён на адаптер `Stepper`+`FormHeader`+`Badge` (как KPS).
- Точечные UX-правки после визуальной проверки пользователем: степпер сужен до `30vw` (обёртка
`.stepperWrap`, прижат влево, откат на 100% ширины ниже `$bp-md`) — применено и в CT, и в KPS;
подсветка «выбран поставщик» (`.colSelected`) переведена с `$warn-bg` (семантически неверно —
«предупреждение») на полупрозрачную золотую заливку `rgba($gold-500, 0.02)`; заливка строк
Логистика/Доп.расходы (`.extraRow`) убрана полностью (были на `$warn-bg`, читались слишком ярко).
## ✅✅✅ ФОРМА CT ПОЛНОСТЬЮ ЗАВЕРШЕНА
Этап 1 (перенос) → Этап 2 (декомпозиция, включая рискованный CP4) → Этап 3 (перекраска компонентов +
полный проход по цветовым токенам + финальное дробление render Step3ComparisonTable) — всё сделано,
собрано и подтверждено пользователем в workbench (полный проход по 4 шагам после каждого крупного этапа).
Дальше по плану — **OID**, тем же маршрутом: перенос 1:1 → декомпозиция под структуру KPS → перекраска.
+164
View File
@@ -0,0 +1,164 @@
# OID — перенос в ct-forms-portal (Фаза 4)
Порядок (тот же, что и CT): **Этап 1 перенос 1:1 → Этап 2 декомпозиция под структуру KPS → Этап 3 перекраска на ui-kit.**
Правило: сохранить 100% поведения; build после каждого этапа. Эталон структуры — `forms/kps/`, карта резки — `docs/CT-PORT.md` (Step2/3/4 у OID структурно близки к CT).
Источник: `oid-create-form/src/webparts/oidCreateForm/`. 10 файлов, самый большой — `Step3ComparisonTable.tsx` (2533 стр, ещё крупнее, чем был у CT).
## Разведка (перед переносом)
- `Step3ComparisonTable.tsx` (2533), `Step4Approval.tsx` (844), `Step1WorksInput.tsx` (646), `Step2CommercialOffers.tsx` (533), `hooks/useOidForm.ts` (299), `OidService.ts` (1470).
- **Шаг 1 у OID устроен ИНАЧЕ, чем у CT**: `Step1WorksInput` — это ручной ввод сгруппированных работ (`PositionGroup → OIDPosition[]`, поля `parentGroup`/`priceSmeta`/`summaSmeta`, `ComboBox` для группы), а не выборка из SP-списка потребностей (как `RequestsTable`+`useRequests` у CT). Ближе к паттерну ItemsTable из «СЗ на перемещение ТМЦ». Для Шага 1 нужен свой чертёж декомпозиции на Этапе 2 — карта CT/KPS сюда не подходит.
- Step2/Step3/Step4 структурно близки к CT (коммерческие предложения / матрица сравнения / согласование) — карта резки CT применима с поправкой на домен (parentGroup/priceSmeta вместо disciplineCode/SMR/agskCode).
- **0% ui-kit** — в отличие от CT (который уже частично сидел на `@tnsu/ui-kit@0.4.0`), OID полностью на raw Fluent. На Этапе 3 конвертировать всё, форы нет.
## Этап 1 — перенос 1:1 — ✅ ЗАВЕРШЁН
1. ✅ Скопированы `components/` и `services/` → `forms/oid/{components,services}/` (17 файлов, 1:1, без правок логики).
2. ✅ **`OidService.ts` НЕ ТРЕБУЕТ параметризации siteUrl** (в отличие от Ct/KpsCtFormService!): модуль-константа
`SITE_URL = "https://tnsukz.sharepoint.com/sites/sed"` уже прописана как раз на целевой сайт портала —
OID изначально жил на /sites/sed, миграции сайта (в отличие от CT/KPS) не было. Все ~15 обращений к SP-листам
используют эту константу напрямую (не `this.baseUrl`), so `context.pageContext.web.absoluteUrl` тоже будет
верно резолвиться при работе внутри портала. Решение: оставить `OidService.ts` полностью нетронутым —
любая правка (даже добавление неиспользуемого `siteUrl?`) была бы риском без пользы.
3. ✅ `IOidCreateFormProps.ts`: `hasTeamsContext` был объявлен, но нигде не читался (мёртвый пропс) —
сделан опциональным (`hasTeamsContext?: boolean`), чтобы порталу не нужно было его прокидывать.
`siteUrl?` НЕ добавлен (сервису он не нужен — см. п.2, не добавляем неиспользуемый пропс).
4. ✅ Обвязка портала:
- `oidDocumentConfig.ts`: добавлены `inPortal: true` + `buildInPortalParams` (условие `Черновик`/`formed` →
`oidId`, иначе → `oidid` — сверено с `useOidForm.ts`, который читает именно так; условие шире, чем у CT,
т.к. `oidDocumentConfig` уже поддерживал статус `formed` в `buildLinkUrl`).
- `CtFormsPortal.tsx`: добавлен импорт `OidCreateForm` + ветка `route.form === 'oid'` → `<OidCreateForm>` +
«← К реестру» (по образцу CT/KPS-веток). `FORM_ID_PARAMS` уже содержал `oidId`/`oidid` (задел на будущее).
- `Dashboard`/`config/index.ts` — уже были готовы (`createOidDocumentConfig()` зарегистрирован, `Dashboard.tsx`
полностью generic по `DOCUMENT_TYPES`, без хардкода typeKey) — правок не потребовалось.
5. ✅ `heft build` — зелёный (после фикса ниже), запущено и открывается в workbench.
**Build-фикс (tsconfig):** первый прогон дал ошибки `TS2550 flatMap/Object.entries does not exist` +
каскадные `TS7006 implicit any`. Причина: у исходного `oid-create-form/tsconfig.json` был явный
`"lib": ["es2019", "dom", "dom.iterable"]`, а у `ct-forms-portal/tsconfig.json` — только базовый
rig-конфиг (`dom, es5, es2015.core/collection/iterable/promise/proxy`, без ES2017/ES2019). Код OID
использует `Array.flatMap`/`Object.entries`, которых там нет. **Решение:** добавлен тот же `lib`-оверрайд
в `ct-forms-portal/tsconfig.json` (а не переписывание кода OID под ES5-совместимые аналоги) — так
проще, ничего не меняет в поведении, и это чистая надстройка типов (не влияет на CT/KPS, которые эти
методы не используют). Файлы OID остались нетронутыми.
## Этап 2 — декомпозиция
**Статус:** [x] Step2CommercialOffers ✅ | [x] Step4Approval ✅ | [ ] Step1WorksInput | [ ] Step3ComparisonTable
### ✅ Step2CommercialOffers (533) — СДЕЛАНО (build+workbench ок)
533 → контейнер `Step2CommercialOffers.tsx` (165) + `useKpProcessing.ts` (248, конвейер Syntex/Flow +
стейт uploadedFiles/suppliers/kpFiles) + `step2Persist.ts` (54, handleSave→функция, возвращает patch)
+ вьюхи `SupplierList.tsx` (43) / `UploadedKpList.tsx` (45) / `AddNoOfferDialog.tsx` (53, диалог
«Добавить без КП» — держит своё UI-состояние формы). Отличие от CT: список поставщиков ведётся прямо
на Шаге 2 (не как у CT), диалог вместо инлайн-формы добавления. Сохранён silent-guard
`if (!formData.oid) return;` перед вызовом persist (было тихим no-op, не стало видимой ошибкой).
### ✅ Step4Approval (844) — СДЕЛАНО (build+workbench ок)
844 → `useStep4Approval.ts` (478: цепочка-операции, add/delete/replace, handleActionConfirm,
send/export/restart, производные флаги + удобные методы selectAddSuggestion/cancelAddRow/openReplace/…)
+ view `Step4Approval.tsx` (426, только разметка + helpers STATUS_LABELS/getStatusClass/formatDate;
Fluent). Структурно почти идентично CT (кроме `handleExport`≈CT `handleSendToIncore`, методы сервиса
называются иначе: `generateOidReport`/`updateOIDStatus`/`resetApprovalChainToDraft`). Финальное
дробление вьюхи (add-member/replace-dialog поиск в под-компоненты) — отложено на Этап 3, как у CT.
### ✅ Step1WorksInput (646) — СДЕЛАНО (build ожидается)
646 → `useStep1WorksInput.ts` (445, перенос ВЕРБАТИМ: весь стейт/рефы(projectSearchTimer/groupsRef/
isMounted/dirtyRef/syncTimer/pendingOidRef/persistRef/scheduleSyncRef)/эффекты(sessionStorage-sync,
гидратация из formData с dirty-guard, cleanup-flush на unmount)/хендлеры(loadProjects/persistPositions/
scheduleSync/markDirtyAndSync/handleWorkNameBlur/handleProjectChange/updateGroupName/updatePosition/
addGroup/removeGroup/addPosition/removePosition/validate/handleSave)) + view `Step1WorksInput.tsx`
(239, только разметка). Риск: клубок рефов/дебаунс-синхронизации позиций в SP + защита от потери правок
при F5/гидратации (dirtyRef) — того же класса, что CP4 у CT, только меньшего масштаба. `dirtyRef`
экспортирован из хука как есть (вьюха дёргает `dirtyRef.current = true` инлайн в одном месте — сохранено
дословно, не оборачивал в сеттер). ✅ build+workbench-тест пройден пользователем (создание группы/позиции, debounce-запись, F5, blur
наименования работ → авто-создание ОИД) — без регрессий.
### ✅ Step3ComparisonTable (2533) — СДЕЛАНО по чекпоинтам (build ожидается)
Разведка подтвердила: структура похожа на CT (Step3+Step4-согласование смешаны), но домен другой
(группы позиций с колонкой «Группа» в шапке, нет валют/тумблера ед.изм., смета price/summa как read-only
референс) — и **два независимых debounce-автосейва** (офферы + критерии, оба по 1500мс) поверх
unmount-persist и beforeunload-persist — риск выше, чем был у CT.
- ✅ CP1 согласование → `useStep3Approval.ts` + `InlineApprovalChain.tsx` (голосование/делегат, 1:1,
NormalPeoplePicker/IPersonaProps оставлены как есть — Fluent, трогать на Этапе 3).
- ✅ CP2 AI/отчёт → `useAiComment.ts` (комментарий ИИ + отдельная генерация Excel-отчёта
`handleGenerateReport`/`generateOidReport` — своя фича, отсутствует в CT в таком виде) +
`AiCommentBlock.tsx` (проще, чем у CT — нет удаления комментария).
- ✅ CP3 хелперы/типы → `matrixTypes.ts` (CriteriaEntry/CellEdit/EditableOffers/ExtraSupplier/
ExtraPosition/PositionRow) + `matrixHelpers.ts` (FIXED_CRITERIA/FIXED_COLS/genId/fmt/fmtKzt/
buildOfferKey).
- ✅ CP4 ядро матрицы → `useStep3Matrix.ts` (1186 строк, перенос ВЕРБАТИМ: весь стейт/рефы/эффекты
ОБОИХ автосейвов(офферы+критерии)/unmount-persist/beforeunload-persist/мемо/хендлеры/handleSave).
`extraIdCounter` — модульная переменная в этом файле (как у CT). `getPosRowSpan` (для rowSpan группы
в шапке таблицы) тоже переехал в хук и возвращается наружу.
- **Контейнер: 2533 → 877 строк.** Финальное дробление render (таблица цен как отдельные
под-компоненты) сознательно НЕ делали — как и у CT/OID Step1/2/4, откладывается на Этап 3 вместе
с перекраской (не трогать вьюху дважды).
- ✅ Build+workbench-тест подтверждён пользователем — без регрессий (матрица, оба автосейва, F5,
unmount-persist, добавление/удаление поставщика/позиции, согласование, отчёт, AI).
## ✅✅✅ ЭТАП 2 ДЛЯ OID ПОЛНОСТЬЮ ЗАВЕРШЁН
Все 4 god-файла (Step1WorksInput/Step2CommercialOffers/Step3ComparisonTable/Step4Approval) разобраны,
build+workbench-тест пройден по каждому. Самый рискованный кусок всего OID (Step3, два автосейва) —
подтверждён рабочим. Дальше — **Этап 3: перекраска на @tnsu/ui-kit** (полный объём, 0% ui-kit форы нет,
как у KPS изначально; не забыть полный проход по цветовым токенам + проверить локальные компоненты
типа Breadcrumbs на скрытые Fluent-цвета вне `@fluentui`-импортов — урок с CT).
## Доменные отличия OID (НЕ терять при декомпозиции!)
- Шаг 1 — **ручной ввод** групп работ (`PositionGroup`/`OIDPosition`), не SP-список потребностей.
- Позиции: `parentGroup`, `positionType` ('Услуга' | 'Дополнительные затраты'), `priceSmeta`/`summaSmeta` (сметная цена — своё, у CT такого нет).
- Списки SP: суффикс «ОИД» уже в названиях (`Сравнительная таблица ОИД`, `Позиции СТ ОИД`, ...) — коллизий с CT/KPS нет.
- `SupplierOffer` в OID проще, чем у CT: нет `currencyCode`/мульти-валюты в базовых полях (нет `deliveryDistanceKm` тоже) — проверить при декомпозиции, не тащить из CT лишнюю доменку.
## ✅✅ Этап 3 — перекраска (механика) ЗАВЕРШЕНА
Все 13 файлов с Fluent → @tnsu/ui-kit (0 импортов `@fluentui` в forms/oid/, включая .tsx и .scss —
проверено grep). Порядок: тривиальные (Step2 IconButton-файлы) → простые (AiCommentBlock/
AddNoOfferDialog/Step2CommercialOffers) → Step1WorksInput → сложные (useStep3Approval+InlineApprovalChain,
Step4Approval) → главный контейнер Step3ComparisonTable.tsx (877 строк) → OidCreateForm+Breadcrumbs → токены.
- **ComboBox не тянется на ui-kit** (нет автокомплита с готовыми опциями) — сделан локальный
`Step1WorksInput/ComboField.tsx` (свободный ввод + подсказки, паттерн как у поиска участника в
Step4Approval: TextInput/native input + `<ul>` подсказок, debounce уже был в хуке). Используется для
«Проект» (серверный поиск через `a.loadProjects`) и «Ед. изм.» (локальная фильтрация статичного списка).
Эскейп-хэтч: поле «Наименование работ» тоже осталось на native `<input>` (не TextInput) — нужен `onBlur`
(создаёт ОИД по потере фокуса), которого у ui-kit `TextInput` нет.
- **`TextInput`/`TextArea` из ui-kit не имеют пропа `className`** — все места, где раньше вешали кастомный
класс на инпут (ячейки матрицы `offerInput`/`qtyInput`/`dataCellName`, критерии, тулбар) переведены на
обёртку `<div className={styles.xxx}><TextInput .../></div>` (как в CT `MatrixRowView`/`CriteriaTable`).
- **useStep3Approval/InlineApprovalChain**: `NormalPeoplePicker`+`IPersonaProps` заменены на паттерн CT —
`delegateDialog.query/suggestions/selected` + debounce 300мс через `service.searchUsers`, `Dialog`
open/title/subText/footer вместо `DialogType.normal`/`DialogFooter`.
- **Иконки**: Fluent→ui-kit спрайт (Add→plus, Delete/Cancel→trash/close, ChevronLeft→arrow-left,
ExcelDocument→document, Forward→chevron-right, Accept→check, Up/Down→arrow-up/arrow-down,
Switch→swap, AddFriend→(без иконки, нет аналога), Lightbulb→(без иконки, как у CT)).
- **Токены**: создан `forms/oid/components/common/_theme.scss` — точное зеркало CT/KPS-моста (тот же
navy/gold маппинг). Полный проход по hex-литералам (~30 уникальных кодов, ~200+ вхождений) →
bulk-sed по всем `*.module.scss` (кроме `OidCreateForm.module.scss`, где 2 литерала `#1b1a19`/`#252423`
сознательно оставлены как есть в `.dark`-блоке — тот же паттерн, что и у CT, dark-theme не токенизируем).
По ходу найдены и убраны 2 мёртвых класса (`.winBtn`/`.winBtnActive`, не использовались в разметке).
`OidCreateForm.module.scss` переписан по образцу `CtCreateForm.module.scss` (root/content/dark), Fluent
`References.scss`-импорт и мёртвые классы `.welcome`/`.welcomeImage`/`.links` убраны.
- ✅ Build+workbench-тест перекраски пройден пользователем. По итогам визуальной проверки — 2 доп. фикса:
- Билд-гоча: SPFx typings-генератор для scss выкидывает из `IExportStyles` **пустые CSS-правила**
(`.class {}` без объявлений) — TS2339 "Property does not exist", хотя класс использован в TSX.
Столкнулись дважды: (1) `.addRowBtn` в Step4Approval.module.scss забыт при переносе JSX-паттерна из
CT (там кнопки стилизовались инлайново); (2) при удалении заливки `.extraRow` (см. ниже) правило
стало пустым — пришлось оставить с `background: transparent;` вместо полного удаления класса.
Написан bash/awk-скрипт для проверки styles.X (tsx) ↔ определение класса (scss) по всему дереву —
полезно на будущее при переносе JSX между формами.
- Цвет: `.colSel`/`.thSupplierSel` (подсветка выбранного поставщика) были на `$warn-bg` — заменены на
`rgba($gold-500, 0.02)`, как у CT. `.extraRow` (доп. затраты) — заливка `$warn-bg` убрана полностью
(только `background: transparent`, класс сохранён пустым не может — см. гочу выше).
- ✅✅ **Финальное дробление render `Step3ComparisonTable.tsx` ЗАВЕРШЕНО** (877→138 строк контейнер),
по точному образцу CT: `ComparisonMatrix.tsx`(228, вся `<table>`)/`MatrixRowView.tsx`(258, обычная
позиция+bundle, с доменной ячейкой «Группа» rowSpan — которой нет у CT)/`ExtraPositionRow.tsx`(123,
доп.затраты, без bundle)/`AddExtraRow.tsx`(73)/`CriteriaTable.tsx`(125). Логика не трогалась — чистая
перекладка JSX, единый проп `matrix: Step3Matrix` (уже был экспортирован из useStep3Matrix.ts). Порядок
строк в `<tbody>` сохранён точно: позиции по группам → `AddExtraRow` → subtotal обычных → extra-позиции
→ subtotal доп.затрат → grand total. Проверка styles.X↔scss после дробления — чисто, `@fluentui` — 0
вхождений.
- ✅ Build + беглый workbench-прогон (Шаг 1→4) подтверждён пользователем после дробления — без регрессий.
## ✅✅✅ ФОРМА OID ПОЛНОСТЬЮ ЗАВЕРШЕНА
Этап 1 (перенос) + Этап 2 (декомпозиция логики) + Этап 3 (перекраска на @tnsu/ui-kit) + финальное
дробление render — всё сделано и подтверждено сборкой/workbench. OID теперь на одном уровне готовности
с CT. Три формы портала (CT/KPS/OID) в состоянии Fluent→ui-kit переведены полностью.
+126
View File
@@ -0,0 +1,126 @@
# REPAINT-MAP — перекраска форм CT/OID/KPS на @tnsu/ui-kit
Живая карта работ по переводу форм портала на общий UI-пакет. Ведётся по ходу.
Связано: `docs/BEHAVIORS.md` (инвентарь поведения форм).
## Принцип
Красим **один раз** в ui-kit через токены, утверждаем в Storybook. В проекте —
только композиция + узкодоменное. «Все цвета в ui-kit» = ui-kit это источник правды
по токенам; потребитель (ct-forms-portal) держит **зеркало** `src/styles/_design-tokens.scss`
(heft не резолвит scss из node_modules), формы красятся из него.
Компоненты не «для KPS», а «для семейства СТ-форм»: API проектируем сразу под 3 формы
(CT/OID используют те же Fluent-примитивы). Правило аддитивности: не хватает пропа для
CT/OID → добавляем **опциональный** (не ломает KPS, semver minor/patch). Escape hatch:
не ложится на 3 формы без уродства → оставляем локальным.
## Архитектура токенов
```
@tnsu/ui-kit src/tokens/_tokens.scss ← ИСТОЧНИК ПРАВДЫ (navy/gold, radius, shadow, bp, миксины)
│ (копия при изменении)
▼
ct-forms-portal src/styles/_design-tokens.scss ← зеркало для SPFx-сборки
▲ (импортят свои .scss: Dashboard, CtFormsPortal, …)
│
forms/kps/components/common/_theme.scss ← МОСТ: семантика формы ($accent,$ok,$supplier-bg…)
▲ → бренд-токены. Имена сохранены.
│ @import '../common/theme'
12 доменных *.module.scss шагов ← перекрашиваются БЕЗ своих правок
```
## Три корзины (по инвентарю KPS-формы)
### A. Уже есть в ui-kit → заменить импорт (мгновенная перекраска)
| Было (Fluent/локальное) | Стало (ui-kit) |
| --- | --- |
| `TextField` | `TextInput` / `TextArea` / `MoneyInput` (числа) |
| `DefaultButton` / `PrimaryButton` | `Button` (variant secondary/primary) |
| низ `StepShell` (кнопки Назад/Далее) | `FormActions` |
| `MessageBar` (+Type) | `Alert` (map severity) |
| `Icon` | `Icon` / `IconSprite` |
| локальный `Pill` | `Badge` |
| локальный `Breadcrumbs` | `Stepper` |
| локальный `common/FormHeader` | `FormHeader` |
### B. Создать в ui-kit (нужны и CT/OID)
| Новый компонент | Драйвер / где нужен |
| --- | --- |
| `Checkbox` (+ строчный вариант) | RequestRow, RequestLines, Step1ReadOnlyView (Шаг 1) |
| `Spinner` / `Loader` | KpFileList, CurrencyRatesForm, AiCommentBlock, Step4Approval |
| `IconButton` | UploadedKpList, AiCommentBlock, CurrencyRatesForm, Step4Approval |
| `Dialog` / `Modal` (generic, с телом+полями) | Step3BuildCt, Step4Approval, InlineApprovalChain (ConfirmDialog не тянет) |
| `Link` | UploadedKpList (скачать) |
### C. Доменное — остаётся в проекте (красим токенами + примитивами ui-kit)
- `Step3BuildCt/ComparisonMatrix`, `MatrixRowView`, `OfferCells` — матрица сравнения СТ.
- `Step3BuildCt/CriteriaTable` — таблица критериев.
- `common/ApprovalChain`, `Step4Approval/InlineApprovalChain` — цепочки (пилюли из `Badge`).
- `Step2LoadKp/KpFileList`, `UploadedKpList` — список файлов КП (статусы обработки Syntex/поток).
Загрузку можно частично на `FileDropzone` + `AttachmentList`.
## Семантические токены формы (мост `_theme.scss`)
| Переменная | Бренд-токен | Было (Fluent) |
| --- | --- | --- |
| `$accent` | `$navy-800` | #0078d4 |
| `$accent-bg` | `$navy-50` | #eff6fc |
| `$accent-border` | `$navy-100` | #c7e0f4 |
| `$accent-dark` | `$navy-950` | #004578 |
| `$accent-hover` | `$navy-700` | #106ebe |
| `$ok` / `$ok-bg` | `$green-600` / `$green-50` | #107c10 / #dff6dd |
| `$danger` / `$danger-bg` | `$red-500` / `$red-50` | #a4262c / #fde7e9 |
| `$warn-fg` / `$warn-bg` | `$amber-700` / `$amber-50` | #7d5900 / — |
| `$text` / `$muted` / `$muted-2` | `$gray-900` / `$gray-500` / `$gray-400` | #323130 / #605e5c / #a19f9d |
| `$border` / `$border-strong` | `$gray-200` / `$gray-300` | #edebe9 / #c8c6c4 |
| `$head-bg` / `$hover` / `$surface` | `$gray-50` / `$gray-100` / `$white` | #faf9f8 / #f3f2f1 / #fff |
| `$supplier-bg` / `$supplier-border` | `$gold-50` / `$gold-100` | #fffdf0 / #f2e9b8 |
## Роадмап и статус
- [x] **Фаза 0 — Токены.** `$amber-50` в ui-kit; зеркало `src/styles/_design-tokens.scss`;
мост `forms/kps/components/common/_theme.scss` → бренд. heft build ок.
_Осталось в рамках гигиены: перевести `Dashboard.module.scss`/`CtFormsPortal.module.scss`
(шелл) с хардкодов на зеркало — не блокер._
- [x] **Фаза 1 — Примитивы (корзина B) собраны в ui-kit** (build ок, 68 модулей):
`Checkbox` (checked/indeterminate/disabled/readOnly/label/ariaLabel),
`Spinner` (xSmall/small/medium/large, label, block),
`IconButton` (icon: IconName, title, danger, size),
`Dialog` (open/title/subText/onDismiss/footer/width, Esc+overlay close),
`Link` (href/onClick/target/download).
В спрайт добавлены иконки `arrow-up`, `arrow-down`, `swap`, `refresh`.
_Осталось: Storybook-утверждение дизайна → релиз ui-kit (skill publish-ui-kit) →
`npm i @tnsu/ui-kit@latest` в ct-forms-portal._
- [~] **Фаза 2 — Перекраска KPS** (по шагам; heft build после каждого):
- [x] **Шаг 1** (`Step1SelectPositions`): Checkbox/Icon → ui-kit (+ariaLabel), `Pill`→адаптер над
`Badge` (перекрасил все 6 мест сразу), `#ffffff`→`$surface`. build ок.
- [x] **Шаг 2** (`Step2LoadKp/*`): MessageBar→Alert, Spinner/IconButton/Link/Icon→ui-kit,
DefaultButton→Button, TextField→TextInput (в обёртке для className), литералы→токены. build ок.
_Косметика: TextInput без suffix/ariaLabel (KZT→placeholder); dropzone оставлен доменным._
_Долг: перед Шагом 4 добавить в Alert вариант `info` (MessageBarType.info) + переиздать ui-kit._
- [x] **Шаг 3** (`Step3BuildCt/*`): AiCommentBlock (IconButton/Spinner/Alert), AddExtraRow +
supplier toolbar (Button/TextInput), OfferCells/CriteriaTable (borderless TextInput/TextArea
в обёртке `.input`), Dialog AI-промпта (ui-kit Dialog + footer). Матрица/критерии — домен,
все литералы→токены ($accent-border/-bg/-dark/-hover, $supplier-bg, $warn-bg/-fg, $surface, $danger).
Микро-кнопки в ячейках (×/+/удалить поставщика) оставлены доменными. build ок.
Потребовало ui-kit **0.11.0**: TextInput/TextArea `borderless`+`ariaLabel`+`onKeyDown`+`autoFocus`,
Alert вариант `info`, иконка `info`.
- [x] **Шаг 4** (`Step4Approval.tsx` + `InlineApprovalChain.tsx`): MessageBar→Alert (ok/err/info/warn),
IconButton (arrow-up/down/trash/swap), Button (approve=primary, reject=danger, send/refresh с иконками),
Dialog×2 (решение/замена) → ui-kit Dialog+footer, TextField→TextInput/TextArea, литералы→токены ($surface,$sh-2).
Цепочки — домен, пилюли через Pill→Badge. build ок.
- [x] **common**: `StepShell` кнопки→`Button` (split-раскладка сохранена), `common/FormHeader`→адаптер
над ui-kit `FormHeader` (right=Pill→Badge), `Breadcrumbs`→адаптер над `Stepper` (адаптивный),
убран `@fluentui References.scss` из `KpsCtForm.module.scss`, #a4262c→$danger. Осиротевшие
`FormHeader.module.scss`/`Breadcrumbs.module.scss` удалены.
- ✅ **Fluent полностью удалён из KPS-формы (0 импортов @fluentui). Фаза 2 завершена, build ок.**
- _KPS = двойной эталон (структура + примитивы)._
- [ ] **Фаза 3 — CT, затем OID.** НЕ только перекраска: **декомпозиция god-компонентов**
(`Step3ComparisonTable.tsx` ~2200–2500 стр) в структуру KPS + сборка из ui-kit +
сохранить 100% поведения. Инвентарь поведения → резка → build/workbench на каждом шаге.
## Заметки / риски
- Декомпозиция CT/OID — самая рискованная часть; KPS = готовый чертёж (тот же скелет
Step1→Step2→Step3 матрица→Step4; отличия доменные: CT disciplineCode/SMR/agskCode, OID parentGroup/priceSmeta).
- При изменении токенов в ui-kit — синхронизировать зеркало `src/styles/_design-tokens.scss`.
-3
View File
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="jest tests" tests="0" failures="0" errors="0" time="0.003">
</testsuites>
-224
View File
@@ -1,224 +0,0 @@
body, html {
margin:0; padding: 0;
height: 100%;
}
body {
font-family: Helvetica Neue, Helvetica, Arial;
font-size: 14px;
color:#333;
}
.small { font-size: 12px; }
*, *:after, *:before {
-webkit-box-sizing:border-box;
-moz-box-sizing:border-box;
box-sizing:border-box;
}
h1 { font-size: 20px; margin: 0;}
h2 { font-size: 14px; }
pre {
font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
margin: 0;
padding: 0;
-moz-tab-size: 2;
-o-tab-size: 2;
tab-size: 2;
}
a { color:#0074D9; text-decoration:none; }
a:hover { text-decoration:underline; }
.strong { font-weight: bold; }
.space-top1 { padding: 10px 0 0 0; }
.pad2y { padding: 20px 0; }
.pad1y { padding: 10px 0; }
.pad2x { padding: 0 20px; }
.pad2 { padding: 20px; }
.pad1 { padding: 10px; }
.space-left2 { padding-left:55px; }
.space-right2 { padding-right:20px; }
.center { text-align:center; }
.clearfix { display:block; }
.clearfix:after {
content:'';
display:block;
height:0;
clear:both;
visibility:hidden;
}
.fl { float: left; }
@media only screen and (max-width:640px) {
.col3 { width:100%; max-width:100%; }
.hide-mobile { display:none!important; }
}
.quiet {
color: #7f7f7f;
color: rgba(0,0,0,0.5);
}
.quiet a { opacity: 0.7; }
.fraction {
font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
font-size: 10px;
color: #555;
background: #E8E8E8;
padding: 4px 5px;
border-radius: 3px;
vertical-align: middle;
}
div.path a:link, div.path a:visited { color: #333; }
table.coverage {
border-collapse: collapse;
margin: 10px 0 0 0;
padding: 0;
}
table.coverage td {
margin: 0;
padding: 0;
vertical-align: top;
}
table.coverage td.line-count {
text-align: right;
padding: 0 5px 0 20px;
}
table.coverage td.line-coverage {
text-align: right;
padding-right: 10px;
min-width:20px;
}
table.coverage td span.cline-any {
display: inline-block;
padding: 0 5px;
width: 100%;
}
.missing-if-branch {
display: inline-block;
margin-right: 5px;
border-radius: 3px;
position: relative;
padding: 0 4px;
background: #333;
color: yellow;
}
.skip-if-branch {
display: none;
margin-right: 10px;
position: relative;
padding: 0 4px;
background: #ccc;
color: white;
}
.missing-if-branch .typ, .skip-if-branch .typ {
color: inherit !important;
}
.coverage-summary {
border-collapse: collapse;
width: 100%;
}
.coverage-summary tr { border-bottom: 1px solid #bbb; }
.keyline-all { border: 1px solid #ddd; }
.coverage-summary td, .coverage-summary th { padding: 10px; }
.coverage-summary tbody { border: 1px solid #bbb; }
.coverage-summary td { border-right: 1px solid #bbb; }
.coverage-summary td:last-child { border-right: none; }
.coverage-summary th {
text-align: left;
font-weight: normal;
white-space: nowrap;
}
.coverage-summary th.file { border-right: none !important; }
.coverage-summary th.pct { }
.coverage-summary th.pic,
.coverage-summary th.abs,
.coverage-summary td.pct,
.coverage-summary td.abs { text-align: right; }
.coverage-summary td.file { white-space: nowrap; }
.coverage-summary td.pic { min-width: 120px !important; }
.coverage-summary tfoot td { }
.coverage-summary .sorter {
height: 10px;
width: 7px;
display: inline-block;
margin-left: 0.5em;
background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
}
.coverage-summary .sorted .sorter {
background-position: 0 -20px;
}
.coverage-summary .sorted-desc .sorter {
background-position: 0 -10px;
}
.status-line { height: 10px; }
/* yellow */
.cbranch-no { background: yellow !important; color: #111; }
/* dark red */
.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
.low .chart { border:1px solid #C21F39 }
.highlighted,
.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
background: #C21F39 !important;
}
/* medium red */
.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
/* light red */
.low, .cline-no { background:#FCE1E5 }
/* light green */
.high, .cline-yes { background:rgb(230,245,208) }
/* medium green */
.cstat-yes { background:rgb(161,215,106) }
/* dark green */
.status-line.high, .high .cover-fill { background:rgb(77,146,33) }
.high .chart { border:1px solid rgb(77,146,33) }
/* dark yellow (gold) */
.status-line.medium, .medium .cover-fill { background: #f9cd0b; }
.medium .chart { border:1px solid #f9cd0b; }
/* light yellow */
.medium { background: #fff4c2; }
.cstat-skip { background: #ddd; color: #111; }
.fstat-skip { background: #ddd; color: #111 !important; }
.cbranch-skip { background: #ddd !important; color: #111; }
span.cline-neutral { background: #eaeaea; }
.coverage-summary td.empty {
opacity: .5;
padding-top: 4px;
padding-bottom: 4px;
line-height: 1;
color: #888;
}
.cover-fill, .cover-empty {
display:inline-block;
height: 12px;
}
.chart {
line-height: 0;
}
.cover-empty {
background: white;
}
.cover-full {
border-right: none !important;
}
pre.prettyprint {
border: none !important;
padding: 0 !important;
margin: 0 !important;
}
.com { color: #999 !important; }
.ignore-none { color: #999; font-weight: normal; }
.wrapper {
min-height: 100%;
height: auto !important;
height: 100%;
margin: 0 auto -48px;
}
.footer, .push {
height: 48px;
}
-87
View File
@@ -1,87 +0,0 @@
/* eslint-disable */
var jumpToCode = (function init() {
// Classes of code we would like to highlight in the file view
var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
// Elements to highlight in the file listing view
var fileListingElements = ['td.pct.low'];
// We don't want to select elements that are direct descendants of another match
var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
// Selector that finds elements on the page to which we can jump
var selector =
fileListingElements.join(', ') +
', ' +
notSelector +
missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
// The NodeList of matching elements
var missingCoverageElements = document.querySelectorAll(selector);
var currentIndex;
function toggleClass(index) {
missingCoverageElements
.item(currentIndex)
.classList.remove('highlighted');
missingCoverageElements.item(index).classList.add('highlighted');
}
function makeCurrent(index) {
toggleClass(index);
currentIndex = index;
missingCoverageElements.item(index).scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center'
});
}
function goToPrevious() {
var nextIndex = 0;
if (typeof currentIndex !== 'number' || currentIndex === 0) {
nextIndex = missingCoverageElements.length - 1;
} else if (missingCoverageElements.length > 1) {
nextIndex = currentIndex - 1;
}
makeCurrent(nextIndex);
}
function goToNext() {
var nextIndex = 0;
if (
typeof currentIndex === 'number' &&
currentIndex < missingCoverageElements.length - 1
) {
nextIndex = currentIndex + 1;
}
makeCurrent(nextIndex);
}
return function jump(event) {
if (
document.getElementById('fileSearch') === document.activeElement &&
document.activeElement != null
) {
// if we're currently focused on the search input, we don't want to navigate
return;
}
switch (event.which) {
case 78: // n
case 74: // j
goToNext();
break;
case 66: // b
case 75: // k
case 80: // p
goToPrevious();
break;
}
};
})();
window.addEventListener('keydown', jumpToCode);
@@ -1,9 +0,0 @@
<?xml version="1.0" ?>
<!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">
<coverage lines-valid="0" lines-covered="0" line-rate="NaN" branches-valid="0" branches-covered="0" branch-rate="NaN" timestamp="1786947493328" complexity="0" version="0.1">
<sources>
<source>C:\Users\gorodetskiy.timur\Documents\Projects\ct-forms-portal</source>
</sources>
<packages>
</packages>
</coverage>
@@ -1,2 +0,0 @@
{"total": {"lines":{"total":0,"covered":0,"skipped":0,"pct":"Unknown"},"statements":{"total":0,"covered":0,"skipped":0,"pct":"Unknown"},"functions":{"total":0,"covered":0,"skipped":0,"pct":"Unknown"},"branches":{"total":0,"covered":0,"skipped":0,"pct":"Unknown"},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":"Unknown"}}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 445 B

-101
View File
@@ -1,101 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for All files</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="prettify.css" />
<link rel="stylesheet" href="base.css" />
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1>All files</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">Unknown% </span>
<span class="quiet">Statements</span>
<span class='fraction'>0/0</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">Unknown% </span>
<span class="quiet">Branches</span>
<span class='fraction'>0/0</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">Unknown% </span>
<span class="quiet">Functions</span>
<span class='fraction'>0/0</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">Unknown% </span>
<span class="quiet">Lines</span>
<span class='fraction'>0/0</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line medium'></div>
<div class="pad1">
<table class="coverage-summary">
<thead>
<tr>
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-08-17T06:18:13.341Z
</div>
<script src="prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="sorter.js"></script>
<script src="block-navigation.js"></script>
</body>
</html>
-1
View File
@@ -1 +0,0 @@
.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 B

-210
View File
@@ -1,210 +0,0 @@
/* eslint-disable */
var addSorting = (function() {
'use strict';
var cols,
currentSort = {
index: 0,
desc: false
};
// returns the summary table element
function getTable() {
return document.querySelector('.coverage-summary');
}
// returns the thead element of the summary table
function getTableHeader() {
return getTable().querySelector('thead tr');
}
// returns the tbody element of the summary table
function getTableBody() {
return getTable().querySelector('tbody');
}
// returns the th element for nth column
function getNthColumn(n) {
return getTableHeader().querySelectorAll('th')[n];
}
function onFilterInput() {
const searchValue = document.getElementById('fileSearch').value;
const rows = document.getElementsByTagName('tbody')[0].children;
// Try to create a RegExp from the searchValue. If it fails (invalid regex),
// it will be treated as a plain text search
let searchRegex;
try {
searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive
} catch (error) {
searchRegex = null;
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
let isMatch = false;
if (searchRegex) {
// If a valid regex was created, use it for matching
isMatch = searchRegex.test(row.textContent);
} else {
// Otherwise, fall back to the original plain text search
isMatch = row.textContent
.toLowerCase()
.includes(searchValue.toLowerCase());
}
row.style.display = isMatch ? '' : 'none';
}
}
// loads the search box
function addSearchBox() {
var template = document.getElementById('filterTemplate');
var templateClone = template.content.cloneNode(true);
templateClone.getElementById('fileSearch').oninput = onFilterInput;
template.parentElement.appendChild(templateClone);
}
// loads all columns
function loadColumns() {
var colNodes = getTableHeader().querySelectorAll('th'),
colNode,
cols = [],
col,
i;
for (i = 0; i < colNodes.length; i += 1) {
colNode = colNodes[i];
col = {
key: colNode.getAttribute('data-col'),
sortable: !colNode.getAttribute('data-nosort'),
type: colNode.getAttribute('data-type') || 'string'
};
cols.push(col);
if (col.sortable) {
col.defaultDescSort = col.type === 'number';
colNode.innerHTML =
colNode.innerHTML + '<span class="sorter"></span>';
}
}
return cols;
}
// attaches a data attribute to every tr element with an object
// of data values keyed by column name
function loadRowData(tableRow) {
var tableCols = tableRow.querySelectorAll('td'),
colNode,
col,
data = {},
i,
val;
for (i = 0; i < tableCols.length; i += 1) {
colNode = tableCols[i];
col = cols[i];
val = colNode.getAttribute('data-value');
if (col.type === 'number') {
val = Number(val);
}
data[col.key] = val;
}
return data;
}
// loads all row data
function loadData() {
var rows = getTableBody().querySelectorAll('tr'),
i;
for (i = 0; i < rows.length; i += 1) {
rows[i].data = loadRowData(rows[i]);
}
}
// sorts the table using the data for the ith column
function sortByIndex(index, desc) {
var key = cols[index].key,
sorter = function(a, b) {
a = a.data[key];
b = b.data[key];
return a < b ? -1 : a > b ? 1 : 0;
},
finalSorter = sorter,
tableBody = document.querySelector('.coverage-summary tbody'),
rowNodes = tableBody.querySelectorAll('tr'),
rows = [],
i;
if (desc) {
finalSorter = function(a, b) {
return -1 * sorter(a, b);
};
}
for (i = 0; i < rowNodes.length; i += 1) {
rows.push(rowNodes[i]);
tableBody.removeChild(rowNodes[i]);
}
rows.sort(finalSorter);
for (i = 0; i < rows.length; i += 1) {
tableBody.appendChild(rows[i]);
}
}
// removes sort indicators for current column being sorted
function removeSortIndicators() {
var col = getNthColumn(currentSort.index),
cls = col.className;
cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, '');
col.className = cls;
}
// adds sort indicators for current column being sorted
function addSortIndicators() {
getNthColumn(currentSort.index).className += currentSort.desc
? ' sorted-desc'
: ' sorted';
}
// adds event listeners for all sorter widgets
function enableUI() {
var i,
el,
ithSorter = function ithSorter(i) {
var col = cols[i];
return function() {
var desc = col.defaultDescSort;
if (currentSort.index === i) {
desc = !currentSort.desc;
}
sortByIndex(i, desc);
removeSortIndicators();
currentSort.index = i;
currentSort.desc = desc;
addSortIndicators();
};
};
for (i = 0; i < cols.length; i += 1) {
if (cols[i].sortable) {
// add the click event handler on the th so users
// dont have to click on those tiny arrows
el = getNthColumn(i).querySelector('.sorter').parentElement;
if (el.addEventListener) {
el.addEventListener('click', ithSorter(i));
} else {
el.attachEvent('onclick', ithSorter(i));
}
}
}
}
// adds sorting functionality to the UI
return function() {
if (!getTable()) {
return;
}
cols = loadColumns();
loadData();
addSearchBox();
addSortIndicators();
enableUI();
};
})();
window.addEventListener('load', addSorting);
@@ -1,107 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var tslib_1 = require("tslib");
var React = tslib_1.__importStar(require("react"));
var ReactDom = tslib_1.__importStar(require("react-dom"));
var sp_core_library_1 = require("@microsoft/sp-core-library");
var sp_property_pane_1 = require("@microsoft/sp-property-pane");
var sp_webpart_base_1 = require("@microsoft/sp-webpart-base");
var strings = tslib_1.__importStar(require("CtFormsPortalWebPartStrings"));
var CtFormsPortal_1 = tslib_1.__importDefault(require("./components/CtFormsPortal"));
var CtFormsPortalWebPart = /** @class */ (function (_super) {
tslib_1.__extends(CtFormsPortalWebPart, _super);
function CtFormsPortalWebPart() {
var _this = _super !== null && _super.apply(this, arguments) || this;
_this._isDarkTheme = false;
_this._environmentMessage = '';
return _this;
}
CtFormsPortalWebPart.prototype.render = function () {
var element = React.createElement(CtFormsPortal_1.default, {
description: this.properties.description,
isDarkTheme: this._isDarkTheme,
environmentMessage: this._environmentMessage,
hasTeamsContext: !!this.context.sdks.microsoftTeams,
userDisplayName: this.context.pageContext.user.displayName,
context: this.context
});
ReactDom.render(element, this.domElement);
};
CtFormsPortalWebPart.prototype.onInit = function () {
var _this = this;
return this._getEnvironmentMessage().then(function (message) {
_this._environmentMessage = message;
});
};
CtFormsPortalWebPart.prototype._getEnvironmentMessage = function () {
var _this = this;
if (!!this.context.sdks.microsoftTeams) { // running in Teams, office.com or Outlook
return this.context.sdks.microsoftTeams.teamsJs.app.getContext()
.then(function (context) {
var environmentMessage = '';
switch (context.app.host.name) {
case 'Office': // running in Office
environmentMessage = _this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOffice : strings.AppOfficeEnvironment;
break;
case 'Outlook': // running in Outlook
environmentMessage = _this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOutlook : strings.AppOutlookEnvironment;
break;
case 'Teams': // running in Teams
case 'TeamsModern':
environmentMessage = _this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentTeams : strings.AppTeamsTabEnvironment;
break;
default:
environmentMessage = strings.UnknownEnvironment;
}
return environmentMessage;
});
}
return Promise.resolve(this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentSharePoint : strings.AppSharePointEnvironment);
};
CtFormsPortalWebPart.prototype.onThemeChanged = function (currentTheme) {
if (!currentTheme) {
return;
}
this._isDarkTheme = !!currentTheme.isInverted;
var semanticColors = currentTheme.semanticColors;
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);
}
};
CtFormsPortalWebPart.prototype.onDispose = function () {
ReactDom.unmountComponentAtNode(this.domElement);
};
Object.defineProperty(CtFormsPortalWebPart.prototype, "dataVersion", {
get: function () {
return sp_core_library_1.Version.parse('1.0');
},
enumerable: false,
configurable: true
});
CtFormsPortalWebPart.prototype.getPropertyPaneConfiguration = function () {
return {
pages: [
{
header: {
description: strings.PropertyPaneDescription
},
groups: [
{
groupName: strings.BasicGroupName,
groupFields: [
(0, sp_property_pane_1.PropertyPaneTextField)('description', {
label: strings.DescriptionFieldLabel
})
]
}
]
}
]
};
};
return CtFormsPortalWebPart;
}(sp_webpart_base_1.BaseClientSideWebPart));
exports.default = CtFormsPortalWebPart;
//# sourceMappingURL=CtFormsPortalWebPart.js.map
File diff suppressed because one or more lines are too long
@@ -1,29 +0,0 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "459579c7-954c-469e-b707-ca75d808603f",
"alias": "CtFormsPortalWebPart",
"componentType": "WebPart",
// The "*" signifies that the version should be taken from the package.json
"version": "*",
"manifestVersion": 2,
// If true, the component can only be installed on sites where Custom Script is allowed.
// Components that allow authors to embed arbitrary script code should set this to true.
// https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
"requiresCustomScript": false,
"supportedHosts": ["SharePointWebPart", "TeamsPersonalApp", "TeamsTab", "SharePointFullPage"],
"supportsThemeVariants": true,
"supportsFullBleed": true,
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Advanced
"group": { "default": "Advanced" },
"title": { "default": "ct-forms-portal" },
"description": { "default": "ct-forms-portal description" },
"officeFabricIconFontName": "Page",
"properties": {
"description": "ct-forms-portal"
}
}]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,15 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var tslib_1 = require("tslib");
var React = tslib_1.__importStar(require("react"));
require("@tnsu/ui-kit/styles.css");
var ui_kit_1 = require("@tnsu/ui-kit");
var CtFormsPortal_module_scss_1 = tslib_1.__importDefault(require("./CtFormsPortal.module.scss"));
var Dashboard_1 = require("./Dashboard");
// Корень веб-парта: спрайт иконок (один раз) + дашборд-реестр.
// Импорт CtFormsPortal.module.scss обязателен — в нём :global full-width правила канвы.
var CtFormsPortal = function (props) { return (React.createElement("div", { className: CtFormsPortal_module_scss_1.default.root },
React.createElement(ui_kit_1.IconSprite, null),
React.createElement(Dashboard_1.Dashboard, { context: props.context }))); };
exports.default = CtFormsPortal;
//# sourceMappingURL=CtFormsPortal.js.map
@@ -1 +0,0 @@
{"version":3,"file":"CtFormsPortal.js","sourceRoot":"","sources":["../../../../src/webparts/ctFormsPortal/components/CtFormsPortal.tsx"],"names":[],"mappings":";;;AAAA,mDAA+B;AAC/B,mCAAiC;AACjC,uCAA0C;AAC1C,kGAAiD;AAEjD,yCAAwC;AAExC,+DAA+D;AAC/D,wFAAwF;AACxF,IAAM,aAAa,GAAkC,UAAC,KAAK,IAAK,OAAA,CAC9D,6BAAK,SAAS,EAAE,mCAAM,CAAC,IAAI;IACzB,oBAAC,mBAAU,OAAG;IACd,oBAAC,qBAAS,IAAC,OAAO,EAAE,KAAK,CAAC,OAAO,GAAI,CACjC,CACP,EAL+D,CAK/D,CAAC;AAEF,kBAAe,aAAa,CAAC","sourcesContent":["import * as React from 'react';\nimport '@tnsu/ui-kit/styles.css';\nimport { IconSprite } from '@tnsu/ui-kit';\nimport styles from './CtFormsPortal.module.scss';\nimport type { ICtFormsPortalProps } from './ICtFormsPortalProps';\nimport { Dashboard } from './Dashboard';\n\n// Корень веб-парта: спрайт иконок (один раз) + дашборд-реестр.\n// Импорт CtFormsPortal.module.scss обязателен — в нём :global full-width правила канвы.\nconst CtFormsPortal: React.FC<ICtFormsPortalProps> = (props) => (\n <div className={styles.root}>\n <IconSprite />\n <Dashboard context={props.context} />\n </div>\n);\n\nexport default CtFormsPortal;\n"]}
@@ -1,11 +0,0 @@
:global #workbenchPageContent,
:global #spPageCanvasContent,
:global .SPCanvas-canvas,
:global .CanvasComponent.LCS .CanvasZone,
:global .CanvasZone {
max-width: 100% !important;
}
.root {
width: 100%;
}
@@ -1,106 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Dashboard = void 0;
var tslib_1 = require("tslib");
var React = tslib_1.__importStar(require("react"));
var react_1 = require("react");
var Dashboard_module_scss_1 = tslib_1.__importDefault(require("./Dashboard.module.scss"));
var ui_kit_1 = require("@tnsu/ui-kit");
var config_1 = require("./config");
var useDocumentDashboard_1 = require("./registry/useDocumentDashboard");
// Иконка сайдбара по типу (kit-иконки; Fluent-иконки из конфигов тут не используем).
var TYPE_ICON = { ct: 'clipboard', oid: 'chart', kps: 'building' };
// Цвет статуса DashboardRow → вариант нашего Badge (error → danger).
var VARIANT_MAP = {
neutral: 'neutral',
info: 'info',
success: 'success',
error: 'danger'
};
function formatDate(iso) {
if (!iso)
return '';
var d = new Date(iso);
var dd = d.getDate() < 10 ? "0".concat(d.getDate()) : "".concat(d.getDate());
var mm = d.getMonth() + 1 < 10 ? "0".concat(d.getMonth() + 1) : "".concat(d.getMonth() + 1);
return "".concat(dd, ".").concat(mm, ".").concat(d.getFullYear());
}
// Дашборд-реестр (config-driven, данные из SharePoint). Оболочка и элементы — из @tnsu/ui-kit.
var Dashboard = function (_a) {
var _b;
var context = _a.context;
var _c = (0, useDocumentDashboard_1.useDocumentDashboard)(context, config_1.DOCUMENT_TYPES), myDocs = _c.myDocs, approvedDocs = _c.approvedDocs, pendingDocs = _c.pendingDocs, isLoading = _c.isLoading, error = _c.error;
var _d = (0, react_1.useState)(config_1.DOCUMENT_TYPES[0].typeKey), activeType = _d[0], setActiveType = _d[1];
var _e = (0, react_1.useState)('pending'), queue = _e[0], setQueue = _e[1];
var _f = (0, react_1.useState)(false), collapsed = _f[0], setCollapsed = _f[1];
var _g = (0, react_1.useState)(false), mobileOpen = _g[0], setMobileOpen = _g[1];
var _h = (0, react_1.useState)(''), search = _h[0], setSearch = _h[1];
var _j = (0, react_1.useState)(''), status = _j[0], setStatus = _j[1];
var cfg = config_1.DOCUMENT_TYPES.filter(function (t) { return t.typeKey === activeType; })[0];
var byType = function (arr) { return arr.filter(function (r) { return r.typeKey === activeType; }); };
var pendingForType = byType(pendingDocs);
var mineForType = byType(myDocs);
var approvedForType = byType(approvedDocs);
var sidebarItems = config_1.DOCUMENT_TYPES.map(function (t) {
var _a;
return ({
id: t.typeKey,
title: t.typeLabel,
icon: (_a = TYPE_ICON[t.typeKey]) !== null && _a !== void 0 ? _a : 'document',
count: pendingDocs.filter(function (r) { return r.typeKey === t.typeKey; }).length
});
});
var tabs = [
{ id: 'pending', label: 'На согласовании у меня', count: pendingForType.length },
{ id: 'mine', label: 'Инициализированные мной', count: mineForType.length },
{ id: 'approved', label: 'Согласованные мной', count: approvedForType.length }
];
var activeRows = queue === 'pending' ? pendingForType : queue === 'mine' ? mineForType : approvedForType;
var statusOptions = Array.from(new Set(activeRows.map(function (r) { return r.statusLabel; }))).map(function (s) { return ({
value: s,
label: s
}); });
var rows = activeRows
.filter(function (r) { var _a; return !search || "".concat(r.title, " ").concat((_a = r.subtitle) !== null && _a !== void 0 ? _a : '').toLowerCase().indexOf(search.toLowerCase()) !== -1; })
.filter(function (r) { return !status || r.statusLabel === status; });
var renderCell = function (row, key) {
var _a;
if (key === 'status') {
return (React.createElement(ui_kit_1.Badge, { variant: VARIANT_MAP[row.statusVariant], size: "sm" }, row.statusLabel));
}
if (key === 'created')
return formatDate(row.createdDate);
return String((_a = row[key]) !== null && _a !== void 0 ? _a : '');
};
var columns = cfg.columns.map(function (c) { return ({
key: c.key,
title: c.label,
width: c.minWidth ? "".concat(c.minWidth, "px") : undefined,
render: function (row) { return renderCell(row, c.key); }
}); });
var selectType = function (id) {
setActiveType(String(id));
setQueue('pending');
setSearch('');
setStatus('');
};
return (React.createElement(ui_kit_1.AppLayout, { sidebar: React.createElement(ui_kit_1.Sidebar, { items: sidebarItems, activeId: activeType, onChange: selectType, brand: "\u0414\u043E\u0433\u043E\u0432\u043E\u0440\u0430", collapsed: collapsed, onToggleCollapsed: function () { return setCollapsed(function (c) { return !c; }); }, mobileOpen: mobileOpen, onMobileClose: function () { return setMobileOpen(false); } }), mobileToggle: React.createElement(ui_kit_1.SidebarToggle, { onClick: function () { return setMobileOpen(true); } }) },
React.createElement("div", { className: Dashboard_module_scss_1.default.head },
React.createElement("h2", { className: Dashboard_module_scss_1.default.title }, cfg.typeLabel),
cfg.createNewUrl && (React.createElement(ui_kit_1.Button, { variant: "accent", onClick: function () { return window.open(cfg.createNewUrl, '_blank'); } }, (_b = cfg.createNewLabel) !== null && _b !== void 0 ? _b : 'Создать'))),
React.createElement("div", { className: Dashboard_module_scss_1.default.toolbar },
React.createElement("div", { className: Dashboard_module_scss_1.default.search },
React.createElement(ui_kit_1.TextInput, { value: search, onChange: setSearch, placeholder: "\u041F\u043E\u0438\u0441\u043A \u043F\u043E \u043D\u043E\u043C\u0435\u0440\u0443 \u0438\u043B\u0438 \u043F\u0440\u043E\u0435\u043A\u0442\u0443" })),
React.createElement("div", { className: Dashboard_module_scss_1.default.filter },
React.createElement(ui_kit_1.Select, { value: status, onChange: setStatus, options: statusOptions, placeholder: "\u0412\u0441\u0435 \u0441\u0442\u0430\u0442\u0443\u0441\u044B" }))),
React.createElement(ui_kit_1.Tabs, { tabs: tabs, activeId: queue, onChange: function (id) { return setQueue(id); } }),
error && (React.createElement("div", { className: Dashboard_module_scss_1.default.state },
React.createElement(ui_kit_1.Alert, { variant: "err" },
"\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044C \u0434\u0430\u043D\u043D\u044B\u0435: ",
error))),
!error && isLoading && React.createElement("div", { className: Dashboard_module_scss_1.default.state }, "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430\u2026"),
!error && !isLoading && (React.createElement("div", { className: Dashboard_module_scss_1.default.tableWrap },
React.createElement(ui_kit_1.DataTable, { columns: columns, rows: rows, rowKey: function (r) { return "".concat(r.typeKey, "-").concat(r.id); }, onRowClick: function (r) { return window.open(cfg.buildLinkUrl(r), '_blank'); }, emptyText: "\u041D\u0435\u0442 \u0434\u043E\u043A\u0443\u043C\u0435\u043D\u0442\u043E\u0432" })))));
};
exports.Dashboard = Dashboard;
//# sourceMappingURL=Dashboard.js.map
File diff suppressed because one or more lines are too long
@@ -1,54 +0,0 @@
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.title {
margin: 0;
font-family: "Lora", Georgia, serif;
font-size: 22px;
font-weight: 600;
color: #0f2440;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search {
width: 280px;
max-width: 100%;
}
.filter {
width: 200px;
}
.tableWrap {
margin-top: 16px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
box-shadow: 0 1px 2px rgba(15, 36, 64, 0.04), 0 1px 3px rgba(15, 36, 64, 0.06);
overflow: hidden;
}
.state {
margin-top: 16px;
color: #6b7280;
font-size: 13px;
}
@media (max-width: 640px) {
.search,
.filter {
width: 100%;
}
}
@@ -1,3 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=ICtFormsPortalProps.js.map
@@ -1 +0,0 @@
{"version":3,"file":"ICtFormsPortalProps.js","sourceRoot":"","sources":["../../../../src/webparts/ctFormsPortal/components/ICtFormsPortalProps.ts"],"names":[],"mappings":"","sourcesContent":["import { WebPartContext } from '@microsoft/sp-webpart-base';\n\nexport interface ICtFormsPortalProps {\n description: string;\n isDarkTheme: boolean;\n environmentMessage: string;\n hasTeamsContext: boolean;\n userDisplayName: string;\n context: WebPartContext;\n}\n"]}
@@ -1,79 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createCtDocumentConfig = createCtDocumentConfig;
var STATUS_LABEL = {
'Черновик': 'Сформирована',
'На согласовании': 'На согласовании',
'Согласовано': 'Согласована',
'Отклонено': 'Отклонена'
};
var STATUS_VARIANT = {
'Черновик': 'neutral',
'На согласовании': 'info',
'Согласовано': 'success',
'Отклонено': 'error'
};
function createCtDocumentConfig() {
return {
typeKey: 'ct',
typeLabel: 'СТ ОМТС',
icon: 'ShoppingCart',
// Списки СТ ОМТС — на сайте СЭД. Явно, чтобы портал не зависел от места размещения.
siteUrl: 'https://tnsukz.sharepoint.com/sites/sed',
myDocsListName: 'Сравнительная таблица',
buildMyDocsFilter: function (userId) { return "AuthorId eq ".concat(userId); },
myDocsSelect: 'Id,Title,number,project,status,Created',
pendingApproval: {
approvalListName: 'Согласование СТ',
buildPendingFilter: function (userId) { return "aproover/Id eq ".concat(userId, " and status eq '\u041D\u0430 \u0441\u043E\u0433\u043B\u0430\u0441\u043E\u0432\u0430\u043D\u0438\u0438'"); },
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: function (parentIds) { return ({
listName: 'Сравнительная таблица',
filter: parentIds.map(function (id) { return "Id eq ".concat(id); }).join(' or '),
select: 'Id,Title,number,project,status,Created'
}); }
},
approvedByMe: {
approvalListName: 'Согласование СТ',
buildPendingFilter: function (userId) { return "aproover/Id eq ".concat(userId, " and status eq '\u0421\u043E\u0433\u043B\u0430\u0441\u043E\u0432\u0430\u043D\u043E'"); },
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: function (parentIds) { return ({
listName: 'Сравнительная таблица',
filter: parentIds.map(function (id) { return "Id eq ".concat(id); }).join(' or '),
select: 'Id,Title,number,project,status,Created'
}); }
},
buildLinkUrl: function (row) {
var base = 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица.aspx';
var param = row.status === 'Черновик' ? 'ctId' : 'ctid';
return "".concat(base, "?").concat(param, "=").concat(row.id);
},
createNewUrl: 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица.aspx',
createNewLabel: 'Создать новую форму СТ',
mapItem: function (raw) {
var _a, _b, _c, _d, _e;
var spStatus = String((_a = raw['status']) !== null && _a !== void 0 ? _a : '');
return {
typeKey: 'ct',
id: String(raw['Id']),
title: "\u0421\u0422 \u2116".concat((_b = raw['number']) !== null && _b !== void 0 ? _b : raw['Title']),
subtitle: String((_c = raw['project']) !== null && _c !== void 0 ? _c : ''),
status: spStatus,
statusLabel: (_d = STATUS_LABEL[spStatus]) !== null && _d !== void 0 ? _d : spStatus,
statusVariant: (_e = STATUS_VARIANT[spStatus]) !== null && _e !== void 0 ? _e : 'neutral',
createdDate: raw['Created'] ? String(raw['Created']) : undefined
};
},
columns: [
{ key: 'title', label: 'Номер СТ', minWidth: 120 },
{ key: 'subtitle', label: 'Проект', minWidth: 180 },
{ key: 'status', label: 'Статус', minWidth: 130 },
{ key: 'created', label: 'Создана', minWidth: 110 }
]
};
}
//# sourceMappingURL=ctDocumentConfig.js.map
File diff suppressed because one or more lines are too long
@@ -1,13 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DOCUMENT_TYPES = void 0;
var ctDocumentConfig_1 = require("./ctDocumentConfig");
var oidDocumentConfig_1 = require("./oidDocumentConfig");
var kpsDocumentConfig_1 = require("./kpsDocumentConfig");
// Все типы документов портала. Добавить тип = добавить конфиг сюда.
exports.DOCUMENT_TYPES = [
(0, ctDocumentConfig_1.createCtDocumentConfig)(),
(0, oidDocumentConfig_1.createOidDocumentConfig)(),
(0, kpsDocumentConfig_1.createKpsDocumentConfig)()
];
//# sourceMappingURL=index.js.map
@@ -1 +0,0 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../src/webparts/ctFormsPortal/components/config/index.ts"],"names":[],"mappings":";;;AACA,uDAA4D;AAC5D,yDAA8D;AAC9D,yDAA8D;AAE9D,oEAAoE;AACvD,QAAA,cAAc,GAAyB;IAClD,IAAA,yCAAsB,GAAE;IACxB,IAAA,2CAAuB,GAAE;IACzB,IAAA,2CAAuB,GAAE;CAC1B,CAAC","sourcesContent":["import { DocumentTypeConfig } from '../registry/documentRegistry';\nimport { createCtDocumentConfig } from './ctDocumentConfig';\nimport { createOidDocumentConfig } from './oidDocumentConfig';\nimport { createKpsDocumentConfig } from './kpsDocumentConfig';\n\n// Все типы документов портала. Добавить тип = добавить конфиг сюда.\nexport const DOCUMENT_TYPES: DocumentTypeConfig[] = [\n createCtDocumentConfig(),\n createOidDocumentConfig(),\n createKpsDocumentConfig()\n];\n"]}
@@ -1,80 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createKpsDocumentConfig = createKpsDocumentConfig;
var KPS_SITE_URL = 'https://tnsukz.sharepoint.com/sites/kps';
var KPS_FORM_PAGE = "".concat(KPS_SITE_URL, "/SitePages/\u0421\u0440\u0430\u0432\u043D\u0438\u0442\u0435\u043B\u044C\u043D\u0430\u044F-\u0442\u0430\u0431\u043B\u0438\u0446\u0430.aspx");
var STATUS_LABEL = {
'Черновик': 'Сформирована',
'На согласовании': 'На согласовании',
'Согласовано': 'Согласована',
'Отклонено': 'Отклонена'
};
var STATUS_VARIANT = {
'Черновик': 'neutral',
'На согласовании': 'info',
'Согласовано': 'success',
'Отклонено': 'error'
};
function createKpsDocumentConfig() {
return {
typeKey: 'kps',
typeLabel: 'СТ КПС',
icon: 'Processing',
// Списки КПС — на отдельном сайт-коллекшене /sites/kps.
siteUrl: KPS_SITE_URL,
myDocsListName: 'Сравнительная таблица',
buildMyDocsFilter: function (userId) { return "AuthorId eq ".concat(userId); },
myDocsSelect: 'Id,Title,number,project,status,Created',
pendingApproval: {
approvalListName: 'Согласование СТ',
buildPendingFilter: function (userId) { return "aproover/Id eq ".concat(userId, " and status eq '\u041D\u0430 \u0441\u043E\u0433\u043B\u0430\u0441\u043E\u0432\u0430\u043D\u0438\u0438'"); },
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: function (parentIds) { return ({
listName: 'Сравнительная таблица',
filter: parentIds.map(function (id) { return "Id eq ".concat(id); }).join(' or '),
select: 'Id,Title,number,project,status,Created'
}); }
},
approvedByMe: {
approvalListName: 'Согласование СТ',
buildPendingFilter: function (userId) { return "aproover/Id eq ".concat(userId, " and status eq '\u0421\u043E\u0433\u043B\u0430\u0441\u043E\u0432\u0430\u043D\u043E'"); },
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: function (parentIds) { return ({
listName: 'Сравнительная таблица',
filter: parentIds.map(function (id) { return "Id eq ".concat(id); }).join(' or '),
select: 'Id,Title,number,project,status,Created'
}); }
},
buildLinkUrl: function (row) {
var param = row.status === 'Черновик' ? 'ctId' : 'ctid';
return "".concat(KPS_FORM_PAGE, "?").concat(param, "=").concat(row.id);
},
createNewUrl: KPS_FORM_PAGE,
createNewLabel: 'Создать новую форму КПС',
mapItem: function (raw) {
var _a, _b, _c, _d, _e;
var spStatus = String((_a = raw['status']) !== null && _a !== void 0 ? _a : '');
return {
typeKey: 'kps',
id: String(raw['Id']),
title: "\u0421\u0422 \u2116".concat((_b = raw['number']) !== null && _b !== void 0 ? _b : raw['Title']),
subtitle: String((_c = raw['project']) !== null && _c !== void 0 ? _c : ''),
status: spStatus,
statusLabel: (_d = STATUS_LABEL[spStatus]) !== null && _d !== void 0 ? _d : spStatus,
statusVariant: (_e = STATUS_VARIANT[spStatus]) !== null && _e !== void 0 ? _e : 'neutral',
createdDate: raw['Created'] ? String(raw['Created']) : undefined
};
},
columns: [
{ key: 'title', label: 'Номер СТ', minWidth: 120 },
{ key: 'subtitle', label: 'Проект', minWidth: 180 },
{ key: 'status', label: 'Статус', minWidth: 130 },
{ key: 'created', label: 'Создана', minWidth: 110 }
]
};
}
//# sourceMappingURL=kpsDocumentConfig.js.map
File diff suppressed because one or more lines are too long
@@ -1,21 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.MOCK_ROWS = exports.FORM_TYPES = void 0;
// Типы форм (позже иконки заменим на профильные, когда добавим их в ui-kit).
exports.FORM_TYPES = [
{ typeKey: 'ct', typeLabel: 'СТ ОМТС', icon: 'clipboard', createLabel: 'Создать новую форму СТ' },
{ typeKey: 'oid', typeLabel: 'СТ ОИД', icon: 'chart', createLabel: 'Создать новую форму ОИД' },
{ typeKey: 'kps', typeLabel: 'СТ КПС', icon: 'building', createLabel: 'Создать новую форму КПС' }
];
// Моки реестра (пока без SharePoint) — чтобы увидеть каркас.
exports.MOCK_ROWS = [
{ id: '53', typeKey: 'ct', queue: 'pending', number: 'СТ №53', project: 'Строительство 5-этажного жилого дома в п.Топар', status: 'На согласовании', statusVariant: 'info', created: '04.06.2026' },
{ id: '52', typeKey: 'ct', queue: 'mine', number: 'СТ №52', project: 'Офис TANSU', status: 'Сформирована', statusVariant: 'neutral', created: '02.06.2026' },
{ id: '48', typeKey: 'ct', queue: 'mine', number: 'СТ №48', project: 'Склад в Караганде', status: 'На согласовании', statusVariant: 'info', created: '28.05.2026' },
{ id: '40', typeKey: 'ct', queue: 'approved', number: 'СТ №40', project: 'Строительство 5-этажного жилого дома в п.Топар', status: 'Согласована', statusVariant: 'success', created: '20.05.2026' },
{ id: '21', typeKey: 'oid', queue: 'mine', number: 'ОИД №21', project: 'Реконструкция кровли', status: 'Сформирована', statusVariant: 'neutral', created: '01.06.2026' },
{ id: '19', typeKey: 'oid', queue: 'approved', number: 'ОИД №19', project: 'Благоустройство территории', status: 'Согласована', statusVariant: 'success', created: '15.05.2026' },
{ id: '17', typeKey: 'oid', queue: 'mine', number: 'ОИД №17', project: 'Отделочные работы', status: 'Отклонена', statusVariant: 'danger', created: '10.05.2026' },
{ id: '9', typeKey: 'kps', queue: 'mine', number: 'КПС №9', project: 'Поставка материалов', status: 'На согласовании', statusVariant: 'info', created: '03.06.2026' }
];
//# sourceMappingURL=mock.js.map
@@ -1 +0,0 @@
{"version":3,"file":"mock.js","sourceRoot":"","sources":["../../../../../src/webparts/ctFormsPortal/components/config/mock.ts"],"names":[],"mappings":";;;AAEA,6EAA6E;AAChE,QAAA,UAAU,GAAsB;IAC3C,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,SAAS,EAAE,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,wBAAwB,EAAE;IACjG,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,yBAAyB,EAAE;IAC9F,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,IAAI,EAAE,UAAU,EAAE,WAAW,EAAE,yBAAyB,EAAE;CAClG,CAAC;AAEF,6DAA6D;AAChD,QAAA,SAAS,GAAmB;IACvC,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,gDAAgD,EAAE,MAAM,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE;IACnM,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,cAAc,EAAE,aAAa,EAAE,SAAS,EAAE,OAAO,EAAE,YAAY,EAAE;IAC5J,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,mBAAmB,EAAE,MAAM,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE;IACnK,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,gDAAgD,EAAE,MAAM,EAAE,aAAa,EAAE,aAAa,EAAE,SAAS,EAAE,OAAO,EAAE,YAAY,EAAE;IAEnM,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,sBAAsB,EAAE,MAAM,EAAE,cAAc,EAAE,aAAa,EAAE,SAAS,EAAE,OAAO,EAAE,YAAY,EAAE;IACxK,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,4BAA4B,EAAE,MAAM,EAAE,aAAa,EAAE,aAAa,EAAE,SAAS,EAAE,OAAO,EAAE,YAAY,EAAE;IACjL,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,mBAAmB,EAAE,MAAM,EAAE,WAAW,EAAE,aAAa,EAAE,QAAQ,EAAE,OAAO,EAAE,YAAY,EAAE;IAEjK,EAAE,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,qBAAqB,EAAE,MAAM,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE;CACtK,CAAC","sourcesContent":["import { IFormTypeConfig, IRegistryRow } from './types';\n\n// Типы форм (позже иконки заменим на профильные, когда добавим их в ui-kit).\nexport const FORM_TYPES: IFormTypeConfig[] = [\n { typeKey: 'ct', typeLabel: 'СТ ОМТС', icon: 'clipboard', createLabel: 'Создать новую форму СТ' },\n { typeKey: 'oid', typeLabel: 'СТ ОИД', icon: 'chart', createLabel: 'Создать новую форму ОИД' },\n { typeKey: 'kps', typeLabel: 'СТ КПС', icon: 'building', createLabel: 'Создать новую форму КПС' }\n];\n\n// Моки реестра (пока без SharePoint) — чтобы увидеть каркас.\nexport const MOCK_ROWS: IRegistryRow[] = [\n { id: '53', typeKey: 'ct', queue: 'pending', number: 'СТ №53', project: 'Строительство 5-этажного жилого дома в п.Топар', status: 'На согласовании', statusVariant: 'info', created: '04.06.2026' },\n { id: '52', typeKey: 'ct', queue: 'mine', number: 'СТ №52', project: 'Офис TANSU', status: 'Сформирована', statusVariant: 'neutral', created: '02.06.2026' },\n { id: '48', typeKey: 'ct', queue: 'mine', number: 'СТ №48', project: 'Склад в Караганде', status: 'На согласовании', statusVariant: 'info', created: '28.05.2026' },\n { id: '40', typeKey: 'ct', queue: 'approved', number: 'СТ №40', project: 'Строительство 5-этажного жилого дома в п.Топар', status: 'Согласована', statusVariant: 'success', created: '20.05.2026' },\n\n { id: '21', typeKey: 'oid', queue: 'mine', number: 'ОИД №21', project: 'Реконструкция кровли', status: 'Сформирована', statusVariant: 'neutral', created: '01.06.2026' },\n { id: '19', typeKey: 'oid', queue: 'approved', number: 'ОИД №19', project: 'Благоустройство территории', status: 'Согласована', statusVariant: 'success', created: '15.05.2026' },\n { id: '17', typeKey: 'oid', queue: 'mine', number: 'ОИД №17', project: 'Отделочные работы', status: 'Отклонена', statusVariant: 'danger', created: '10.05.2026' },\n\n { id: '9', typeKey: 'kps', queue: 'mine', number: 'КПС №9', project: 'Поставка материалов', status: 'На согласовании', statusVariant: 'info', created: '03.06.2026' }\n];\n"]}
@@ -1,85 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createOidDocumentConfig = createOidDocumentConfig;
var STATUS_LABEL = {
'Черновик': 'Сформирована',
'formed': 'Сформирована',
'На согласовании': 'На согласовании',
'on_approval': 'На согласовании',
'Согласовано': 'Согласована',
'approved': 'Согласована',
'Отклонено': 'Отклонена'
};
var STATUS_VARIANT = {
'Черновик': 'neutral',
'formed': 'neutral',
'На согласовании': 'info',
'on_approval': 'info',
'Согласовано': 'success',
'approved': 'success',
'Отклонено': 'error'
};
function createOidDocumentConfig() {
return {
typeKey: 'oid',
typeLabel: 'СТ ОИД',
icon: 'CRMServices',
// Списки СТ ОИД — на сайте СЭД. Явно, чтобы портал не зависел от места размещения.
siteUrl: 'https://tnsukz.sharepoint.com/sites/sed',
myDocsListName: 'Сравнительная таблица ОИД',
buildMyDocsFilter: function (userId) { return "AuthorId eq ".concat(userId); },
myDocsSelect: 'Id,Title,number,project,status,Created',
pendingApproval: {
approvalListName: 'Согласование СТ ОИД',
buildPendingFilter: function (userId) { return "aproover/Id eq ".concat(userId, " and status eq '\u041D\u0430 \u0441\u043E\u0433\u043B\u0430\u0441\u043E\u0432\u0430\u043D\u0438\u0438'"); },
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: function (parentIds) { return ({
listName: 'Сравнительная таблица ОИД',
filter: parentIds.map(function (id) { return "Id eq ".concat(id); }).join(' or '),
select: 'Id,Title,number,project,status,Created'
}); }
},
approvedByMe: {
approvalListName: 'Согласование СТ ОИД',
buildPendingFilter: function (userId) { return "aproover/Id eq ".concat(userId, " and status eq '\u0421\u043E\u0433\u043B\u0430\u0441\u043E\u0432\u0430\u043D\u043E'"); },
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: function (parentIds) { return ({
listName: 'Сравнительная таблица ОИД',
filter: parentIds.map(function (id) { return "Id eq ".concat(id); }).join(' or '),
select: 'Id,Title,number,project,status,Created'
}); }
},
buildLinkUrl: function (row) {
var base = 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица-ОИД.aspx';
var param = row.status === 'Черновик' || row.status === 'formed' ? 'oidId' : 'oidid';
return "".concat(base, "?").concat(param, "=").concat(row.id);
},
createNewUrl: 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица-ОИД.aspx',
createNewLabel: 'Создать новую форму ОИД',
mapItem: function (raw) {
var _a, _b, _c, _d, _e;
var spStatus = String((_a = raw['status']) !== null && _a !== void 0 ? _a : '');
return {
typeKey: 'oid',
id: String(raw['Id']),
title: "\u0421\u0422 \u2116".concat((_b = raw['number']) !== null && _b !== void 0 ? _b : raw['Title']),
subtitle: String((_c = raw['project']) !== null && _c !== void 0 ? _c : ''),
status: spStatus,
statusLabel: (_d = STATUS_LABEL[spStatus]) !== null && _d !== void 0 ? _d : spStatus,
statusVariant: (_e = STATUS_VARIANT[spStatus]) !== null && _e !== void 0 ? _e : 'neutral',
createdDate: raw['Created'] ? String(raw['Created']) : undefined
};
},
columns: [
{ key: 'title', label: 'Номер СТ', minWidth: 120 },
{ key: 'subtitle', label: 'Проект', minWidth: 180 },
{ key: 'status', label: 'Статус', minWidth: 130 },
{ key: 'created', label: 'Создана', minWidth: 110 }
]
};
}
//# sourceMappingURL=oidDocumentConfig.js.map
File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=types.js.map
@@ -1 +0,0 @@
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../../../src/webparts/ctFormsPortal/components/config/types.ts"],"names":[],"mappings":"","sourcesContent":["import { IconName, BadgeVariant } from '@tnsu/ui-kit';\n\n// Очередь реестра (вкладки).\nexport type QueueKey = 'pending' | 'mine' | 'approved';\n\n// Конфиг типа формы (CT/OID/KPS). Пока — минимум для оболочки/реестра.\n// Позже расширим: SP-списки, фильтры, buildLinkUrl, мастер и т.д.\nexport interface IFormTypeConfig {\n typeKey: string;\n typeLabel: string;\n icon: IconName;\n createLabel: string;\n}\n\n// Унифицированная строка реестра. statusVariant — цвет бейджа (маппинг в конфиге типа).\nexport interface IRegistryRow {\n id: string;\n typeKey: string;\n queue: QueueKey;\n number: string;\n project: string;\n status: string;\n statusVariant: BadgeVariant;\n created: string;\n}\n"]}
@@ -1,3 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=documentRegistry.js.map
@@ -1 +0,0 @@
{"version":3,"file":"documentRegistry.js","sourceRoot":"","sources":["../../../../../src/webparts/ctFormsPortal/components/registry/documentRegistry.ts"],"names":[],"mappings":"","sourcesContent":["import { WebPartContext } from '@microsoft/sp-webpart-base';\n\n// Унифицированная строка реестра (перенесено из ct-forms-list/myDocuments).\nexport interface DashboardRow {\n typeKey: string;\n id: string;\n title: string;\n subtitle?: string;\n status: string;\n statusLabel: string;\n statusVariant: 'neutral' | 'info' | 'success' | 'error';\n createdDate?: string;\n}\n\nexport interface ColumnDef {\n key: string;\n label: string;\n minWidth?: number;\n}\n\nexport interface ApprovalSectionConfig {\n approvalListName: string;\n buildPendingFilter: (userId: number) => string;\n approvalSelect: string;\n approvalExpand?: string;\n /** поле в записи согласования, хранящее ID родителя */\n parentIdField: string;\n buildParentQuery: (parentIds: number[]) => { listName: string; filter: string; select: string };\n}\n\n// Конфиг типа документа (CT/OID/KPS): где лежат SP-списки, как фильтровать,\n// как маппить в строку и как открыть форму. Различия типов живут ЗДЕСЬ.\nexport interface DocumentTypeConfig {\n typeKey: string;\n typeLabel: string;\n /** Имя иконки Fluent (легаси из myDocuments); в портале сайдбар берёт kit-иконку по typeKey. */\n icon?: string;\n /** Абсолютный URL сайта со списками этого типа. Не задан — текущий сайт. */\n siteUrl?: string;\n myDocsListName: string;\n buildMyDocsFilter: (userId: number) => string;\n myDocsSelect: string;\n myDocsExpand?: string;\n /** null = нет согласования у этого типа */\n pendingApproval: ApprovalSectionConfig | null;\n /** null = нет вкладки \"Согласованные мной\" */\n approvedByMe: ApprovalSectionConfig | null;\n mapItem: (raw: Record<string, unknown>, context: WebPartContext) => DashboardRow;\n buildLinkUrl: (row: DashboardRow) => string;\n createNewUrl?: string;\n createNewLabel?: string;\n columns: ColumnDef[];\n}\n"]}
@@ -1,175 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DocumentsService = void 0;
var tslib_1 = require("tslib");
var sp_http_1 = require("@microsoft/sp-http");
// Загрузка реестра из SharePoint (перенесено из ct-forms-list/myDocuments).
// Поддерживает типы на разных сайт-коллекшенах (KPS на /sites/kps).
var DocumentsService = /** @class */ (function () {
function DocumentsService(context) {
this.context = context;
this.userIdCache = new Map();
}
Object.defineProperty(DocumentsService.prototype, "currentUserId", {
get: function () {
var _a;
return Number((_a = this.context.pageContext.legacyPageContext['userId']) !== null && _a !== void 0 ? _a : 0);
},
enumerable: false,
configurable: true
});
Object.defineProperty(DocumentsService.prototype, "webUrl", {
get: function () {
return this.context.pageContext.web.absoluteUrl;
},
enumerable: false,
configurable: true
});
DocumentsService.prototype.siteUrlFor = function (config) {
var _a;
return (_a = config.siteUrl) !== null && _a !== void 0 ? _a : this.webUrl;
};
DocumentsService.prototype.resolveUserId = function (config) {
return tslib_1.__awaiter(this, void 0, void 0, function () {
var site, cached, response, json;
return tslib_1.__generator(this, function (_a) {
switch (_a.label) {
case 0:
site = this.siteUrlFor(config);
if (site === this.webUrl)
return [2 /*return*/, this.currentUserId];
cached = this.userIdCache.get(site);
if (cached !== undefined)
return [2 /*return*/, cached];
return [4 /*yield*/, this.context.spHttpClient.get("".concat(site, "/_api/web/currentUser?$select=Id"), sp_http_1.SPHttpClient.configurations.v1)];
case 1:
response = _a.sent();
if (!response.ok) {
throw new Error("\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0438\u0442\u044C \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F \u043D\u0430 \u0441\u0430\u0439\u0442\u0435 \"".concat(site, "\": ").concat(response.status));
}
return [4 /*yield*/, response.json()];
case 2:
json = _a.sent();
this.userIdCache.set(site, json.Id);
return [2 /*return*/, json.Id];
}
});
});
};
DocumentsService.prototype.buildListUrl = function (siteUrl, listName) {
return "".concat(siteUrl, "/_api/web/lists/getByTitle('").concat(listName, "')/items");
};
DocumentsService.prototype.fetchItems = function (siteUrl, listName, params) {
return tslib_1.__awaiter(this, void 0, void 0, function () {
var q, url, response, json;
return tslib_1.__generator(this, function (_a) {
switch (_a.label) {
case 0:
q = [];
if (params.filter)
q.push("$filter=".concat(params.filter));
if (params.select)
q.push("$select=".concat(params.select));
if (params.expand)
q.push("$expand=".concat(params.expand));
if (params.orderby)
q.push("$orderby=".concat(params.orderby));
if (params.top !== undefined)
q.push("$top=".concat(params.top));
url = q.length > 0 ? "".concat(this.buildListUrl(siteUrl, listName), "?").concat(q.join('&')) : this.buildListUrl(siteUrl, listName);
return [4 /*yield*/, this.context.spHttpClient.get(url, sp_http_1.SPHttpClient.configurations.v1)];
case 1:
response = _a.sent();
if (!response.ok) {
throw new Error("\u041E\u0448\u0438\u0431\u043A\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043A\u0438 \u0441\u043F\u0438\u0441\u043A\u0430 \"".concat(listName, "\": ").concat(response.status));
}
return [4 /*yield*/, response.json()];
case 2:
json = _a.sent();
return [2 /*return*/, json.value];
}
});
});
};
DocumentsService.prototype.fetchMyDocs = function (config) {
return tslib_1.__awaiter(this, void 0, void 0, function () {
var siteUrl, userId, items;
var _this = this;
return tslib_1.__generator(this, function (_a) {
switch (_a.label) {
case 0:
siteUrl = this.siteUrlFor(config);
return [4 /*yield*/, this.resolveUserId(config)];
case 1:
userId = _a.sent();
return [4 /*yield*/, this.fetchItems(siteUrl, config.myDocsListName, {
filter: config.buildMyDocsFilter(userId),
select: config.myDocsSelect,
expand: config.myDocsExpand,
orderby: 'Created desc',
top: 100
})];
case 2:
items = _a.sent();
return [2 /*return*/, items.map(function (item) { return config.mapItem(item, _this.context); })];
}
});
});
};
DocumentsService.prototype.fetchApprovedByMe = function (config) {
return tslib_1.__awaiter(this, void 0, void 0, function () {
return tslib_1.__generator(this, function (_a) {
if (!config.approvedByMe)
return [2 /*return*/, []];
return [2 /*return*/, this._fetchApprovalSection(config, config.approvedByMe)];
});
});
};
DocumentsService.prototype.fetchPendingApprovals = function (config) {
return tslib_1.__awaiter(this, void 0, void 0, function () {
return tslib_1.__generator(this, function (_a) {
if (!config.pendingApproval)
return [2 /*return*/, []];
return [2 /*return*/, this._fetchApprovalSection(config, config.pendingApproval)];
});
});
};
DocumentsService.prototype._fetchApprovalSection = function (config, pa) {
return tslib_1.__awaiter(this, void 0, void 0, function () {
var siteUrl, userId, approvalItems, parentIds, parentQuery, parentItems;
var _this = this;
return tslib_1.__generator(this, function (_a) {
switch (_a.label) {
case 0:
siteUrl = this.siteUrlFor(config);
return [4 /*yield*/, this.resolveUserId(config)];
case 1:
userId = _a.sent();
return [4 /*yield*/, this.fetchItems(siteUrl, pa.approvalListName, {
filter: pa.buildPendingFilter(userId),
select: pa.approvalSelect,
expand: pa.approvalExpand,
top: 200
})];
case 2:
approvalItems = _a.sent();
parentIds = Array.from(new Set(approvalItems.map(function (item) { return Number(item[pa.parentIdField]); }).filter(function (id) { return !isNaN(id) && id > 0; })));
if (parentIds.length === 0)
return [2 /*return*/, []];
parentQuery = pa.buildParentQuery(parentIds);
return [4 /*yield*/, this.fetchItems(siteUrl, parentQuery.listName, {
filter: parentQuery.filter,
select: parentQuery.select,
top: 200
})];
case 3:
parentItems = _a.sent();
return [2 /*return*/, parentItems.map(function (item) { return config.mapItem(item, _this.context); })];
}
});
});
};
return DocumentsService;
}());
exports.DocumentsService = DocumentsService;
//# sourceMappingURL=documentsService.js.map
File diff suppressed because one or more lines are too long
@@ -1,39 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.useDocumentDashboard = useDocumentDashboard;
var react_1 = require("react");
var documentsService_1 = require("./documentsService");
// Грузит три очереди (мои / согласованные / на согласовании) по всем типам сразу.
function useDocumentDashboard(context, documentTypes) {
var _a = (0, react_1.useState)([]), myDocs = _a[0], setMyDocs = _a[1];
var _b = (0, react_1.useState)([]), approvedDocs = _b[0], setApprovedDocs = _b[1];
var _c = (0, react_1.useState)([]), pendingDocs = _c[0], setPendingDocs = _c[1];
var _d = (0, react_1.useState)(true), isLoading = _d[0], setIsLoading = _d[1];
var _e = (0, react_1.useState)(null), error = _e[0], setError = _e[1];
var _f = (0, react_1.useState)(0), tick = _f[0], setTick = _f[1];
var reload = (0, react_1.useCallback)(function () { return setTick(function (t) { return t + 1; }); }, []);
(0, react_1.useEffect)(function () {
var service = new documentsService_1.DocumentsService(context);
setIsLoading(true);
setError(null);
Promise.all([
Promise.all(documentTypes.map(function (cfg) { return service.fetchMyDocs(cfg); })),
Promise.all(documentTypes.map(function (cfg) { return service.fetchApprovedByMe(cfg); })),
Promise.all(documentTypes.map(function (cfg) { return service.fetchPendingApprovals(cfg); }))
])
.then(function (_a) {
var myDocGroups = _a[0], approvedGroups = _a[1], pendingGroups = _a[2];
setMyDocs(myDocGroups.reduce(function (acc, arr) { return acc.concat(arr); }, []));
setApprovedDocs(approvedGroups.reduce(function (acc, arr) { return acc.concat(arr); }, []));
setPendingDocs(pendingGroups.reduce(function (acc, arr) { return acc.concat(arr); }, []));
setIsLoading(false);
})
.catch(function (err) {
var _a;
setError((_a = err.message) !== null && _a !== void 0 ? _a : 'Произошла ошибка при загрузке данных');
setIsLoading(false);
});
}, [context, tick]); // eslint-disable-line react-hooks/exhaustive-deps
return { myDocs: myDocs, approvedDocs: approvedDocs, pendingDocs: pendingDocs, isLoading: isLoading, error: error, reload: reload };
}
//# sourceMappingURL=useDocumentDashboard.js.map
@@ -1 +0,0 @@
{"version":3,"file":"useDocumentDashboard.js","sourceRoot":"","sources":["../../../../../src/webparts/ctFormsPortal/components/registry/useDocumentDashboard.ts"],"names":[],"mappings":";;AAeA,oDAoCC;AAnDD,+BAAyD;AAGzD,uDAAsD;AAWtD,kFAAkF;AAClF,SAAgB,oBAAoB,CAClC,OAAuB,EACvB,aAAmC;IAE7B,IAAA,KAAsB,IAAA,gBAAQ,EAAiB,EAAE,CAAC,EAAjD,MAAM,QAAA,EAAE,SAAS,QAAgC,CAAC;IACnD,IAAA,KAAkC,IAAA,gBAAQ,EAAiB,EAAE,CAAC,EAA7D,YAAY,QAAA,EAAE,eAAe,QAAgC,CAAC;IAC/D,IAAA,KAAgC,IAAA,gBAAQ,EAAiB,EAAE,CAAC,EAA3D,WAAW,QAAA,EAAE,cAAc,QAAgC,CAAC;IAC7D,IAAA,KAA4B,IAAA,gBAAQ,EAAU,IAAI,CAAC,EAAlD,SAAS,QAAA,EAAE,YAAY,QAA2B,CAAC;IACpD,IAAA,KAAoB,IAAA,gBAAQ,EAAgB,IAAI,CAAC,EAAhD,KAAK,QAAA,EAAE,QAAQ,QAAiC,CAAC;IAClD,IAAA,KAAkB,IAAA,gBAAQ,EAAS,CAAC,CAAC,EAApC,IAAI,QAAA,EAAE,OAAO,QAAuB,CAAC;IAE5C,IAAM,MAAM,GAAG,IAAA,mBAAW,EAAC,cAAM,OAAA,OAAO,CAAC,UAAC,CAAC,IAAK,OAAA,CAAC,GAAG,CAAC,EAAL,CAAK,CAAC,EAArB,CAAqB,EAAE,EAAE,CAAC,CAAC;IAE5D,IAAA,iBAAS,EAAC;QACR,IAAM,OAAO,GAAG,IAAI,mCAAgB,CAAC,OAAO,CAAC,CAAC;QAC9C,YAAY,CAAC,IAAI,CAAC,CAAC;QACnB,QAAQ,CAAC,IAAI,CAAC,CAAC;QAEf,OAAO,CAAC,GAAG,CAAC;YACV,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC,GAAG,CAAC,UAAC,GAAG,IAAK,OAAA,OAAO,CAAC,WAAW,CAAC,GAAG,CAAC,EAAxB,CAAwB,CAAC,CAAC;YACjE,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC,GAAG,CAAC,UAAC,GAAG,IAAK,OAAA,OAAO,CAAC,iBAAiB,CAAC,GAAG,CAAC,EAA9B,CAA8B,CAAC,CAAC;YACvE,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC,GAAG,CAAC,UAAC,GAAG,IAAK,OAAA,OAAO,CAAC,qBAAqB,CAAC,GAAG,CAAC,EAAlC,CAAkC,CAAC,CAAC;SAC5E,CAAC;aACC,IAAI,CAAC,UAAC,EAA4C;gBAA3C,WAAW,QAAA,EAAE,cAAc,QAAA,EAAE,aAAa,QAAA;YAChD,SAAS,CAAC,WAAW,CAAC,MAAM,CAAC,UAAC,GAAG,EAAE,GAAG,IAAK,OAAA,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,EAAf,CAAe,EAAE,EAAoB,CAAC,CAAC,CAAC;YACnF,eAAe,CAAC,cAAc,CAAC,MAAM,CAAC,UAAC,GAAG,EAAE,GAAG,IAAK,OAAA,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,EAAf,CAAe,EAAE,EAAoB,CAAC,CAAC,CAAC;YAC5F,cAAc,CAAC,aAAa,CAAC,MAAM,CAAC,UAAC,GAAG,EAAE,GAAG,IAAK,OAAA,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,EAAf,CAAe,EAAE,EAAoB,CAAC,CAAC,CAAC;YAC1F,YAAY,CAAC,KAAK,CAAC,CAAC;QACtB,CAAC,CAAC;aACD,KAAK,CAAC,UAAC,GAAU;;YAChB,QAAQ,CAAC,MAAA,GAAG,CAAC,OAAO,mCAAI,sCAAsC,CAAC,CAAC;YAChE,YAAY,CAAC,KAAK,CAAC,CAAC;QACtB,CAAC,CAAC,CAAC;IACP,CAAC,EAAE,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,kDAAkD;IAEvE,OAAO,EAAE,MAAM,QAAA,EAAE,YAAY,cAAA,EAAE,WAAW,aAAA,EAAE,SAAS,WAAA,EAAE,KAAK,OAAA,EAAE,MAAM,QAAA,EAAE,CAAC;AACzE,CAAC","sourcesContent":["import { useState, useEffect, useCallback } from 'react';\nimport { WebPartContext } from '@microsoft/sp-webpart-base';\nimport { DashboardRow, DocumentTypeConfig } from './documentRegistry';\nimport { DocumentsService } from './documentsService';\n\ninterface IDashboardState {\n myDocs: DashboardRow[];\n approvedDocs: DashboardRow[];\n pendingDocs: DashboardRow[];\n isLoading: boolean;\n error: string | null;\n reload: () => void;\n}\n\n// Грузит три очереди (мои / согласованные / на согласовании) по всем типам сразу.\nexport function useDocumentDashboard(\n context: WebPartContext,\n documentTypes: DocumentTypeConfig[]\n): IDashboardState {\n const [myDocs, setMyDocs] = useState<DashboardRow[]>([]);\n const [approvedDocs, setApprovedDocs] = useState<DashboardRow[]>([]);\n const [pendingDocs, setPendingDocs] = useState<DashboardRow[]>([]);\n const [isLoading, setIsLoading] = useState<boolean>(true);\n const [error, setError] = useState<string | null>(null);\n const [tick, setTick] = useState<number>(0);\n\n const reload = useCallback(() => setTick((t) => t + 1), []);\n\n useEffect(() => {\n const service = new DocumentsService(context);\n setIsLoading(true);\n setError(null);\n\n Promise.all([\n Promise.all(documentTypes.map((cfg) => service.fetchMyDocs(cfg))),\n Promise.all(documentTypes.map((cfg) => service.fetchApprovedByMe(cfg))),\n Promise.all(documentTypes.map((cfg) => service.fetchPendingApprovals(cfg)))\n ])\n .then(([myDocGroups, approvedGroups, pendingGroups]) => {\n setMyDocs(myDocGroups.reduce((acc, arr) => acc.concat(arr), [] as DashboardRow[]));\n setApprovedDocs(approvedGroups.reduce((acc, arr) => acc.concat(arr), [] as DashboardRow[]));\n setPendingDocs(pendingGroups.reduce((acc, arr) => acc.concat(arr), [] as DashboardRow[]));\n setIsLoading(false);\n })\n .catch((err: Error) => {\n setError(err.message ?? 'Произошла ошибка при загрузке данных');\n setIsLoading(false);\n });\n }, [context, tick]); // eslint-disable-line react-hooks/exhaustive-deps\n\n return { myDocs, approvedDocs, pendingDocs, isLoading, error, reload };\n}\n"]}
@@ -1,16 +0,0 @@
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"
}
});
+4 -4
View File
@@ -15,7 +15,7 @@
"@microsoft/sp-office-ui-fabric-core": "1.22.1", "@microsoft/sp-office-ui-fabric-core": "1.22.1",
"@microsoft/sp-property-pane": "1.22.1", "@microsoft/sp-property-pane": "1.22.1",
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@tnsu/ui-kit": "^0.8.0", "@tnsu/ui-kit": "^0.11.0",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
"tslib": "2.3.1" "tslib": "2.3.1"
@@ -6268,9 +6268,9 @@
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/@tnsu/ui-kit": { "node_modules/@tnsu/ui-kit": {
"version": "0.8.0", "version": "0.11.0",
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.8.0/ui-kit-0.8.0.tgz", "resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.11.0/ui-kit-0.11.0.tgz",
"integrity": "sha512-PdAxr09JVkbK8XmxSM5Lgfz1Iq56RGFsoz4bI1YBd8quPzM3g/r4gRqqjbhCxyemzfK1d4w4WJD0ZBSs8jJbdA==", "integrity": "sha512-Yv4hUmCsDNvGMj32ygNhBZiurhe5lU0IHvWSZMkG15h+xmCBmsTzgrItmKenKkKiOM2R4cjOo4D2W8SsTNHleQ==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"@fluentui/react": "^8.106.4", "@fluentui/react": "^8.106.4",
+1 -1
View File
@@ -19,7 +19,7 @@
"@microsoft/sp-office-ui-fabric-core": "1.22.1", "@microsoft/sp-office-ui-fabric-core": "1.22.1",
"@microsoft/sp-property-pane": "1.22.1", "@microsoft/sp-property-pane": "1.22.1",
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@tnsu/ui-kit": "^0.8.0", "@tnsu/ui-kit": "^0.11.0",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
"tslib": "2.3.1" "tslib": "2.3.1"
-742
View File
@@ -1,742 +0,0 @@
{
"version": "2.1.0",
"$schema": "http://json.schemastore.org/sarif-2.1.0-rtm.5",
"runs": [
{
"tool": {
"driver": {
"name": "ESLint",
"informationUri": "https://eslint.org",
"version": "8.57.1",
"rules": [
{
"id": "@rushstack/no-new-null",
"helpUri": "https://www.npmjs.com/package/@rushstack/eslint-plugin",
"properties": {},
"shortDescription": {
"text": "Prevent usage of JavaScript's \"null\" keyword in new type declarations. To avoid hampering usage of preexisting APIs that require \"null\", the rule ignores declarations that are local variables, private members, or types that are not exported."
}
},
{
"id": "dot-notation",
"helpUri": "https://eslint.org/docs/latest/rules/dot-notation",
"properties": {},
"shortDescription": {
"text": "Enforce dot notation whenever possible"
}
}
]
}
},
"artifacts": [
{
"location": {
"uri": "src/webparts/ctFormsPortal/components/registry/documentRegistry.ts"
}
},
{
"location": {
"uri": "src/webparts/ctFormsPortal/components/config/ctDocumentConfig.ts"
}
},
{
"location": {
"uri": "src/webparts/ctFormsPortal/components/config/oidDocumentConfig.ts"
}
},
{
"location": {
"uri": "src/webparts/ctFormsPortal/components/config/kpsDocumentConfig.ts"
}
},
{
"location": {
"uri": "src/webparts/ctFormsPortal/components/registry/documentsService.ts"
}
},
{
"location": {
"uri": "src/webparts/ctFormsPortal/components/registry/useDocumentDashboard.ts"
}
}
],
"results": [
{
"level": "warning",
"message": {
"text": "Usage of \"null\" is deprecated except when describing legacy APIs; use \"undefined\" instead"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/registry/documentRegistry.ts",
"index": 0
},
"region": {
"startLine": 45,
"startColumn": 44,
"endLine": 45,
"endColumn": 48,
"snippet": {
"text": "null"
}
}
}
}
],
"ruleId": "@rushstack/no-new-null",
"ruleIndex": 0
},
{
"level": "warning",
"message": {
"text": "Usage of \"null\" is deprecated except when describing legacy APIs; use \"undefined\" instead"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/registry/documentRegistry.ts",
"index": 0
},
"region": {
"startLine": 47,
"startColumn": 41,
"endLine": 47,
"endColumn": 45,
"snippet": {
"text": "null"
}
}
}
}
],
"ruleId": "@rushstack/no-new-null",
"ruleIndex": 0
},
{
"level": "warning",
"message": {
"text": "[\"status\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/ctDocumentConfig.ts",
"index": 1
},
"region": {
"startLine": 57,
"startColumn": 35,
"endLine": 57,
"endColumn": 43,
"snippet": {
"text": "'status'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"Id\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/ctDocumentConfig.ts",
"index": 1
},
"region": {
"startLine": 60,
"startColumn": 24,
"endLine": 60,
"endColumn": 28,
"snippet": {
"text": "'Id'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"number\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/ctDocumentConfig.ts",
"index": 1
},
"region": {
"startLine": 61,
"startColumn": 27,
"endLine": 61,
"endColumn": 35,
"snippet": {
"text": "'number'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"Title\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/ctDocumentConfig.ts",
"index": 1
},
"region": {
"startLine": 61,
"startColumn": 44,
"endLine": 61,
"endColumn": 51,
"snippet": {
"text": "'Title'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"project\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/ctDocumentConfig.ts",
"index": 1
},
"region": {
"startLine": 62,
"startColumn": 30,
"endLine": 62,
"endColumn": 39,
"snippet": {
"text": "'project'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"Created\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/ctDocumentConfig.ts",
"index": 1
},
"region": {
"startLine": 66,
"startColumn": 26,
"endLine": 66,
"endColumn": 35,
"snippet": {
"text": "'Created'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"Created\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/ctDocumentConfig.ts",
"index": 1
},
"region": {
"startLine": 66,
"startColumn": 50,
"endLine": 66,
"endColumn": 59,
"snippet": {
"text": "'Created'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"status\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/oidDocumentConfig.ts",
"index": 2
},
"region": {
"startLine": 63,
"startColumn": 35,
"endLine": 63,
"endColumn": 43,
"snippet": {
"text": "'status'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"Id\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/oidDocumentConfig.ts",
"index": 2
},
"region": {
"startLine": 66,
"startColumn": 24,
"endLine": 66,
"endColumn": 28,
"snippet": {
"text": "'Id'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"number\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/oidDocumentConfig.ts",
"index": 2
},
"region": {
"startLine": 67,
"startColumn": 27,
"endLine": 67,
"endColumn": 35,
"snippet": {
"text": "'number'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"Title\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/oidDocumentConfig.ts",
"index": 2
},
"region": {
"startLine": 67,
"startColumn": 44,
"endLine": 67,
"endColumn": 51,
"snippet": {
"text": "'Title'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"project\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/oidDocumentConfig.ts",
"index": 2
},
"region": {
"startLine": 68,
"startColumn": 30,
"endLine": 68,
"endColumn": 39,
"snippet": {
"text": "'project'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"Created\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/oidDocumentConfig.ts",
"index": 2
},
"region": {
"startLine": 72,
"startColumn": 26,
"endLine": 72,
"endColumn": 35,
"snippet": {
"text": "'Created'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"Created\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/oidDocumentConfig.ts",
"index": 2
},
"region": {
"startLine": 72,
"startColumn": 50,
"endLine": 72,
"endColumn": 59,
"snippet": {
"text": "'Created'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"status\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/kpsDocumentConfig.ts",
"index": 3
},
"region": {
"startLine": 61,
"startColumn": 35,
"endLine": 61,
"endColumn": 43,
"snippet": {
"text": "'status'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"Id\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/kpsDocumentConfig.ts",
"index": 3
},
"region": {
"startLine": 64,
"startColumn": 24,
"endLine": 64,
"endColumn": 28,
"snippet": {
"text": "'Id'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"number\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/kpsDocumentConfig.ts",
"index": 3
},
"region": {
"startLine": 65,
"startColumn": 27,
"endLine": 65,
"endColumn": 35,
"snippet": {
"text": "'number'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"Title\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/kpsDocumentConfig.ts",
"index": 3
},
"region": {
"startLine": 65,
"startColumn": 44,
"endLine": 65,
"endColumn": 51,
"snippet": {
"text": "'Title'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"project\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/kpsDocumentConfig.ts",
"index": 3
},
"region": {
"startLine": 66,
"startColumn": 30,
"endLine": 66,
"endColumn": 39,
"snippet": {
"text": "'project'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"Created\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/kpsDocumentConfig.ts",
"index": 3
},
"region": {
"startLine": 70,
"startColumn": 26,
"endLine": 70,
"endColumn": 35,
"snippet": {
"text": "'Created'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"Created\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/config/kpsDocumentConfig.ts",
"index": 3
},
"region": {
"startLine": 70,
"startColumn": 50,
"endLine": 70,
"endColumn": 59,
"snippet": {
"text": "'Created'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "[\"userId\"] is better written in dot notation."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/registry/documentsService.ts",
"index": 4
},
"region": {
"startLine": 13,
"startColumn": 91,
"endLine": 13,
"endColumn": 99,
"snippet": {
"text": "'userId'"
}
}
}
}
],
"ruleId": "dot-notation",
"ruleIndex": 1
},
{
"level": "warning",
"message": {
"text": "Usage of \"null\" is deprecated except when describing legacy APIs; use \"undefined\" instead"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "src/webparts/ctFormsPortal/components/registry/useDocumentDashboard.ts",
"index": 5
},
"region": {
"startLine": 11,
"startColumn": 19,
"endLine": 11,
"endColumn": 23,
"snippet": {
"text": "null"
}
}
}
}
],
"ruleId": "@rushstack/no-new-null",
"ruleIndex": 0
}
]
}
]
}
@@ -1,16 +0,0 @@
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"
}
});
@@ -1,16 +0,0 @@
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"
}
});
@@ -1,102 +0,0 @@
"use strict";
self["webpackHotUpdate_459579c7_954c_469e_b707_ca75d808603f_0_0_1"]("ct-forms-portal-web-part",{
/***/ 996:
/*!**************************************************************************!*\
!*** ./lib/webparts/ctFormsPortal/components/config/ctDocumentConfig.js ***!
\**************************************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ createCtDocumentConfig: () => (/* binding */ createCtDocumentConfig)
/* harmony export */ });
var STATUS_LABEL = {
'Черновик': 'Сформирована',
'На согласовании': 'На согласовании',
'Согласовано': 'Согласована',
'Отклонено': 'Отклонена'
};
var STATUS_VARIANT = {
'Черновик': 'neutral',
'На согласовании': 'info',
'Согласовано': 'success',
'Отклонено': 'error'
};
function createCtDocumentConfig() {
return {
typeKey: 'ct',
typeLabel: 'СТ ОМТС',
icon: 'ShoppingCart',
// Списки СТ ОМТС — на сайте СЭД. Явно, чтобы портал не зависел от места размещения.
siteUrl: 'https://tnsukz.sharepoint.com/sites/sed',
myDocsListName: 'Сравнительная таблица',
buildMyDocsFilter: function (userId) { return "AuthorId eq ".concat(userId); },
myDocsSelect: 'Id,Title,number,project,status,Created',
pendingApproval: {
approvalListName: 'Согласование СТ',
buildPendingFilter: function (userId) { return "aproover/Id eq ".concat(userId, " and status eq '\u041D\u0430 \u0441\u043E\u0433\u043B\u0430\u0441\u043E\u0432\u0430\u043D\u0438\u0438'"); },
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: function (parentIds) { return ({
listName: 'Сравнительная таблица',
filter: parentIds.map(function (id) { return "Id eq ".concat(id); }).join(' or '),
select: 'Id,Title,number,project,status,Created'
}); }
},
approvedByMe: {
approvalListName: 'Согласование СТ',
buildPendingFilter: function (userId) { return "aproover/Id eq ".concat(userId, " and status eq '\u0421\u043E\u0433\u043B\u0430\u0441\u043E\u0432\u0430\u043D\u043E'"); },
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: function (parentIds) { return ({
listName: 'Сравнительная таблица',
filter: parentIds.map(function (id) { return "Id eq ".concat(id); }).join(' or '),
select: 'Id,Title,number,project,status,Created'
}); }
},
buildLinkUrl: function (row) {
var base = 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица.aspx';
var param = row.status === 'Черновик' ? 'ctId' : 'ctid';
return "".concat(base, "?").concat(param, "=").concat(row.id);
},
createNewUrl: 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица.aspx',
createNewLabel: 'Создать новую форму СТ',
mapItem: function (raw) {
var _a, _b, _c, _d, _e;
var spStatus = String((_a = raw['status']) !== null && _a !== void 0 ? _a : '');
return {
typeKey: 'ct',
id: String(raw['Id']),
title: "\u0421\u0422 \u2116".concat((_b = raw['number']) !== null && _b !== void 0 ? _b : raw['Title']),
subtitle: String((_c = raw['project']) !== null && _c !== void 0 ? _c : ''),
status: spStatus,
statusLabel: (_d = STATUS_LABEL[spStatus]) !== null && _d !== void 0 ? _d : spStatus,
statusVariant: (_e = STATUS_VARIANT[spStatus]) !== null && _e !== void 0 ? _e : 'neutral',
createdDate: raw['Created'] ? String(raw['Created']) : undefined
};
},
columns: [
{ key: 'title', label: 'Номер СТ', minWidth: 120 },
{ key: 'subtitle', label: 'Проект', minWidth: 180 },
{ key: 'status', label: 'Статус', minWidth: 130 },
{ key: 'created', label: 'Создана', minWidth: 110 }
]
};
}
/***/ })
},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("c0c82c263a05c3417a10")
/******/ })();
/******/
/******/ }
);
//# sourceMappingURL=ct-forms-portal-web-part.8bc87a793f5dc3d11d32.hot-update.js.map
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
{"c":["ct-forms-portal-web-part"],"r":[],"m":[]}
@@ -1,108 +0,0 @@
"use strict";
self["webpackHotUpdate_459579c7_954c_469e_b707_ca75d808603f_0_0_1"]("ct-forms-portal-web-part",{
/***/ 633:
/*!***************************************************************************!*\
!*** ./lib/webparts/ctFormsPortal/components/config/oidDocumentConfig.js ***!
\***************************************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ createOidDocumentConfig: () => (/* binding */ createOidDocumentConfig)
/* harmony export */ });
var STATUS_LABEL = {
'Черновик': 'Сформирована',
'formed': 'Сформирована',
'На согласовании': 'На согласовании',
'on_approval': 'На согласовании',
'Согласовано': 'Согласована',
'approved': 'Согласована',
'Отклонено': 'Отклонена'
};
var STATUS_VARIANT = {
'Черновик': 'neutral',
'formed': 'neutral',
'На согласовании': 'info',
'on_approval': 'info',
'Согласовано': 'success',
'approved': 'success',
'Отклонено': 'error'
};
function createOidDocumentConfig() {
return {
typeKey: 'oid',
typeLabel: 'СТ ОИД',
icon: 'CRMServices',
// Списки СТ ОИД — на сайте СЭД. Явно, чтобы портал не зависел от места размещения.
siteUrl: 'https://tnsukz.sharepoint.com/sites/sed',
myDocsListName: 'Сравнительная таблица ОИД',
buildMyDocsFilter: function (userId) { return "AuthorId eq ".concat(userId); },
myDocsSelect: 'Id,Title,number,project,status,Created',
pendingApproval: {
approvalListName: 'Согласование СТ ОИД',
buildPendingFilter: function (userId) { return "aproover/Id eq ".concat(userId, " and status eq '\u041D\u0430 \u0441\u043E\u0433\u043B\u0430\u0441\u043E\u0432\u0430\u043D\u0438\u0438'"); },
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: function (parentIds) { return ({
listName: 'Сравнительная таблица ОИД',
filter: parentIds.map(function (id) { return "Id eq ".concat(id); }).join(' or '),
select: 'Id,Title,number,project,status,Created'
}); }
},
approvedByMe: {
approvalListName: 'Согласование СТ ОИД',
buildPendingFilter: function (userId) { return "aproover/Id eq ".concat(userId, " and status eq '\u0421\u043E\u0433\u043B\u0430\u0441\u043E\u0432\u0430\u043D\u043E'"); },
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: function (parentIds) { return ({
listName: 'Сравнительная таблица ОИД',
filter: parentIds.map(function (id) { return "Id eq ".concat(id); }).join(' or '),
select: 'Id,Title,number,project,status,Created'
}); }
},
buildLinkUrl: function (row) {
var base = 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица-ОИД.aspx';
var param = row.status === 'Черновик' || row.status === 'formed' ? 'oidId' : 'oidid';
return "".concat(base, "?").concat(param, "=").concat(row.id);
},
createNewUrl: 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица-ОИД.aspx',
createNewLabel: 'Создать новую форму ОИД',
mapItem: function (raw) {
var _a, _b, _c, _d, _e;
var spStatus = String((_a = raw['status']) !== null && _a !== void 0 ? _a : '');
return {
typeKey: 'oid',
id: String(raw['Id']),
title: "\u0421\u0422 \u2116".concat((_b = raw['number']) !== null && _b !== void 0 ? _b : raw['Title']),
subtitle: String((_c = raw['project']) !== null && _c !== void 0 ? _c : ''),
status: spStatus,
statusLabel: (_d = STATUS_LABEL[spStatus]) !== null && _d !== void 0 ? _d : spStatus,
statusVariant: (_e = STATUS_VARIANT[spStatus]) !== null && _e !== void 0 ? _e : 'neutral',
createdDate: raw['Created'] ? String(raw['Created']) : undefined
};
},
columns: [
{ key: 'title', label: 'Номер СТ', minWidth: 120 },
{ key: 'subtitle', label: 'Проект', minWidth: 180 },
{ key: 'status', label: 'Статус', minWidth: 130 },
{ key: 'created', label: 'Создана', minWidth: 110 }
]
};
}
/***/ })
},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("28bba40dc5ac93a046a4")
/******/ })();
/******/
/******/ }
);
//# sourceMappingURL=ct-forms-portal-web-part.c0c82c263a05c3417a10.hot-update.js.map
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
{"c":["ct-forms-portal-web-part"],"r":[],"m":[]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,27 +0,0 @@
/** @license React v17.0.1
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/*
object-assign
(c) Sindre Sorhus
@license MIT
*/
/*! *****************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
@@ -1,237 +0,0 @@
{
"bundles": {
"ct-forms-portal-web-part": {
"dependencies": [
{
"componentId": "0d910c1c-13b9-4e1c-9aa4-b008c5e42d7d",
"componentName": "react",
"componentVersion": "17.0.1",
"isDirectDependency": true
},
{
"componentId": "aa0a46ec-1505-43cd-a44a-93f3a5aa460a",
"componentName": "react-dom",
"componentVersion": "17.0.1",
"isDirectDependency": true
},
{
"componentId": "7263c7d0-1d6a-45ec-8d85-d4d1d234171b",
"componentName": "@microsoft/sp-core-library",
"componentVersion": "1.22.1",
"isDirectDependency": true
},
{
"componentId": "01c4df03-e775-48cb-aa14-171ee5199a15",
"componentName": "tslib",
"componentVersion": "2.3.1",
"isDirectDependency": false
},
{
"componentId": "2e09fb9b-13bb-48f2-859f-97d6fff71176",
"componentName": "@msinternal/odsp-core-bundle",
"componentVersion": "1.4.517",
"isDirectDependency": false
},
{
"componentId": "f9e737b7-f0df-4597-ba8c-3060f82380db",
"componentName": "@microsoft/sp-property-pane",
"componentVersion": "1.22.1",
"isDirectDependency": true
},
{
"componentId": "78359e4b-07c2-43c6-8d0b-d060b4d577e8",
"componentName": "@microsoft/sp-diagnostics",
"componentVersion": "1.22.1",
"isDirectDependency": false
},
{
"componentId": "73e1dc6c-8441-42cc-ad47-4bd3659f8a3a",
"componentName": "@microsoft/sp-lodash-subset",
"componentVersion": "1.22.1",
"isDirectDependency": false
},
{
"componentId": "3173fefe-9995-49d1-a75d-a5caeaaba212",
"componentName": "@msinternal/sp-pages-content-panel-context",
"componentVersion": "0.1.0",
"isDirectDependency": false
},
{
"componentId": "229b8d08-79f3-438b-8c21-4613fc877abd",
"componentName": "@microsoft/load-themed-styles",
"componentVersion": "0.1.2",
"isDirectDependency": false
},
{
"componentId": "8494e7d7-6b99-47b2-a741-59873e42f16f",
"componentName": "@msinternal/sp-component-utilities",
"componentVersion": "13.12.19",
"isDirectDependency": false
},
{
"componentId": "63d75709-2785-4c91-97ad-489b171397d4",
"componentName": "@msinternal/sp-fluentui-migration-reactdom-render-bundle",
"componentVersion": "0.1.0",
"isDirectDependency": false
},
{
"componentId": "cc2cc925-b5be-41bb-880a-f0f8030c6aff",
"componentName": "@msinternal/odsp-utilities-bundle",
"componentVersion": "5.6.517",
"isDirectDependency": false
},
{
"componentId": "8217e442-8ed3-41fd-957d-b112e841286a",
"componentName": "@msinternal/sp-telemetry",
"componentVersion": "0.35.58",
"isDirectDependency": false
},
{
"componentId": "0ec74f52-38bc-4a51-ab82-7c91a2b399a2",
"componentName": "@msinternal/sp-fluentui-v9-utilities-bundle",
"componentVersion": "0.1.0",
"isDirectDependency": false
},
{
"componentId": "72ec0651-8a26-441c-87f2-77ec66884ca3",
"componentName": "@msinternal/sp-fluentui-v9-react-button-bundle",
"componentVersion": "0.1.0",
"isDirectDependency": false
},
{
"componentId": "f652a763-4d5f-4a08-aed3-d3377ef835ff",
"componentName": "@msinternal/sp-fluentui-v9-react-menu-bundle",
"componentVersion": "0.1.0",
"isDirectDependency": false
},
{
"componentId": "d3139516-bb4d-4141-9d35-a8aefe0272ec",
"componentName": "@msinternal/sp-fluentui-v9-react-field-bundle",
"componentVersion": "0.1.0",
"isDirectDependency": false
},
{
"componentId": "17f3a86d-33ce-4338-8594-8700ee90c494",
"componentName": "@msinternal/sp-fluentui-v9-react-infolabel-bundle",
"componentVersion": "0.1.0",
"isDirectDependency": false
},
{
"componentId": "33c5a798-8532-4741-888a-d0a7734036b5",
"componentName": "@msinternal/sp-fluentui-v9-react-tooltip-bundle",
"componentVersion": "0.1.0",
"isDirectDependency": false
},
{
"componentId": "29728f96-9823-40fa-a481-ffdf106880b1",
"componentName": "@msinternal/sp-fluentui-v9-react-combobox-bundle",
"componentVersion": "0.1.0",
"isDirectDependency": false
},
{
"componentId": "dbdce381-c024-42a0-ab20-4134aca49a27",
"componentName": "@msinternal/sp-fluentui-v9-react-label-bundle",
"componentVersion": "0.1.0",
"isDirectDependency": false
},
{
"componentId": "396bbc17-3d18-4f43-8299-d99720d88e22",
"componentName": "@ms/office-ui-fabric-react-bundle",
"componentVersion": "1.19.51",
"isDirectDependency": false
},
{
"componentId": "fab65e60-a04f-4772-874c-10691e4afa4b",
"componentName": "@msinternal/sp-fluentui-migration-tooltip-bundle",
"componentVersion": "0.1.0",
"isDirectDependency": false
},
{
"componentId": "0ad7e31a-8df9-4659-add8-a32c8e40040d",
"componentName": "@msinternal/sp-fluentui-migration-button-bundle",
"componentVersion": "0.1.0",
"isDirectDependency": false
},
{
"componentId": "bf211556-3cf0-412c-9618-a4f0e7069ebf",
"componentName": "@msinternal/sp-heading-level",
"componentVersion": "0.1.0",
"isDirectDependency": false
},
{
"componentId": "ffe5c101-373a-4bd2-bebb-13082852bab0",
"componentName": "@msinternal/sp-fluentui-migration-label-bundle",
"componentVersion": "0.1.0",
"isDirectDependency": false
},
{
"componentId": "1c6c9123-7aac-41f3-a376-3caea41ed83f",
"componentName": "@microsoft/sp-loader",
"componentVersion": "1.22.1",
"isDirectDependency": false
},
{
"componentId": "8496636c-2300-4915-abef-20de64c98d8b",
"componentName": "@microsoft/sp-http-base",
"componentVersion": "1.22.1",
"isDirectDependency": false
},
{
"componentId": "1c4541f7-5c31-41aa-9fa8-fbc9dc14c0a8",
"componentName": "@microsoft/sp-page-context",
"componentVersion": "1.22.1",
"isDirectDependency": false
},
{
"componentId": "e40f8203-b39d-425a-a957-714852e33b79",
"componentName": "@microsoft/sp-dynamic-data",
"componentVersion": "1.22.1",
"isDirectDependency": false
},
{
"componentId": "974a7777-0990-4136-8fa6-95d80114c2e0",
"componentName": "@microsoft/sp-webpart-base",
"componentVersion": "1.22.1",
"isDirectDependency": true
},
{
"componentId": "467dc675-7cc5-4709-8aac-78e3b71bd2f6",
"componentName": "@microsoft/sp-component-base",
"componentVersion": "1.22.1",
"isDirectDependency": false
},
{
"componentId": "c07208f0-ea3b-4c1a-9965-ac1b825211a6",
"componentName": "@microsoft/sp-http",
"componentVersion": "1.22.1",
"isDirectDependency": false
},
{
"componentId": "aec2bbfd-a968-4d5b-a279-12e570f378e6",
"componentName": "@microsoft/sp-http-msgraph",
"componentVersion": "1.22.1",
"isDirectDependency": false
},
{
"componentId": "6d3c733c-8cb7-4871-bb22-b5b37c1d518d",
"componentName": "@msinternal/sp-safehtml",
"componentVersion": "0.5.2",
"isDirectDependency": false
},
{
"componentId": "42647dee-1f75-4b9f-b521-5c844a6c35cc",
"componentName": "@msinternal/sp-edit-customer-promise",
"componentVersion": "0.1.0",
"isDirectDependency": false
},
{
"componentId": "c49ba483-7614-4c5c-b9dd-11b45a5fa280",
"componentName": "@msinternal/sp-idle-web-task",
"componentVersion": "0.1.2",
"isDirectDependency": false
}
],
"asyncChunks": {}
}
}
}
@@ -1,80 +0,0 @@
{
"id": "459579c7-954c-469e-b707-ca75d808603f",
"alias": "CtFormsPortalWebPart",
"componentType": "WebPart",
"version": "0.0.1",
"manifestVersion": 2,
"requiresCustomScript": false,
"supportedHosts": [
"SharePointWebPart",
"TeamsPersonalApp",
"TeamsTab",
"SharePointFullPage"
],
"supportsThemeVariants": true,
"supportsFullBleed": true,
"preconfiguredEntries": [
{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70",
"group": {
"default": "Advanced"
},
"title": {
"default": "ct-forms-portal"
},
"description": {
"default": "ct-forms-portal description"
},
"officeFabricIconFontName": "Page",
"properties": {
"description": "ct-forms-portal"
}
}
],
"loaderConfig": {
"internalModuleBaseUrls": [
"<!-- PATH TO CDN -->"
],
"entryModuleId": "ct-forms-portal-web-part",
"scriptResources": {
"ct-forms-portal-web-part": {
"type": "path",
"path": "ct-forms-portal-web-part.js"
},
"react": {
"type": "component",
"id": "0d910c1c-13b9-4e1c-9aa4-b008c5e42d7d",
"version": "17.0.1"
},
"react-dom": {
"type": "component",
"id": "aa0a46ec-1505-43cd-a44a-93f3a5aa460a",
"version": "17.0.1"
},
"@microsoft/sp-core-library": {
"type": "component",
"id": "7263c7d0-1d6a-45ec-8d85-d4d1d234171b",
"version": "1.22.1"
},
"@microsoft/sp-property-pane": {
"type": "component",
"id": "f9e737b7-f0df-4597-ba8c-3060f82380db",
"version": "1.22.1"
},
"@microsoft/sp-webpart-base": {
"type": "component",
"id": "974a7777-0990-4136-8fa6-95d80114c2e0",
"version": "1.22.1"
},
"CtFormsPortalWebPartStrings": {
"type": "path",
"path": "CtFormsPortalWebPartStrings_en-us.js"
},
"@microsoft/sp-http": {
"type": "component",
"id": "c07208f0-ea3b-4c1a-9965-ac1b825211a6",
"version": "1.22.1"
}
}
}
}
+162
View File
@@ -0,0 +1,162 @@
// ============================================
// Tansu Construction — Design Tokens (V5: Executive Premium)
// ЗЕРКАЛО токенов пакета @tnsu/ui-kit (src/tokens/_tokens.scss).
// Источник правды — ui-kit; здесь локальная копия для SPFx-сборки (heft не
// резолвит scss из node_modules через ~). При изменении токенов в ui-kit —
// синхронизировать этот файл. Импортируется в *.module.scss:
// @import '<путь>/styles/design-tokens';
// Без блока :root — чтобы импорт в десятки модулей не дублировал CSS.
// ============================================
// ---- Navy (Primary) ----
$navy-950: #0A1B30;
$navy-900: #0F2440;
$navy-800: #1B3A5C;
$navy-700: #244B73;
$navy-600: #2D5C8A;
$navy-100: #E8EEF4;
$navy-50: #F4F7FA;
// ---- Gold (Accent) ----
$gold-600: #B8881A;
$gold-500: #D4A01E;
$gold-400: #E5B635;
$gold-300: #F0CC5E;
$gold-100: #FDF5E0;
$gold-50: #FEFBF0;
// ---- Gray (Neutral) ----
$gray-900: #111827;
$gray-700: #374151;
$gray-500: #6B7280;
$gray-400: #9CA3AF;
$gray-300: #D1D5DB;
$gray-200: #E5E7EB;
$gray-100: #F3F4F6;
$gray-50: #F9FAFB;
// ---- Semantic ----
$red-500: #DC2626;
$red-50: #FEF2F2;
$green-600: #15803D;
$green-50: #F0FDF4;
$blue-600: #2563EB;
$blue-50: #EFF6FF;
$amber-700: #854D0E;
$amber-50: #FBF1D9; // светлый янтарный фон предупреждений (пара к $amber-700)
$white: #fff;
// ---- Typography ----
$font-serif: 'Lora', Georgia, serif;
$font-sans: 'Inter', -apple-system, 'Segoe UI', sans-serif;
$fs-page-title: 24px;
$fs-form-title: 22px;
$fs-section-title: 15px;
$fs-body: 14px;
$fs-sm: 13px;
$fs-label: 12px;
$fs-caption: 11px;
$fs-overline: 10.5px;
// ---- Radius ----
$r-sm: 6px;
$r-md: 10px;
$r-lg: 14px;
$r-full: 100px;
// ---- Shadows ----
$sh-1: 0 1px 2px rgba(15,36,64,.04), 0 1px 3px rgba(15,36,64,.06);
$sh-2: 0 4px 16px rgba(15,36,64,.07);
$sh-3: 0 12px 32px rgba(15,36,64,.10);
$sh-gold: 0 4px 20px rgba(212,160,30,.18);
$sh-modal: 0 24px 70px rgba(10,27,48,.25);
// ---- Gradients (фирменные) ----
$grad-header: linear-gradient(135deg, $navy-950 0%, $navy-800 85%);
$grad-gold: linear-gradient(135deg, $gold-400, $gold-500);
$grad-gold-v: linear-gradient(180deg, $gold-400, $gold-600);
$grad-hairline: linear-gradient(90deg, $gold-400, transparent 70%);
// ---- Spacing ----
$sp-1: 4px; $sp-2: 8px; $sp-3: 12px;
$sp-4: 16px; $sp-5: 20px; $sp-6: 24px;
$sp-7: 28px; $sp-8: 32px;
// ---- Готовые миксины ----
@mixin page-header {
background: $grad-header;
border-radius: $r-lg;
padding: 26px 30px;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
left: 0; right: 0; bottom: 0;
height: 2px;
background: linear-gradient(90deg, $gold-500, transparent 60%);
}
}
@mixin card-hover-gold {
transition: .22s;
&:hover {
box-shadow: $sh-3;
transform: translateY(-2px);
border-color: $gold-300;
}
}
@mixin btn-accent {
background: $grad-gold;
color: $navy-950;
font-weight: 600;
border: 1px solid $gold-500;
&:hover { box-shadow: $sh-gold; transform: translateY(-1px); }
}
@mixin input-focus-gold {
&:focus {
outline: 0;
border-color: $gold-500;
box-shadow: 0 0 0 3px rgba(212,160,30,.12);
}
}
@mixin readonly-field($sel) {
&#{$sel},
&#{$sel}:focus {
background: $gray-50;
color: $gray-500;
border-color: $gray-200;
box-shadow: none;
cursor: default;
}
}
@mixin hairline {
height: 1px;
background: $grad-hairline;
border: 0;
}
// ---- Брейкпоинты (адаптивность) ----
$bp-xs: 350px;
$bp-sm: 640px;
$bp-md: 768px;
$bp-lg: 1024px;
$bp-xl: 1280px;
@mixin below($bp) {
@media (max-width: $bp) {
@content;
}
}
@mixin above($bp) {
@media (min-width: ($bp + 1)) {
@content;
}
}
@@ -15,3 +15,8 @@
.root { .root {
width: 100%; width: 100%;
} }
// Панель «назад к реестру» над встроенной формой.
.formBack {
padding: 12px 16px 0;
}
@@ -1,17 +1,99 @@
import * as React from 'react'; import * as React from 'react';
import { useState, useEffect } from 'react';
import '@tnsu/ui-kit/styles.css'; import '@tnsu/ui-kit/styles.css';
import { IconSprite } from '@tnsu/ui-kit'; import { IconSprite, Button } from '@tnsu/ui-kit';
import styles from './CtFormsPortal.module.scss'; import styles from './CtFormsPortal.module.scss';
import type { ICtFormsPortalProps } from './ICtFormsPortalProps'; import type { ICtFormsPortalProps } from './ICtFormsPortalProps';
import { Dashboard } from './Dashboard'; import { Dashboard } from './Dashboard';
import { DOCUMENT_TYPES } from './config';
import KpsCtForm from '../forms/kps/components/KpsCtForm';
import CtCreateForm from '../forms/ct/components/CtCreateForm';
import OidCreateForm from '../forms/oid/components/OidCreateForm';
// Корень веб-парта: спрайт иконок (один раз) + дашборд-реестр. interface Route {
// Импорт CtFormsPortal.module.scss обязателен — в нём :global full-width правила канвы. form?: string;
const CtFormsPortal: React.FC<ICtFormsPortalProps> = (props) => ( }
function readRoute(): Route {
return { form: new URLSearchParams(window.location.search).get('form') ?? undefined };
}
// URL-параметры идентификатора формы (чистим при навигации туда-обратно).
const FORM_ID_PARAMS = ['ctId', 'ctid', 'oidId', 'oidid'];
// Корень веб-парта: простой роутер. Без form-параметра — реестр (Dashboard);
// form=kps/ct — форма встроена в портал (сама читает ctId/ctid из URL).
const CtFormsPortal: React.FC<ICtFormsPortalProps> = (props) => {
const [route, setRoute] = useState<Route>(readRoute);
useEffect(() => {
const onPop = (): void => setRoute(readRoute());
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
const navigate = (form: string | undefined, params: Record<string, string> = {}): void => {
const sp = new URLSearchParams(window.location.search);
sp.delete('form');
FORM_ID_PARAMS.forEach((p) => sp.delete(p));
if (form) {
sp.set('form', form);
Object.keys(params).forEach((k) => sp.set(k, params[k]));
}
const qs = sp.toString();
window.history.pushState(undefined, '', qs ? `?${qs}` : window.location.pathname);
setRoute({ form });
};
return (
<div className={styles.root}> <div className={styles.root}>
<IconSprite /> <IconSprite />
<Dashboard context={props.context} /> {route.form === 'kps' ? (
<>
<div className={styles.formBack}>
<Button variant="ghost" onClick={() => navigate(undefined)}>
← К реестру
</Button>
</div>
<KpsCtForm
context={props.context}
userDisplayName={props.userDisplayName}
isDarkTheme={props.isDarkTheme}
siteUrl={DOCUMENT_TYPES.filter((t) => t.typeKey === 'kps')[0]?.siteUrl}
/>
</>
) : route.form === 'ct' ? (
<>
<div className={styles.formBack}>
<Button variant="ghost" onClick={() => navigate(undefined)}>
← К реестру
</Button>
</div>
<CtCreateForm
context={props.context}
userDisplayName={props.userDisplayName}
isDarkTheme={props.isDarkTheme}
siteUrl={DOCUMENT_TYPES.filter((t) => t.typeKey === 'ct')[0]?.siteUrl}
/>
</>
) : route.form === 'oid' ? (
<>
<div className={styles.formBack}>
<Button variant="ghost" onClick={() => navigate(undefined)}>
← К реестру
</Button>
</div>
<OidCreateForm
context={props.context}
userDisplayName={props.userDisplayName}
isDarkTheme={props.isDarkTheme}
/>
</>
) : (
<Dashboard context={props.context} onNavigateForm={navigate} />
)}
</div> </div>
); );
};
export default CtFormsPortal; export default CtFormsPortal;
@@ -45,8 +45,14 @@ function formatDate(iso?: string): string {
return `${dd}.${mm}.${d.getFullYear()}`; return `${dd}.${mm}.${d.getFullYear()}`;
} }
interface IDashboardProps {
context: WebPartContext;
/** Открыть форму внутри портала (для типов с inPortal). Маршрут: form=<typeKey> + params. */
onNavigateForm: (form: string, params: Record<string, string>) => void;
}
// Дашборд-реестр (config-driven, данные из SharePoint). Оболочка и элементы — из @tnsu/ui-kit. // Дашборд-реестр (config-driven, данные из SharePoint). Оболочка и элементы — из @tnsu/ui-kit.
export const Dashboard: React.FC<{ context: WebPartContext }> = ({ context }) => { export const Dashboard: React.FC<IDashboardProps> = ({ context, onNavigateForm }) => {
const { myDocs, approvedDocs, pendingDocs, isLoading, error } = useDocumentDashboard(context, DOCUMENT_TYPES); const { myDocs, approvedDocs, pendingDocs, isLoading, error } = useDocumentDashboard(context, DOCUMENT_TYPES);
const [activeType, setActiveType] = useState<string>(DOCUMENT_TYPES[0].typeKey); const [activeType, setActiveType] = useState<string>(DOCUMENT_TYPES[0].typeKey);
@@ -131,8 +137,8 @@ export const Dashboard: React.FC<{ context: WebPartContext }> = ({ context }) =>
> >
<div className={styles.head}> <div className={styles.head}>
<h2 className={styles.title}>{cfg.typeLabel}</h2> <h2 className={styles.title}>{cfg.typeLabel}</h2>
{cfg.createNewUrl && ( {cfg.inPortal && (
<Button variant="accent" onClick={() => window.open(cfg.createNewUrl, '_blank')}> <Button variant="accent" onClick={() => onNavigateForm(cfg.typeKey, {})}>
{cfg.createNewLabel ?? 'Создать'} {cfg.createNewLabel ?? 'Создать'}
</Button> </Button>
)} )}
@@ -161,7 +167,9 @@ export const Dashboard: React.FC<{ context: WebPartContext }> = ({ context }) =>
columns={columns} columns={columns}
rows={rows} rows={rows}
rowKey={(r) => `${r.typeKey}-${r.id}`} rowKey={(r) => `${r.typeKey}-${r.id}`}
onRowClick={(r) => window.open(cfg.buildLinkUrl(r), '_blank')} onRowClick={(r) =>
cfg.inPortal && onNavigateForm(cfg.typeKey, cfg.buildInPortalParams ? cfg.buildInPortalParams(r) : {})
}
emptyText="Нет документов" emptyText="Нет документов"
/> />
</div> </div>
@@ -48,13 +48,11 @@ export function createCtDocumentConfig(): DocumentTypeConfig {
select: 'Id,Title,number,project,status,Created' select: 'Id,Title,number,project,status,Created'
}) })
}, },
buildLinkUrl: (row) => {
const base = 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица.aspx';
const param = row.status === 'Черновик' ? 'ctId' : 'ctid';
return `${base}?${param}=${row.id}`;
},
createNewUrl: 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица.aspx',
createNewLabel: 'Создать новую форму СТ', createNewLabel: 'Создать новую форму СТ',
// Форма CT встроена в портал (маршрут). Открытие существующей: ctId (черновик) / ctid (иначе)
// — как читает сама форма (см. forms/ct/.../hooks/useCtForm.ts).
inPortal: true,
buildInPortalParams: (row) => ({ [row.status === 'Черновик' ? 'ctId' : 'ctid']: row.id }),
mapItem: (raw): DashboardRow => { mapItem: (raw): DashboardRow => {
const spStatus = String(raw['status'] ?? ''); const spStatus = String(raw['status'] ?? '');
return { return {
@@ -1,7 +1,8 @@
import { DocumentTypeConfig, DashboardRow } from '../registry/documentRegistry'; import { DocumentTypeConfig, DashboardRow } from '../registry/documentRegistry';
const KPS_SITE_URL = 'https://tnsukz.sharepoint.com/sites/kps'; // Миграция на /sites/sed (2026-08-24): списки KPS переехали на общий сайт портала
const KPS_FORM_PAGE = `${KPS_SITE_URL}/SitePages/Сравнительная-таблица.aspx`; // вместе с CT/OID. Data-списки получили суффикс « КПС» (см. LISTS в KpsCtFormService).
const KPS_SITE_URL = 'https://tnsukz.sharepoint.com/sites/sed';
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
'Черновик': 'Сформирована', 'Черновик': 'Сформирована',
@@ -22,41 +23,40 @@ export function createKpsDocumentConfig(): DocumentTypeConfig {
typeKey: 'kps', typeKey: 'kps',
typeLabel: 'СТ КПС', typeLabel: 'СТ КПС',
icon: 'Processing', icon: 'Processing',
// Списки КПС — на отдельном сайт-коллекшене /sites/kps. // Списки КПС — на общем сайте портала /sites/sed (data-списки с суффиксом « КПС»).
siteUrl: KPS_SITE_URL, siteUrl: KPS_SITE_URL,
myDocsListName: 'Сравнительная таблица', myDocsListName: 'Сравнительная таблица КПС',
buildMyDocsFilter: (userId) => `AuthorId eq ${userId}`, buildMyDocsFilter: (userId) => `AuthorId eq ${userId}`,
myDocsSelect: 'Id,Title,number,project,status,Created', myDocsSelect: 'Id,Title,number,project,status,Created',
pendingApproval: { pendingApproval: {
approvalListName: 'Согласование СТ', approvalListName: 'Согласование СТ КПС',
buildPendingFilter: (userId) => `aproover/Id eq ${userId} and status eq 'На согласовании'`, buildPendingFilter: (userId) => `aproover/Id eq ${userId} and status eq 'На согласовании'`,
approvalSelect: 'Id,CTId,status,aproover/Id', approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover', approvalExpand: 'aproover',
parentIdField: 'CTId', parentIdField: 'CTId',
buildParentQuery: (parentIds) => ({ buildParentQuery: (parentIds) => ({
listName: 'Сравнительная таблица', listName: 'Сравнительная таблица КПС',
filter: parentIds.map((id) => `Id eq ${id}`).join(' or '), filter: parentIds.map((id) => `Id eq ${id}`).join(' or '),
select: 'Id,Title,number,project,status,Created' select: 'Id,Title,number,project,status,Created'
}) })
}, },
approvedByMe: { approvedByMe: {
approvalListName: 'Согласование СТ', approvalListName: 'Согласование СТ КПС',
buildPendingFilter: (userId) => `aproover/Id eq ${userId} and status eq 'Согласовано'`, buildPendingFilter: (userId) => `aproover/Id eq ${userId} and status eq 'Согласовано'`,
approvalSelect: 'Id,CTId,status,aproover/Id', approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover', approvalExpand: 'aproover',
parentIdField: 'CTId', parentIdField: 'CTId',
buildParentQuery: (parentIds) => ({ buildParentQuery: (parentIds) => ({
listName: 'Сравнительная таблица', listName: 'Сравнительная таблица КПС',
filter: parentIds.map((id) => `Id eq ${id}`).join(' or '), filter: parentIds.map((id) => `Id eq ${id}`).join(' or '),
select: 'Id,Title,number,project,status,Created' select: 'Id,Title,number,project,status,Created'
}) })
}, },
buildLinkUrl: (row) => {
const param = row.status === 'Черновик' ? 'ctId' : 'ctid';
return `${KPS_FORM_PAGE}?${param}=${row.id}`;
},
createNewUrl: KPS_FORM_PAGE,
createNewLabel: 'Создать новую форму КПС', createNewLabel: 'Создать новую форму КПС',
// Форма KPS встроена в портал (маршрут). Открытие существующей: ctId (черновик) / ctid (иначе)
// — как читает сама форма (см. forms/kps/.../utils/session.ts).
inPortal: true,
buildInPortalParams: (row) => ({ [row.status === 'Черновик' ? 'ctId' : 'ctid']: row.id }),
mapItem: (raw): DashboardRow => { mapItem: (raw): DashboardRow => {
const spStatus = String(raw['status'] ?? ''); const spStatus = String(raw['status'] ?? '');
return { return {
@@ -54,13 +54,11 @@ export function createOidDocumentConfig(): DocumentTypeConfig {
select: 'Id,Title,number,project,status,Created' select: 'Id,Title,number,project,status,Created'
}) })
}, },
buildLinkUrl: (row) => {
const base = 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица-ОИД.aspx';
const param = row.status === 'Черновик' || row.status === 'formed' ? 'oidId' : 'oidid';
return `${base}?${param}=${row.id}`;
},
createNewUrl: 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица-ОИД.aspx',
createNewLabel: 'Создать новую форму ОИД', createNewLabel: 'Создать новую форму ОИД',
// Форма ОИД встроена в портал (маршрут). Открытие существующей: oidId (черновик/сформирована) /
// oidid (иначе) — как читает сама форма (см. forms/oid/.../hooks/useOidForm.ts).
inPortal: true,
buildInPortalParams: (row) => ({ [row.status === 'Черновик' || row.status === 'formed' ? 'oidId' : 'oidid']: row.id }),
mapItem: (raw): DashboardRow => { mapItem: (raw): DashboardRow => {
const spStatus = String(raw['status'] ?? ''); const spStatus = String(raw['status'] ?? '');
return { return {
@@ -46,8 +46,10 @@ export interface DocumentTypeConfig {
/** null = нет вкладки "Согласованные мной" */ /** null = нет вкладки "Согласованные мной" */
approvedByMe: ApprovalSectionConfig | null; approvedByMe: ApprovalSectionConfig | null;
mapItem: (raw: Record<string, unknown>, context: WebPartContext) => DashboardRow; mapItem: (raw: Record<string, unknown>, context: WebPartContext) => DashboardRow;
buildLinkUrl: (row: DashboardRow) => string;
createNewUrl?: string;
createNewLabel?: string; createNewLabel?: string;
columns: ColumnDef[]; columns: ColumnDef[];
/** Форма этого типа встроена в портал (открывается маршрутом), а не по внешней ссылке. */
inPortal?: boolean;
/** URL-параметры для открытия существующей записи внутри портала (напр. {ctId:'53'}). */
buildInPortalParams?: (row: DashboardRow) => Record<string, string>;
} }
@@ -0,0 +1,27 @@
@import '../common/theme';
.root {
background: $surface;
border-bottom: 1px solid $border;
padding: 12px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.projectName {
font-size: 13px;
font-weight: 400;
opacity: 0.85;
margin-left: 8px;
}
.stepperWrap {
width: 30vw;
min-width: 320px;
margin: 0 auto 0 0;
@include below($bp-md) {
width: 100%;
min-width: 0;
}
}
@@ -0,0 +1,72 @@
import * as React from 'react';
import { Stepper, FormHeader, Badge } from '@tnsu/ui-kit';
import { FORM_STEPS, FormStep, CTStatus } from '../types/domain';
import styles from './Breadcrumbs.module.scss';
interface Props {
currentStep: FormStep;
ctNumber?: string;
projectName?: string;
ctStatus?: CTStatus;
completedSteps: FormStep[];
highestStep: FormStep;
onStepClick: (step: FormStep) => void;
}
const STATUS_LABELS: Record<CTStatus, string> = {
draft: 'Черновик',
kp_loading: 'Загрузка КП',
kp_loaded: 'КП загружены',
formed: 'Сформирована',
on_approval: 'На согласовании',
approved: 'Согласована',
rejected: 'Отклонена',
};
const STATUS_VARIANT: Record<CTStatus, 'neutral' | 'info' | 'success' | 'danger'> = {
draft: 'neutral',
kp_loading: 'neutral',
kp_loaded: 'neutral',
formed: 'neutral',
on_approval: 'info',
approved: 'success',
rejected: 'danger',
};
// Адаптер над Stepper/FormHeader из @tnsu/ui-kit (по образцу KPS Breadcrumbs): контракт props формы
// сохранён, внутри — шапка (номер СТ/проект/статус) + степпер шагов.
export const Breadcrumbs: React.FC<Props> = ({
currentStep,
ctNumber,
projectName,
ctStatus,
completedSteps,
highestStep,
onStepClick,
}) => {
return (
<div className={styles.root}>
{(ctNumber || projectName) && (
<FormHeader
title={
<>
{ctNumber && <>Сравнительная таблица №{ctNumber}</>}
{projectName && <span className={styles.projectName}>{projectName}</span>}
</>
}
right={ctStatus && <Badge variant={STATUS_VARIANT[ctStatus]}>{STATUS_LABELS[ctStatus]}</Badge>}
/>
)}
<div className={styles.stepperWrap}>
<Stepper
steps={FORM_STEPS.map(s => ({ id: s.step, label: s.label }))}
currentId={currentStep}
completedIds={completedSteps}
highestId={highestStep}
onStepClick={id => onStepClick(id as FormStep)}
/>
</div>
</div>
);
};
@@ -0,0 +1,44 @@
@import './common/theme';
@import '~@fluentui/react/dist/sass/References.scss';
:global {
#workbenchPageContent,
.CanvasComponent.LCS .CanvasZone {
max-width: 100% !important;
}
}
.root {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $head-bg;
color: "[theme:bodyText, default: #111827]";
color: var(--bodyText);
font-family: $ms-font-family-fallbacks;
font-size: 14px;
line-height: 1.5;
*,
*::before,
*::after {
box-sizing: border-box;
}
}
.content {
flex: 1;
background: $surface;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
min-height: 0;
}
// Dark theme override — inherit from SharePoint's theme tokens
.dark {
background: #1b1a19;
color: $hover;
.content {
background: #252423;
}
}
@@ -0,0 +1,267 @@
import * as React from 'react';
import { useMemo, useEffect, useState } from 'react';
import type { ICtCreateFormProps } from './ICtCreateFormProps';
import { CtService } from '../services/CtService';
import { useCtForm } from './hooks/useCtForm';
import { Breadcrumbs } from './Breadcrumbs/Breadcrumbs';
import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSelection';
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
import { Step4Approval } from './Step4Approval/Step4Approval';
import { ApprovalMember, CTStatus } from './types/domain';
import styles from './CtCreateForm.module.scss';
import '@tnsu/ui-kit/styles.css';
function deriveCtStatus(chain: ApprovalMember[]): CTStatus {
if (chain.length === 0) return 'formed';
if (chain.some(m => m.status === 'rejected')) return 'rejected';
if (chain.every(m => m.status === 'approved' || m.status === 'recommends' || m.status === 'skipped')) return 'approved';
// Chain is activated only when the initiator has 'recommends' (Рекомендую).
// All-pending means chain was saved as draft but not yet activated via Step 4.
if (chain.some(m => m.status === 'recommends')) return 'on_approval';
return 'formed';
}
const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName, isDarkTheme, siteUrl }) => {
const service = useMemo(() => new CtService(context, siteUrl), [context, siteUrl]);
const [isRestrictedView, setIsRestrictedView] = useState(false);
const {
currentStep,
formData,
initialCtId,
isNotificationLink,
goToStep,
goNext,
goPrev,
updateFormData,
completedSteps,
markStepComplete,
highestStep,
ensureHighestStep,
} = useCtForm();
// Цены показываем только участникам согласования: рекомендующему (Инициатор) и согласующим.
// Автор/инициатор при создании и редактировании СТ (до отправки на согласование) видит цены всегда.
// astel-k (isRestrictedView) — независимое ограничение, складывается с этим: если его уберут,
// правило по цепочке согласования останется. Сопоставление участника — по отображаемому имени
// (пока тестируем; при необходимости переключим на ID).
const userInApprovalChain = (formData.approvalChain ?? []).some(m => m.userName === userDisplayName);
const hidePrices = isRestrictedView || (formData.approvalSent === true && !userInApprovalChain);
useEffect(() => {
service.getCurrentUserProfile()
.then(profile => updateFormData({ initiatorDepartment: profile.department }))
.catch(() => undefined);
service.getCurrentUserCompany()
.then(company => setIsRestrictedView(company.toLowerCase() === 'astel-k'))
.catch(() => undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load existing CT from SP when opened via notification link (?ctid=N)
// Always reloads from SP to get fresh approval statuses
useEffect(() => {
if (!initialCtId) return;
Promise.all([
service.loadCTById(initialCtId),
service.loadApprovalChain(initialCtId),
service.loadPositionsForCT(initialCtId),
]).then(async ([ct, approvalChain, positions]) => {
const positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined);
const rawOffers = await service.loadFullOffersForPositions(positionSpIds);
// Build supplierSpId → offerId map from session uploadedKpFiles.
// offerId cannot be read back via SP REST (500), so we restore it from session.
// formData here is the initial mount value (session state before this update).
const offerIdBySupplierSpId = new Map<number, number>();
for (const kpFile of (formData.uploadedKpFiles ?? [])) {
if (!kpFile.spId || !kpFile.supplierName) continue;
const sup = (formData.suppliers ?? []).find(s => s.name === kpFile.supplierName);
if (sup && sup.spId) offerIdBySupplierSpId.set(sup.spId, kpFile.spId);
}
// Load currency rates for the valutaIds referenced by offers (separate query avoids
// SP REST 400 that occurs when selecting a Choice field via $expand on a lookup).
const valutaIds = rawOffers
.map(o => o.valutaSpId)
.filter((id): id is number => id !== undefined && id > 0)
.filter((id, idx, arr) => arr.indexOf(id) === idx);
const loadedRates = await service.loadCurrencyRatesByIds(valutaIds);
const rateById = new Map(loadedRates.map(r => [Number(r.id), r]));
const spCurrencyRates = loadedRates;
// Build unique suppliers from offers (used only as fallback when session has none)
const supplierCurrencyMap = new Map<number, string>();
rawOffers.forEach(o => {
supplierCurrencyMap.set(o.supplierSpId, o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT');
});
const supplierMap = new Map<number, string>();
rawOffers.forEach(o => { if (!supplierMap.has(o.supplierSpId)) supplierMap.set(o.supplierSpId, o.supplierName); });
const spSuppliers = Array.from(supplierMap.entries()).map(([spId, name]) => ({
id: String(spId), spId, name, hasOffer: true as const,
currency: supplierCurrencyMap.get(spId) ?? 'KZT',
}));
// Build step3DeliveryKm from SP offers. Step 3 reads km by React-id key
// (`${pos.id}::${s.id}`), so map SP ids → React ids; for delivery the position is an
// extra (step3ExtraPositions) whose React id never equals its SP id, hence the lookup.
// Fallback to String(spId) keeps the no-session / notification-link case working
// (there the table uses String(spId) ids for SP-loaded positions/suppliers).
const posReactIdBySpId = new Map<number, string>();
[...formData.selectedPositions, ...(formData.step3ExtraPositions ?? [])].forEach(p => {
if (p.spId !== undefined) posReactIdBySpId.set(p.spId, p.id);
});
const supReactIdBySpId = new Map<number, string>();
formData.suppliers.forEach(s => {
if (s.spId !== undefined) supReactIdBySpId.set(s.spId, s.id);
});
const step3DeliveryKm: Record<string, number> = {};
rawOffers.forEach(o => {
if (o.deliveryDistanceKm !== undefined) {
const posKey = posReactIdBySpId.get(o.positionSpId) ?? String(o.positionSpId);
const supKey = supReactIdBySpId.get(o.supplierSpId) ?? String(o.supplierSpId);
step3DeliveryKm[`${posKey}::${supKey}`] = o.deliveryDistanceKm;
}
});
// Build offers in domain format, restoring offerId from session
const offers = rawOffers.map(o => {
const offerIdNum = offerIdBySupplierSpId.get(o.supplierSpId);
const currencyCode = o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT';
return {
id: '',
supplierId: String(o.supplierSpId),
positionId: String(o.positionSpId),
unit: o.unit,
pricePerUnit: o.price,
quantity: o.quantity,
currencyCode,
pricePerUnitKzt: o.priceTenge || o.price,
totalPriceKzt: o.summaTenge,
totalWithExtraKzt: o.summaTenge,
offerId: offerIdNum !== undefined ? String(offerIdNum) : undefined,
};
});
// Reconstruct uploadedKpFiles for users without session (other users opening CT via link)
let uploadedKpFiles = formData.uploadedKpFiles;
if (isNotificationLink || uploadedKpFiles.length === 0) {
const kpFilesRaw = await service.loadKpFilesForOffers(positionSpIds);
if (kpFilesRaw.length > 0) {
uploadedKpFiles = kpFilesRaw.map(f => ({
spId: f.spId,
name: f.name,
supplierName: supplierMap.get(f.supplierSpId) ?? '',
}));
}
}
const derivedStatus = deriveCtStatus(approvalChain);
updateFormData({
ct: { ...ct, status: derivedStatus },
approvalChain,
// Preserve session selectedPositions — overwriting changes pos.id (Запросы ID → Позиции СТ ID),
// which breaks edits keys built from buildOfferKey(sup.id, pos.id) in Step 3.
...(isNotificationLink || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}),
// Preserve session suppliers and offers — SP data may be incomplete or stale vs. unsaved edits.
// Only use SP-derived data when session has none, or when opened via notification link (always fresh).
...(formData.suppliers.length === 0 ? { suppliers: spSuppliers } : {}),
...(isNotificationLink || formData.offers.length === 0 ? { offers } : {}),
...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}),
...(isNotificationLink ? { approvalSent: true } : {}),
...(uploadedKpFiles.length > 0 ? { uploadedKpFiles } : {}),
...(Object.keys(step3DeliveryKm).length > 0 ? { step3DeliveryKm } : {}),
});
if (isNotificationLink) {
// Teams notification link — always show Step 3 (comparison table + approval chain)
goToStep(3);
ensureHighestStep(4);
markStepComplete(3);
markStepComplete(4);
} else if (approvalChain.length > 0) {
// Regular reload — make Step 4 accessible; only mark complete if CT is fully resolved
ensureHighestStep(4);
if (derivedStatus === 'approved' || derivedStatus === 'rejected') {
markStepComplete(4);
}
}
}).catch(() => undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const renderStep = (): React.ReactElement => {
switch (currentStep) {
case 1:
return (
<Step1PositionSelection
service={service}
formData={formData}
onSave={updateFormData}
onNext={goNext}
onStepComplete={markStepComplete}
isReadOnly={formData.ct?.status === 'on_approval' || formData.ct?.status === 'approved' || formData.ct?.status === 'rejected'}
/>
);
case 2:
return (
<Step2CommercialOffers
service={service}
formData={formData}
userDisplayName={userDisplayName}
isNotificationLink={isNotificationLink}
isReadOnly={formData.ct?.status === 'on_approval' || formData.ct?.status === 'approved' || formData.ct?.status === 'rejected'}
isRestrictedView={hidePrices}
onSave={updateFormData}
onNext={goNext}
onPrev={goPrev}
onStepComplete={markStepComplete}
/>
);
case 3:
return (
<Step3ComparisonTable
service={service}
formData={formData}
userDisplayName={userDisplayName}
isNotificationLink={isNotificationLink}
isRestrictedView={hidePrices}
onSave={updateFormData}
onNext={goNext}
onPrev={goPrev}
onStepComplete={markStepComplete}
/>
);
case 4:
return (
<Step4Approval
service={service}
formData={formData}
userDisplayName={userDisplayName}
onSave={updateFormData}
onPrev={goPrev}
onStepComplete={markStepComplete}
/>
);
}
};
return (
<div className={[styles.root, isDarkTheme ? styles.dark : ''].filter(Boolean).join(' ')}>
<Breadcrumbs
currentStep={currentStep}
ctNumber={formData.ct?.number}
projectName={formData.selectedProject?.name}
ctStatus={formData.ct?.status}
completedSteps={completedSteps}
highestStep={highestStep}
onStepClick={goToStep}
/>
<main className={styles.content}>
{renderStep()}
</main>
</div>
);
};
export default CtCreateForm;
@@ -0,0 +1,9 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
export interface ICtCreateFormProps {
context: WebPartContext;
userDisplayName: string;
isDarkTheme: boolean;
/** URL сайта со списками CT, когда форма встроена в портал на другом сайте. Не задан — текущий сайт. */
siteUrl?: string;
}
@@ -0,0 +1,166 @@
@import '../common/theme';
.tableWrapper {
overflow-x: auto;
border: 1px solid $border;
border-radius: 4px;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
background: $head-bg;
color: $text;
font-weight: 600;
padding: 10px 12px;
text-align: left;
border-bottom: 2px solid $border;
white-space: nowrap;
position: sticky;
top: 0;
z-index: 1;
}
td {
padding: 8px 12px;
border-bottom: 1px solid $hover;
vertical-align: middle;
color: $text;
}
}
.requestRow {
background: $surface;
cursor: default;
&:hover td {
background: $hover;
}
&.rowExpanded td {
background: $head-bg;
}
}
.rowLocked {
opacity: 0.45;
pointer-events: none;
}
.positionRow {
background: #fefefe;
td {
padding-left: 20px;
font-size: 12px;
color: $muted;
}
&:hover td {
background: $hover;
}
&.positionSelected td {
background: $accent-bg;
color: $accent-dark;
}
}
.checkboxCell {
width: 40px;
text-align: center;
}
.expandCell {
width: 32px;
text-align: center;
}
.expandBtn {
width: 24px;
height: 24px;
border: none;
background: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2px;
color: $muted;
padding: 0;
&:hover {
background: $border;
color: $text;
}
svg {
width: 14px;
height: 14px;
}
}
.requestNumber {
font-weight: 600;
color: $accent;
}
.materialName {
font-weight: 500;
}
.qty {
white-space: nowrap;
}
.dateCell {
white-space: nowrap;
color: $muted;
}
.countCell {
text-align: center;
}
.positionCount {
display: inline-block;
background: $border;
color: $text;
border-radius: 10px;
padding: 1px 8px;
font-size: 11px;
font-weight: 600;
min-width: 20px;
text-align: center;
}
.selectionBadge {
display: inline-block;
background: $accent;
color: $surface;
border-radius: 10px;
padding: 1px 7px;
font-size: 11px;
font-weight: 600;
min-width: 20px;
text-align: center;
}
.lockedHint {
td {
padding: 4px 12px;
font-size: 11px;
color: $muted-2;
font-style: italic;
border: none;
}
}
.empty {
padding: 48px 24px;
text-align: center;
color: $muted;
font-size: 14px;
}
@@ -0,0 +1,179 @@
import * as React from 'react';
import { useState, useCallback } from 'react';
import { Checkbox } from '@tnsu/ui-kit';
import { Request, Position } from '../types/domain';
import styles from './RequestsTable.module.scss';
interface Props {
requests: Request[];
selectedPositionIds: Set<string>;
lockedProjectId: string | undefined;
onSelectionChange: (positionId: string, position: Position, request: Request, checked: boolean) => void;
onRequestSelectionChange: (request: Request, checked: boolean) => void;
isReadOnly?: boolean;
}
function formatDate(iso: string): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
export const RequestsTable: React.FC<Props> = ({
requests,
selectedPositionIds,
lockedProjectId,
onSelectionChange,
onRequestSelectionChange,
isReadOnly = false,
}) => {
const [expandedRequestIds, setExpandedRequestIds] = useState<Set<string>>(new Set());
const toggleExpand = useCallback((id: string) => {
setExpandedRequestIds(prev => {
const next = new Set(prev);
if (next.has(id)) { next.delete(id); } else { next.add(id); }
return next;
});
}, []);
const getRequestSelectionCount = (request: Request): number =>
request.positions.filter(p => selectedPositionIds.has(p.id)).length;
const isProjectLocked = (request: Request): boolean =>
lockedProjectId !== undefined && request.project.id !== lockedProjectId;
if (requests.length === 0) {
return (
<div className={styles.empty}>
<p>Потребности не найдены за указанный период</p>
</div>
);
}
return (
<div className={styles.tableWrapper}>
<table className={styles.table} role="treegrid">
<thead>
<tr>
<th style={{ width: 40 }} />
<th style={{ width: 32 }} />
<th>№ потребности</th>
<th>Проект</th>
<th>Раздел</th>
<th>Вид работ</th>
<th>Организация</th>
<th>Дата создания</th>
<th>Позиций</th>
</tr>
</thead>
<tbody>
{requests.map(request => {
const isExpanded = expandedRequestIds.has(request.id);
const selCount = getRequestSelectionCount(request);
const totalCount = request.positions.length;
const locked = isProjectLocked(request);
return (
<React.Fragment key={request.id}>
<tr
className={[
styles.requestRow,
locked ? styles.rowLocked : '',
isExpanded ? styles.rowExpanded : '',
]
.filter(Boolean)
.join(' ')}
aria-expanded={isExpanded}
>
<td className={styles.checkboxCell} onClick={e => e.stopPropagation()}>
<Checkbox
checked={selCount === totalCount && totalCount > 0}
indeterminate={selCount > 0 && selCount < totalCount}
disabled={locked || isReadOnly || totalCount === 0}
onChange={checked => onRequestSelectionChange(request, checked)}
ariaLabel={`Выбрать все позиции потребности ${request.requestNumber}`}
/>
</td>
<td className={styles.expandCell}>
{request.positions.length > 0 && (
<button
className={styles.expandBtn}
onClick={() => toggleExpand(request.id)}
aria-label={isExpanded ? 'Свернуть' : 'Развернуть'}
>
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d={isExpanded ? 'M4 6l4 4 4-4' : 'M6 4l4 4-4 4'}
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
</td>
<td className={styles.requestNumber}>{request.requestNumber}</td>
<td>{request.project.name}</td>
<td>{request.discipline || '—'}</td>
<td>{request.workType || '—'}</td>
<td>{request.organization || '—'}</td>
<td className={styles.dateCell}>{formatDate(request.createdAt)}</td>
<td className={styles.countCell}>
<span className={styles.positionCount}>{request.positions.length}</span>
</td>
</tr>
{isExpanded &&
request.positions.map(position => {
const isSelected = selectedPositionIds.has(position.id);
return (
<tr
key={position.id}
className={[
styles.positionRow,
isSelected ? styles.positionSelected : '',
locked ? styles.rowLocked : '',
]
.filter(Boolean)
.join(' ')}
role="row"
aria-selected={isSelected}
>
<td className={styles.checkboxCell} onClick={e => e.stopPropagation()}>
<Checkbox
checked={isSelected}
disabled={locked || isReadOnly}
onChange={checked => onSelectionChange(position.id, position, request, checked)}
ariaLabel={`Выбрать позицию: ${position.materialName}`}
/>
</td>
<td />
<td colSpan={2} className={styles.materialName}>
{position.materialName}
</td>
<td className={styles.qty}>
{position.quantity} {position.unit}
</td>
<td>{position.smrType || '—'}</td>
<td colSpan={2}>{formatDate(position.deadline)}</td>
<td />
</tr>
);
})}
{locked && (
<tr className={styles.lockedHint}>
<td colSpan={9}>
<span>Позиции из другого проекта недоступны для выбора</span>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
);
};
@@ -0,0 +1,76 @@
@import '../common/theme';
.root {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
min-height: 400px;
}
.toolbar {
display: flex;
flex-direction: column;
gap: 12px;
}
.filterRow {
display: flex;
align-items: flex-end;
gap: 12px;
}
.daysInput {
width: 220px;
}
.searchInput {
width: 280px;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 8px 0;
}
.pageInfo {
font-size: 13px;
color: $text;
min-width: 80px;
text-align: center;
}
.loaderWrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 64px 0;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 16px;
border-top: 1px solid $border;
gap: 16px;
flex-wrap: wrap;
}
.selectionSummary {
font-size: 13px;
color: $text;
}
.noSelection {
color: $muted-2;
font-style: italic;
}
.actions {
display: flex;
align-items: center;
gap: 12px;
}
@@ -0,0 +1,287 @@
import * as React from 'react';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { Button, Alert, Spinner, TextInput, FormField, IconButton } from '@tnsu/ui-kit';
import { Request, Position, FormStep } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { useRequests } from '../hooks/useRequests';
import { RequestsTable } from './RequestsTable';
import styles from './Step1PositionSelection.module.scss';
interface Props {
service: CtService;
formData: CTFormData;
onSave: (partial: Partial<CTFormData>) => void;
onNext: () => void;
onStepComplete: (step: FormStep) => void;
isReadOnly?: boolean;
}
const DEFAULT_DAYS = 5;
const PAGE_SIZE = 10;
export const Step1PositionSelection: React.FC<Props> = ({
service,
formData,
onSave,
onNext,
onStepComplete,
isReadOnly = false,
}) => {
const [days, setDays] = useState(DEFAULT_DAYS);
const [daysInput, setDaysInput] = useState(String(DEFAULT_DAYS));
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [selectedPositionIds, setSelectedPositionIds] = useState<Set<string>>(
new Set(formData.selectedPositions.map(p => p.id))
);
const [positionMap, setPositionMap] = useState<Map<string, { position: Position; request: Request }>>(new Map());
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined);
const { requests, isLoading, error: loadError, reload } = useRequests(service, days, isReadOnly);
const readOnlyRequests = useMemo(() => {
if (!isReadOnly) return [];
const byRequestId = new Map<string, Position[]>();
formData.selectedPositions.forEach(p => {
const group = byRequestId.get(p.requestId) ?? [];
group.push(p);
byRequestId.set(p.requestId, group);
});
return Array.from(byRequestId.entries()).map(([requestId, positions]) => ({
id: requestId,
requestNumber: positions[0].requestNumber,
project: formData.selectedProject ?? { id: '', name: '', code: '' },
discipline: positions[0].disciplineCode,
workType: positions[0].smrType,
organization: positions[0].organization ?? '',
createdAt: positions[0].requestCreatedAt ?? '',
positions,
}));
}, [isReadOnly, formData.selectedPositions, formData.selectedProject]);
const filteredRequests = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
const filtered = q ? requests.filter(r => r.requestNumber.toLowerCase().includes(q)) : requests;
return [...filtered].sort((a, b) => {
const aSelected = a.positions.some(p => selectedPositionIds.has(p.id)) ? 0 : 1;
const bSelected = b.positions.some(p => selectedPositionIds.has(p.id)) ? 0 : 1;
return aSelected - bSelected;
});
}, [requests, searchQuery, selectedPositionIds]);
const totalPages = Math.max(1, Math.ceil(filteredRequests.length / PAGE_SIZE));
const safePage = Math.min(currentPage, totalPages);
const pagedRequests = isReadOnly ? readOnlyRequests : filteredRequests.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
// Build position→request map for resolving selection metadata
useEffect(() => {
const map = new Map<string, { position: Position; request: Request }>();
requests.forEach(req => {
req.positions.forEach(pos => {
map.set(pos.id, { position: pos, request: req });
});
});
setPositionMap(map);
}, [requests]);
const lockedProjectId = formData.selectedProject?.id;
const handleSelectionChange = useCallback(
(positionId: string, _position: Position, request: Request, checked: boolean) => {
if (checked && lockedProjectId !== undefined && request.project.id !== lockedProjectId) return;
setSelectedPositionIds(prev => {
const next = new Set(prev);
if (checked) { next.add(positionId); } else { next.delete(positionId); }
return next;
});
if (checked && lockedProjectId === undefined) {
onSave({ selectedProject: request.project });
}
// Release project lock when last position is deselected
if (!checked && selectedPositionIds.size === 1 && selectedPositionIds.has(positionId)) {
onSave({ selectedProject: undefined });
}
},
[lockedProjectId, onSave, selectedPositionIds]
);
const handleRequestSelectionChange = useCallback(
(request: Request, checked: boolean) => {
if (request.positions.length === 0) return;
if (checked && lockedProjectId !== undefined && request.project.id !== lockedProjectId) return;
setSelectedPositionIds(prev => {
const next = new Set(prev);
request.positions.forEach(p => checked ? next.add(p.id) : next.delete(p.id));
return next;
});
if (checked && lockedProjectId === undefined) {
onSave({ selectedProject: request.project });
}
if (!checked) {
const remainingAfter = Array.from(selectedPositionIds).filter(
id => !request.positions.some(p => p.id === id)
);
if (remainingAfter.length === 0) onSave({ selectedProject: undefined });
}
},
[lockedProjectId, onSave, selectedPositionIds]
);
const handleDaysChange = (val: string): void => {
setDaysInput(val);
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0 && parsed <= 365) {
setDays(parsed);
setCurrentPage(1);
}
};
const handleSearchChange = (val: string): void => {
setSearchQuery(val);
setCurrentPage(1);
};
const selectedPositions: Position[] = Array.from(selectedPositionIds)
.map(id => positionMap.get(id)?.position)
.filter((p): p is Position => p !== undefined);
const selectedProject = selectedPositions.length > 0
? positionMap.get(selectedPositions[0].id)?.request.project ?? null
: null;
const canSave = selectedPositions.length > 0;
const handleSave = async (): Promise<void> => {
if (!selectedProject || selectedPositions.length === 0) return;
setIsSaving(true);
setSaveError(undefined);
try {
const ctNumber = await service.generateCtNumber(selectedProject.name);
const ct = await service.createCT(selectedProject.name, ctNumber);
const enrichedPositions = selectedPositions.map(p => ({
...p,
disciplineCode: p.disciplineCode || (positionMap.get(p.id)?.request.discipline ?? ''),
}));
const savedPositions = await service.createCTPositions(ct.id, enrichedPositions);
const initiatorCompany = positionMap.get(selectedPositions[0].id)?.request.organization ?? '';
onSave({ ct, selectedProject, selectedPositions: savedPositions, initiatorCompany });
onStepComplete(1);
onNext();
} catch (err) {
setSaveError((err as Error).message ?? 'Ошибка сохранения');
} finally {
setIsSaving(false);
}
};
return (
<div className={styles.root}>
{!isReadOnly && (
<div className={styles.toolbar}>
<div className={styles.filterRow}>
<div className={styles.daysInput}>
<FormField label="Потребности за последние (дней)">
<TextInput value={daysInput} onChange={handleDaysChange} type="number" min={1} max={365} />
</FormField>
</div>
<div className={styles.searchInput}>
<FormField label="Поиск по номеру потребности">
<TextInput value={searchQuery} onChange={handleSearchChange} placeholder="Например: 081/26-04/0017" />
</FormField>
</div>
<Button variant="secondary" onClick={reload} disabled={isLoading}>
Обновить
</Button>
<Button variant="secondary" onClick={() => window.open('https://tnsukz.sharepoint.com/sites/sed', '_blank')}>
Мои СТ
</Button>
</div>
{formData.selectedProject && (
<Alert variant="info">
Выбран проект: <strong>{formData.selectedProject.name}</strong> — позиции из других проектов недоступны
</Alert>
)}
</div>
)}
{loadError && <Alert variant="err">{loadError}</Alert>}
{isLoading ? (
<div className={styles.loaderWrapper}>
<Spinner size="large" label="Загрузка потребностей..." />
</div>
) : (
<>
<RequestsTable
requests={pagedRequests}
selectedPositionIds={selectedPositionIds}
lockedProjectId={lockedProjectId}
onSelectionChange={handleSelectionChange}
onRequestSelectionChange={handleRequestSelectionChange}
isReadOnly={isReadOnly}
/>
{!isReadOnly && totalPages > 1 && (
<div className={styles.pagination}>
<IconButton
icon="chevron-left"
disabled={safePage === 1}
onClick={() => setCurrentPage(1)}
title="Первая страница"
/>
<IconButton
icon="chevron-left"
disabled={safePage === 1}
onClick={() => setCurrentPage(p => p - 1)}
title="Предыдущая страница"
/>
<span className={styles.pageInfo}>{safePage} / {totalPages}</span>
<IconButton
icon="chevron-right"
disabled={safePage === totalPages}
onClick={() => setCurrentPage(p => p + 1)}
title="Следующая страница"
/>
<IconButton
icon="chevron-right"
disabled={safePage === totalPages}
onClick={() => setCurrentPage(totalPages)}
title="Последняя страница"
/>
</div>
)}
</>
)}
<div className={styles.footer}>
<div className={styles.selectionSummary}>
{selectedPositions.length > 0 ? (
<span>
Выбрано позиций: <strong>{selectedPositions.length}</strong>
{selectedProject && <> · Проект: <strong>{selectedProject.name}</strong></>}
</span>
) : (
<span className={styles.noSelection}>Выберите позиции для формирования СТ</span>
)}
</div>
{!isReadOnly && (
<div className={styles.actions}>
{saveError && <Alert variant="err">{saveError}</Alert>}
<Button variant="accent" onClick={handleSave} disabled={!canSave || isSaving}>
{isSaving ? 'Сохранение…' : 'Сохранить и продолжить'}
</Button>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,102 @@
@import '../common/theme';
.root {
display: flex;
flex-direction: column;
gap: 14px;
}
.hint {
font-size: 13px;
color: $muted;
margin: 0;
}
.presets {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.presetsLabel {
font-size: 13px;
color: $text;
font-weight: 500;
}
.presetBtn {
font-size: 12px;
height: 28px;
}
.ratesTable {
border-collapse: collapse;
font-size: 13px;
width: auto;
align-self: flex-start;
th {
text-align: left;
padding: 6px 12px;
color: $muted;
font-weight: 600;
border-bottom: 1px solid $border;
}
td {
padding: 6px 12px;
vertical-align: middle;
}
tr:hover td {
background: $head-bg;
}
}
.currencyCode {
display: inline-block;
background: $border;
color: $text;
border-radius: 3px;
padding: 2px 6px;
font-family: monospace;
font-size: 12px;
font-weight: 600;
}
.rateInput {
width: 160px;
display: flex;
align-items: center;
gap: 6px;
}
.rateInputField {
flex: 1;
min-width: 0;
}
.rateInputSuffix {
font-size: 12px;
color: $muted;
flex-shrink: 0;
}
.empty {
font-size: 13px;
color: $muted-2;
font-style: italic;
margin: 0;
}
.nbkRow {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.nbkError {
flex: 1;
min-width: 0;
}
@@ -0,0 +1,175 @@
import * as React from 'react';
import { useCallback, useState } from 'react';
import { Button, Alert, TextInput, IconButton, Spinner } from '@tnsu/ui-kit';
import { CurrencyRate } from '../types/domain';
import styles from './CurrencyRatesForm.module.scss';
interface Props {
rates: CurrencyRate[];
onChange: (rates: CurrencyRate[]) => void;
readOnly?: boolean;
}
const PRESET_CURRENCIES: Omit<CurrencyRate, 'rateToKzt'>[] = [
{ code: 'USD', name: 'Доллар США' },
{ code: 'EUR', name: 'Евро' },
{ code: 'RUB', name: 'Российский рубль' },
{ code: 'CNY', name: 'Китайский юань' },
];
export const CurrencyRatesForm: React.FC<Props> = ({ rates, onChange, readOnly = false }) => {
const [isNbkLoading, setIsNbkLoading] = useState(false);
const [nbkError, setNbkError] = useState<string | undefined>(undefined);
const fetchNbkRates = useCallback(async () => {
if (rates.length === 0) return;
setIsNbkLoading(true);
setNbkError(undefined);
try {
const res = await fetch('https://open.er-api.com/v6/latest/USD');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.result !== 'success') throw new Error('Ошибка API');
const usdToKzt: number = data.rates['KZT'];
if (!usdToKzt) throw new Error('Курс KZT не найден');
const updated = rates.map(r => {
const rateToUsd: number = data.rates[r.code];
if (!rateToUsd) return r;
const rateToKzt = r.code === 'USD'
? Math.round(usdToKzt * 100) / 100
: Math.round((usdToKzt / rateToUsd) * 100) / 100;
return { ...r, rateToKzt };
});
onChange(updated);
} catch {
setNbkError('Не удалось получить курсы. Проверьте подключение к интернету.');
} finally {
setIsNbkLoading(false);
}
}, [rates, onChange]);
const addCurrency = useCallback(
(preset: Omit<CurrencyRate, 'rateToKzt'>) => {
if (rates.some(r => r.code === preset.code)) return;
onChange([...rates, { ...preset, rateToKzt: 0 }]);
},
[rates, onChange]
);
const updateRate = useCallback(
(code: string, value: string) => {
const parsed = parseFloat(value.replace(',', '.'));
onChange(
rates.map(r => (r.code === code ? { ...r, rateToKzt: isNaN(parsed) ? 0 : parsed } : r))
);
},
[rates, onChange]
);
const removeRate = useCallback(
(code: string) => {
onChange(rates.filter(r => r.code !== code));
},
[rates, onChange]
);
const presetsToShow = PRESET_CURRENCIES.filter(p => !rates.some(r => r.code === p.code));
return (
<div className={styles.root}>
{!readOnly && (
<p className={styles.hint}>
Укажите курсы валют к тенге на дату формирования СТ. Если все КП в тенге, оставьте пустым.
</p>
)}
{!readOnly && presetsToShow.length > 0 && (
<div className={styles.presets}>
<span className={styles.presetsLabel}>Добавить валюту:</span>
{presetsToShow.map(p => (
<Button
key={p.code}
variant="secondary"
onClick={() => addCurrency(p)}
className={styles.presetBtn}
>
{p.code} — {p.name}
</Button>
))}
</div>
)}
{!readOnly && rates.length > 0 && (
<div className={styles.nbkRow}>
<Button
variant="secondary"
onClick={fetchNbkRates}
disabled={isNbkLoading}
>
{isNbkLoading
? <><Spinner size="small" />&nbsp;Загрузка…</>
: 'Получить актуальный курс'}
</Button>
{nbkError && (
<div className={styles.nbkError}>
<Alert variant="err">{nbkError}</Alert>
</div>
)}
</div>
)}
{rates.length > 0 && (
<table className={styles.ratesTable}>
<thead>
<tr>
<th>Валюта</th>
<th>Код</th>
<th>Курс к KZT</th>
{!readOnly && <th />}
</tr>
</thead>
<tbody>
{rates.map(rate => (
<tr key={rate.code}>
<td>{rate.name}</td>
<td>
<span className={styles.currencyCode}>{rate.code}</span>
</td>
<td>
{readOnly ? (
<span>{rate.rateToKzt === 0 ? '—' : `${rate.rateToKzt} KZT`}</span>
) : (
<div className={styles.rateInput}>
<div className={styles.rateInputField}>
<TextInput
value={rate.rateToKzt === 0 ? '' : String(rate.rateToKzt)}
onChange={val => updateRate(rate.code, val)}
placeholder="0.00"
ariaLabel={`Курс ${rate.code} к KZT`}
/>
</div>
<span className={styles.rateInputSuffix}>KZT</span>
</div>
)}
</td>
{!readOnly && (
<td>
<IconButton
icon="trash"
onClick={() => removeRate(rate.code)}
title={`Удалить валюту ${rate.code}`}
/>
</td>
)}
</tr>
))}
</tbody>
</table>
)}
{rates.length === 0 && (
<p className={styles.empty}>Все предложения в тенге (KZT)</p>
)}
</div>
);
};
@@ -0,0 +1,186 @@
@import '../common/theme';
.root {
display: flex;
flex-direction: column;
gap: 16px;
}
.dropZone {
border: 2px dashed $border-strong;
border-radius: 8px;
padding: 40px 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
background: $head-bg;
&:hover,
&:focus {
border-color: $accent;
background: $accent-bg;
outline: none;
}
&.dropZoneDragging {
border-color: $accent;
background: $accent-bg;
}
&.dropZoneDisabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
}
.uploadIcon {
width: 40px;
height: 40px;
color: $muted-2;
margin: 0 auto 12px;
display: block;
}
.dropText {
font-size: 14px;
color: $text;
margin: 0 0 6px;
}
.browseLink {
color: $accent;
text-decoration: underline;
}
.acceptedTypes {
font-size: 12px;
color: $muted-2;
margin: 0;
}
.hiddenInput {
display: none;
}
.fileList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.fileItem {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: $surface;
border: 1px solid $border;
border-radius: 4px;
font-size: 13px;
}
.fileIcon {
flex-shrink: 0;
font-size: 18px;
}
.fileName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $text;
}
.statusBadge {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
&.status_pending {
background: $hover;
color: $muted;
}
&.status_uploading,
&.status_processing {
background: $accent-bg;
color: $accent-dark;
}
&.status_done {
background: $ok-bg;
color: $ok;
}
&.status_error {
background: $danger-bg;
color: $danger;
}
}
.spinner {
display: inline-block;
width: 10px;
height: 10px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.supplierTag {
font-size: 11px;
color: $ok;
background: $ok-bg;
border-radius: 10px;
padding: 1px 8px;
white-space: nowrap;
flex-shrink: 0;
}
.flowProgress {
font-size: 11px;
color: $accent-dark;
flex-shrink: 0;
}
.errorMsg {
font-size: 11px;
color: $danger;
}
.currencySelect {
font-size: 11px;
padding: 2px 6px;
border: 1px solid $border;
border-radius: 3px;
background: $surface;
color: $text;
font-family: inherit;
cursor: pointer;
outline: none;
flex-shrink: 0;
height: 26px;
&:focus { border-color: $accent; }
}
.removeBtn {
flex-shrink: 0;
color: $muted-2;
&:hover {
color: $danger;
}
}
@@ -0,0 +1,143 @@
import * as React from 'react';
import { useState, useCallback, useRef } from 'react';
import { IconButton } from '@tnsu/ui-kit';
import styles from './FileUploadArea.module.scss';
export interface UploadedFile {
id: string;
file: File;
status: 'pending' | 'uploading' | 'processing' | 'done' | 'error';
errorMessage?: string;
supplier?: string; // extracted supplier name from Syntex
flowProgress?: string; // e.g. "3/6 позиций"
currencyCode?: string; // currency selected for this KP file
}
interface Props {
files: UploadedFile[];
onFilesAdded: (files: File[]) => void;
onFileRemove: (id: string) => void;
disabled?: boolean;
currencyOptions?: string[];
onCurrencyChange?: (id: string, currency: string) => void;
}
const ACCEPTED_TYPES = ['.pdf', '.docx', '.doc'];
function getStatusLabel(status: UploadedFile['status']): string {
switch (status) {
case 'pending': return 'Ожидание';
case 'uploading': return 'Загрузка...';
case 'processing': return 'Обработка...';
case 'done': return 'Готово';
case 'error': return 'Ошибка';
}
}
export const FileUploadArea: React.FC<Props> = ({ files, onFilesAdded, onFileRemove, disabled, currencyOptions, onCurrencyChange }) => {
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const addFiles = useCallback(
(incoming: FileList | null) => {
if (!incoming || disabled) return;
onFilesAdded(Array.from(incoming));
},
[onFilesAdded, disabled]
);
const handleDragOver = (e: React.DragEvent): void => {
e.preventDefault();
if (!disabled) setIsDragging(true);
};
const handleDragLeave = (): void => setIsDragging(false);
const handleDrop = (e: React.DragEvent): void => {
e.preventDefault();
setIsDragging(false);
addFiles(e.dataTransfer.files);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
addFiles(e.target.files);
// Reset input so same file can be re-added after removal
if (inputRef.current) inputRef.current.value = '';
};
return (
<div className={styles.root}>
<div
className={[styles.dropZone, isDragging ? styles.dropZoneDragging : '', disabled ? styles.dropZoneDisabled : '']
.filter(Boolean)
.join(' ')}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !disabled && inputRef.current?.click()}
role="button"
tabIndex={disabled ? -1 : 0}
onKeyDown={e => e.key === 'Enter' && !disabled && inputRef.current?.click()}
aria-label="Загрузить файлы КП"
>
<svg className={styles.uploadIcon} 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>
<p className={styles.dropText}>
Перетащите файлы или <span className={styles.browseLink}>выберите на диске</span>
</p>
<p className={styles.acceptedTypes}>Поддерживаемые форматы: {ACCEPTED_TYPES.join(', ')}</p>
<input
ref={inputRef}
type="file"
multiple
accept={ACCEPTED_TYPES.join(',')}
className={styles.hiddenInput}
onChange={handleInputChange}
disabled={disabled}
aria-hidden="true"
tabIndex={-1}
/>
</div>
{files.length > 0 && (
<ul className={styles.fileList} role="list">
{files.map(f => (
<li key={f.id} className={styles.fileItem}>
<span className={styles.fileIcon} aria-hidden="true">📄</span>
<span className={styles.fileName}>{f.file.name}</span>
<span className={[styles.statusBadge, styles[`status_${f.status}`]].join(' ')}>
{f.status === 'processing' || f.status === 'uploading' ? (
<span className={styles.spinner} aria-hidden="true" />
) : null}
{getStatusLabel(f.status)}
</span>
{f.supplier && <span className={styles.supplierTag}>{f.supplier}</span>}
{f.flowProgress && <span className={styles.flowProgress}>{f.flowProgress}</span>}
{f.status === 'done' && currencyOptions && currencyOptions.length > 0 && onCurrencyChange && (
<select
className={styles.currencySelect}
value={f.currencyCode ?? 'KZT'}
onChange={e => onCurrencyChange(f.id, e.target.value)}
aria-label={`Валюта КП ${f.file.name}`}
>
{currencyOptions.map(c => <option key={c} value={c}>{c}</option>)}
</select>
)}
{f.errorMessage && <span className={styles.errorMsg}>{f.errorMessage}</span>}
{(f.status === 'pending' || f.status === 'done' || f.status === 'error') && (
<IconButton
icon="close"
onClick={() => onFileRemove(f.id)}
title="Удалить файл"
className={styles.removeBtn}
/>
)}
</li>
))}
</ul>
)}
</div>
);
};
@@ -0,0 +1,184 @@
@import '../common/theme';
.root {
display: flex;
flex-direction: column;
gap: 32px;
padding: 24px;
}
.topRow {
display: flex;
gap: 32px;
align-items: flex-start;
> .section:first-child {
flex: 1 1 0;
min-width: 0;
}
}
.currencySection {
flex: 1 1 0;
min-width: 0;
}
.section {
display: flex;
flex-direction: column;
gap: 12px;
}
.sectionTitle {
font-size: 15px;
font-weight: 600;
color: $text;
margin: 0;
padding-bottom: 8px;
border-bottom: 1px solid $border;
}
.sectionHint {
font-size: 13px;
color: $muted;
margin: 0;
}
.addSupplierRow {
display: flex;
align-items: flex-end;
gap: 10px;
flex-wrap: wrap;
max-width: 860px;
}
.supplierNameInput {
flex: 2;
min-width: 200px;
}
.supplierInnInput {
flex: 1;
min-width: 140px;
}
.noOfferList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
max-width: 860px;
}
.noOfferItem {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: $head-bg;
border: 1px solid $border;
border-radius: 4px;
font-size: 13px;
}
.noOfferName {
flex: 1;
font-weight: 500;
color: $text;
}
.noOfferInn {
font-size: 12px;
color: $muted;
font-family: monospace;
}
.kpFileLink {
font-size: 13px;
color: $accent;
text-decoration: none;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
text-decoration: underline;
color: $accent-dark;
}
}
.noOfferBadge {
background: $danger-bg;
color: $danger;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
}
// ─── Initiator card ───────────────────────────────────────────────────────
.initiatorCard {
display: flex;
align-items: flex-start;
gap: 16px;
background: $accent-bg;
border: 1px solid $accent-border;
border-radius: 4px;
padding: 12px 16px;
}
.initiatorTitle {
font-size: 12px;
font-weight: 600;
color: $accent-dark;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
padding-top: 2px;
min-width: 70px;
}
.initiatorFields {
display: flex;
flex-wrap: wrap;
gap: 6px 24px;
}
.initiatorRow {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.initiatorLabel {
color: $muted;
font-weight: 500;
}
.initiatorValue {
color: $text;
font-weight: 600;
}
// ─── Footer ───────────────────────────────────────────────────────────────
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16px;
border-top: 1px solid $border;
gap: 12px;
flex-wrap: wrap;
}
.footerRight {
display: flex;
align-items: center;
gap: 12px;
}
@@ -0,0 +1,131 @@
import * as React from 'react';
import { useState } from 'react';
import { Button, Alert, Spinner } from '@tnsu/ui-kit';
import { CurrencyRate, FormStep } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { FileUploadArea, UploadedFile } from './FileUploadArea';
import { CurrencyRatesForm } from './CurrencyRatesForm';
import { NoOfferEntry } from './kpTypes';
import { useKpProcessing } from './useKpProcessing';
import { persistStep2 } from './step2Persist';
import { SuppliersWithoutKp } from './SuppliersWithoutKp';
import { UploadedKpList } from './UploadedKpList';
import styles from './Step2CommercialOffers.module.scss';
interface Props {
service: CtService;
formData: CTFormData;
userDisplayName: string;
isNotificationLink?: boolean;
isReadOnly?: boolean;
isRestrictedView?: boolean;
onSave: (partial: Partial<CTFormData>) => void;
onNext: () => void;
onPrev: () => void;
onStepComplete: (step: FormStep) => void;
}
// Шаг 2 «Коммерческие предложения»: загрузка КП (конвейер — useKpProcessing), курсы валют, поставщики
// без КП. Сохранение — step2Persist. Контейнер компонует блоки и колбэки (§стайл-гайд).
export const Step2CommercialOffers: React.FC<Props> = ({
service,
formData,
userDisplayName,
isNotificationLink,
onSave,
onNext,
onPrev,
onStepComplete,
isReadOnly = false,
isRestrictedView = false,
}) => {
const [currencyRates, setCurrencyRates] = useState<CurrencyRate[]>(formData.currencyRates);
const [noOfferSuppliers, setNoOfferSuppliers] = useState<NoOfferEntry[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined);
const kp = useKpProcessing(service, formData, onSave);
const handleSave = async (): Promise<void> => {
setIsSaving(true);
setSaveError(undefined);
try {
const patch = await persistStep2({ service, formData, kpFiles: kp.kpFiles, currencyRates, noOfferSuppliers, userDisplayName });
onSave(patch);
onStepComplete(2);
onNext();
} catch (err) {
setSaveError((err as Error).message ?? 'Ошибка сохранения');
} finally {
setIsSaving(false);
}
};
const canProceed = !isSaving && currencyRates.every(r => r.rateToKzt > 0);
const currencyOptions = ['KZT', ...currencyRates.filter(r => r.code !== 'KZT').map(r => r.code)];
const uploadedFiles: UploadedFile[] = kp.kpFiles.map(f => ({
id: f.localId,
file: f.file,
status: (f.status === 'extracting' || f.status === 'flowing') ? 'processing' : f.status,
errorMessage: f.error,
supplier: f.supplierName,
flowProgress: f.status === 'flowing' && f.flowTotal > 0
? `${f.flowDone}/${f.flowTotal} позиций`
: undefined,
currencyCode: f.currencyCode,
}));
return (
<div className={styles.root}>
<div className={styles.topRow}>
{!isRestrictedView && <section className={styles.section}>
<h3 className={styles.sectionTitle}>Загрузка коммерческих предложений</h3>
<FileUploadArea
files={uploadedFiles}
onFilesAdded={files => { kp.handleFilesAdded(files).catch(() => undefined); }}
onFileRemove={kp.handleFileRemove}
currencyOptions={currencyOptions}
onCurrencyChange={kp.handleCurrencyChange}
disabled={isNotificationLink || isReadOnly}
/>
{kp.duplicateWarning && <Alert variant="warn">{kp.duplicateWarning}</Alert>}
{kp.isProcessing && (
<Alert variant="info">Файлы обрабатываются системой Syntex. Можно продолжить заполнение формы.</Alert>
)}
</section>}
<section className={`${styles.section} ${styles.currencySection}`}>
<h3 className={styles.sectionTitle}>Курсы валют</h3>
<CurrencyRatesForm rates={currencyRates} onChange={setCurrencyRates} readOnly={isReadOnly} />
</section>
</div>
<SuppliersWithoutKp items={noOfferSuppliers} onChange={setNoOfferSuppliers} disabled={isReadOnly} />
{!isRestrictedView && (formData.uploadedKpFiles?.length ?? 0) > 0 && (
<UploadedKpList
files={formData.uploadedKpFiles}
service={service}
onRemove={(spId) => onSave({ uploadedKpFiles: formData.uploadedKpFiles.filter(u => u.spId !== spId) })}
disabled={isReadOnly}
/>
)}
<div className={styles.footer}>
<Button variant="secondary" onClick={onPrev}>
Назад
</Button>
<div className={styles.footerRight}>
{saveError && <Alert variant="err">{saveError}</Alert>}
{!isNotificationLink && !isReadOnly && (
<Button variant="accent" onClick={handleSave} disabled={!canProceed}>
{isSaving ? <Spinner size="small" /> : 'Сохранить и продолжить'}
</Button>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,72 @@
import * as React from 'react';
import { useState } from 'react';
import { Button, TextInput, IconButton } from '@tnsu/ui-kit';
import { NoOfferEntry } from './kpTypes';
import styles from './Step2CommercialOffers.module.scss';
interface Props {
items: NoOfferEntry[];
onChange: (items: NoOfferEntry[]) => void;
disabled?: boolean;
}
// Секция «Потенциальные поставщики без КП»: держит своё UI-состояние формы добавления, список — снаружи.
export const SuppliersWithoutKp: React.FC<Props> = ({ items, onChange, disabled }) => {
const [name, setName] = useState('');
const [comment, setComment] = useState('');
const add = (): void => {
if (!name.trim()) return;
onChange([...items, { localId: `no_${Date.now()}`, name: name.trim(), comment: comment.trim() }]);
setName('');
setComment('');
};
return (
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Потенциальные поставщики без КП</h3>
<p className={styles.sectionHint}>Укажите потенциальных поставщиков, которые не предоставили коммерческое предложение.</p>
<div className={styles.addSupplierRow}>
<div className={styles.supplierNameInput}>
<TextInput
placeholder="Наименование потенциального поставщика"
value={name}
onChange={setName}
ariaLabel="Наименование потенциального поставщика без КП"
readOnly={disabled}
/>
</div>
<div className={styles.supplierInnInput}>
<TextInput
placeholder="Комментарий (необязательно)"
value={comment}
onChange={setComment}
ariaLabel="Комментарий"
readOnly={disabled}
/>
</div>
<Button variant="secondary" onClick={add} disabled={!name.trim() || disabled}>
Добавить
</Button>
</div>
{items.length > 0 && (
<ul className={styles.noOfferList}>
{items.map(s => (
<li key={s.localId} className={styles.noOfferItem}>
<span className={styles.noOfferName}>{s.name}</span>
{s.comment && <span className={styles.noOfferInn}>{s.comment}</span>}
<span className={styles.noOfferBadge}>Без КП</span>
<IconButton
icon="trash"
onClick={() => onChange(items.filter(x => x.localId !== s.localId))}
title={`Удалить потенциального поставщика ${s.name}`}
disabled={disabled}
/>
</li>
))}
</ul>
)}
</section>
);
};
@@ -0,0 +1,40 @@
import * as React from 'react';
import { IconButton } from '@tnsu/ui-kit';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import styles from './Step2CommercialOffers.module.scss';
interface Props {
files: CTFormData['uploadedKpFiles'];
service: CtService;
onRemove: (spId: number) => void;
disabled?: boolean;
}
// Секция «Загруженные КП» (постоянный список из formData): ссылка на файл + удаление из списка.
export const UploadedKpList: React.FC<Props> = ({ files, service, onRemove, disabled }) => (
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Загруженные КП</h3>
<ul className={styles.noOfferList}>
{files.map(f => (
<li key={f.spId} className={styles.noOfferItem}>
{f.supplierName && <span className={styles.noOfferName}>{f.supplierName}</span>}
<a
href={service.getKpFileUrl(f.name)}
target="_blank"
rel="noopener noreferrer"
className={styles.kpFileLink}
>
{f.name}
</a>
<IconButton
icon="trash"
title="Удалить КП из списка"
onClick={() => onRemove(f.spId)}
disabled={disabled}
/>
</li>
))}
</ul>
</section>
);
@@ -0,0 +1,33 @@
import { FlowResult } from '../types/domain';
// Результат разбора КП потоком по одной позиции.
export interface FlowPositionResult {
positionId: string;
positionSpId?: number;
flowResult: FlowResult;
}
// Файл КП в процессе загрузки/обработки (Шаг 2). Статус-конечный автомат: upload → extract → flow → done/error.
export interface KpFile {
localId: string;
file: File;
status: 'uploading' | 'extracting' | 'flowing' | 'done' | 'error';
spId?: number;
supplierName?: string;
supplierSpId?: number;
flowDone: number;
flowTotal: number;
error?: string;
currencyCode: string;
flowResults: FlowPositionResult[];
}
// Потенциальный поставщик без КП (ручной ввод).
export interface NoOfferEntry {
localId: string;
name: string;
comment: string;
}
export const POLL_INTERVAL_MS = 5000;
export const MAX_POLL_RETRIES = 60; // 5 min
@@ -0,0 +1,148 @@
import { ApprovalMember, ApprovalMemberStatus, CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { KpFile, NoOfferEntry } from './kpTypes';
export interface PersistStep2Args {
service: CtService;
formData: CTFormData;
kpFiles: KpFile[];
currencyRates: CurrencyRate[];
noOfferSuppliers: NoOfferEntry[];
userDisplayName: string;
}
// Сохранение Шага 2: курсы валют, поставщики без КП, поставщики с КП + офферы из результатов потока,
// пересборка цепочки согласования из матрицы. Возвращает patch для onSave. Логика 1:1 из исходного handleSave.
export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFormData>> {
const { service, formData, kpFiles, currencyRates, noOfferSuppliers, userDisplayName } = args;
let savedRates = currencyRates;
if (currencyRates.length > 0) {
savedRates = await service.saveCurrencyRates(currencyRates);
}
for (const s of noOfferSuppliers) {
await service.saveNoOfferSupplierItem(s.name, s.comment);
}
const newSuppliersWithOffer: Supplier[] = kpFiles
.filter(f => f.supplierName)
.map(f => ({
id: f.supplierSpId ? String(f.supplierSpId) : f.localId,
spId: f.supplierSpId,
name: f.supplierName as string,
hasOffer: true,
currency: f.currencyCode,
}));
const suppliersWithoutOffer: Supplier[] = noOfferSuppliers.map(s => ({
id: s.localId,
name: s.name,
hasOffer: false,
}));
// Build offers from flow results so Step 3 can pre-fill the table
const newOffers: SupplierOffer[] = [];
for (const f of kpFiles) {
if (!f.supplierSpId || f.flowResults.length === 0) continue;
const currency = f.currencyCode;
const rate = currency !== 'KZT'
? (savedRates.find(r => r.code === currency)?.rateToKzt ?? 1)
: 1;
for (const fr of f.flowResults) {
const pricePerUnit = fr.flowResult.price ?? 0;
const quantity = fr.flowResult.quantity ?? 0;
const pricePerUnitKzt = pricePerUnit * rate;
const totalPriceKzt = pricePerUnitKzt * quantity;
newOffers.push({
id: '',
supplierId: String(f.supplierSpId),
positionId: fr.positionId,
supplierPosition: fr.flowResult.position,
unit: fr.flowResult.unit,
pricePerUnit,
quantity,
currencyCode: currency,
pricePerUnitKzt,
totalPriceKzt,
totalWithExtraKzt: totalPriceKzt,
offerId: f.spId ? String(f.spId) : undefined,
});
}
}
// Persist offers with currency data to SP so shared links show correct currency columns.
// saveKpOffer (called during flow) omits valutaId and stores price as priceTenge — fix here
// before proceeding to Step 3.
if (newOffers.length > 0) {
const offersForSP = newOffers
.map(o => {
const pos = formData.selectedPositions.find(p => p.id === o.positionId);
return pos?.spId !== undefined ? { ...o, positionId: String(pos.spId) } : null;
})
.filter((o): o is SupplierOffer => o !== null && !isNaN(parseInt(o.supplierId, 10)));
if (offersForSP.length > 0) {
await service.saveOffers(offersForSP, savedRates);
}
}
// Мержим с поставщиками из предыдущих сессий на шаге 2
// (повторная загрузка КП того же поставщика — заменяем по имени)
const newSupplierNames = new Set(newSuppliersWithOffer.map(s => s.name));
const newSupplierIds = new Set(newSuppliersWithOffer.map(s => s.id));
const mergedSuppliersWithOffer: Supplier[] = [
...(formData.suppliers ?? []).filter(s => s.hasOffer && !newSupplierNames.has(s.name)),
...newSuppliersWithOffer,
];
const mergedOffers: SupplierOffer[] = [
...(formData.offers ?? []).filter(o => !newSupplierIds.has(o.supplierId)),
...newOffers,
];
// Preserve existing hasOffer:false suppliers from formData when user re-visits Step 2
// (noOfferSuppliers state starts empty on remount, so without this they'd be lost on re-save)
const newNoOfferNames = new Set(noOfferSuppliers.map(s => s.name.trim()));
const preservedNoOfferSuppliers = (formData.suppliers ?? [])
.filter(s => !s.hasOffer && !newNoOfferNames.has(s.name.trim()));
let approvalChain: ApprovalMember[] = [];
if (formData.ct?.id) {
await service.deleteApprovalChainByCt(formData.ct.id);
let matrix = formData.initiatorDepartment
? await service.loadApprovalMatrix(formData.initiatorCompany, formData.initiatorDepartment)
: [];
if (matrix.length === 0) {
matrix = await service.loadApprovalMatrix(formData.initiatorCompany, '');
}
if (matrix.length > 0) {
approvalChain = [
{
id: 'chain_1',
order: 1,
userId: String(service.currentUserSpId),
userName: userDisplayName,
role: 'Инициатор',
status: 'pending',
},
...matrix.map((m, idx) => ({
id: `chain_${idx + 2}`,
order: idx + 2,
userId: String(m.approverSpId),
userName: m.approverName,
role: m.post,
status: 'pending' as ApprovalMemberStatus,
canAddApprover: m.canAddApprover,
})),
];
approvalChain = await service.saveApprovalChain(approvalChain, formData.ct.id);
}
}
return {
currencyRates: savedRates,
suppliers: [...mergedSuppliersWithOffer, ...preservedNoOfferSuppliers, ...suppliersWithoutOffer],
offers: mergedOffers,
approvalChain,
};
}
@@ -0,0 +1,252 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from './kpTypes';
let localIdCounter = 0;
export interface UseKpProcessingResult {
kpFiles: KpFile[];
duplicateWarning: string | null;
setDuplicateWarning: (v: string | null) => void;
handleFilesAdded: (files: File[]) => Promise<void>;
handleFileRemove: (localId: string) => void;
handleCurrencyChange: (localId: string, currency: string) => void;
isProcessing: boolean;
}
// Конвейер обработки КП (Шаг 2): загрузка в библиотеку → извлечение текста (Word) → поллинг Syntex →
// запуск потока по позициям → сбор офферов/критериев/поставщика. Логика 1:1 из исходного
// Step2CommercialOffers (§стайл-гайд: логика вне вёрстки). Завершённые файлы пишутся в formData через onSave.
export function useKpProcessing(
service: CtService,
formData: CTFormData,
onSave: (partial: Partial<CTFormData>) => void
): UseKpProcessingResult {
const [kpFiles, setKpFiles] = useState<KpFile[]>([]);
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null);
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; }, []);
// Сохраняем завершённые файлы в formData при изменении статуса
useEffect(() => {
const doneFiles = kpFiles.filter(f => f.status === 'done' && f.spId !== undefined);
if (doneFiles.length === 0) return;
const currentUploaded = formDataRef.current.uploadedKpFiles ?? [];
const newEntries = doneFiles
.filter(f => !currentUploaded.some(u => u.spId === f.spId))
.map(f => ({ name: f.file.name, spId: f.spId as number, supplierName: f.supplierName }));
if (newEntries.length > 0) {
onSave({ uploadedKpFiles: [...currentUploaded, ...newEntries] });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [kpFiles]);
const patchFile = (localId: string, patch: Partial<KpFile>): void => {
if (!mountedRef.current) return;
setKpFiles(prev => prev.map(f => f.localId === localId ? { ...f, ...patch } : f));
};
const triggerFlowsForFile = async (
localId: string,
fileSpId: number,
fileName: string,
supplierSpId?: number
): Promise<void> => {
const positions = formDataRef.current.selectedPositions;
if (!mountedRef.current) return;
setKpFiles(prev => prev.map(f =>
f.localId === localId ? { ...f, status: 'flowing', flowTotal: positions.length, flowDone: 0, flowResults: [] } : f
));
const collectedResults: FlowPositionResult[] = [];
await Promise.all(
positions.map(async pos => {
try {
const result = await service.triggerFlow(fileName, fileSpId, pos.materialName);
collectedResults.push({ positionId: pos.id, positionSpId: pos.spId, flowResult: result });
if (pos.spId && supplierSpId && result) {
await service.saveKpOffer({
positionSpId: pos.spId,
supplierSpId,
offerSpId: fileSpId,
supplierPosition: result.position ?? pos.materialName,
unit: result.unit ?? pos.unit,
quantity: result.quantity ?? pos.quantity,
price: result.price ?? 0,
summa: result.cost ?? 0,
});
}
} catch {
// individual position failure doesn't stop others
}
if (mountedRef.current) {
setKpFiles(prev => prev.map(f =>
f.localId === localId ? { ...f, flowDone: f.flowDone + 1 } : f
));
}
})
);
// If supplier wasn't set by Syntex, use supplier name from flow result
let resolvedSupplierSpId = supplierSpId;
if (!resolvedSupplierSpId) {
const supplierName = collectedResults.find(r => r.flowResult.supplier)?.flowResult.supplier;
if (supplierName) {
try {
resolvedSupplierSpId = await service.saveSupplierWithOffer(supplierName);
patchFile(localId, { supplierName, supplierSpId: resolvedSupplierSpId });
} catch {
// supplier save failed — continue without supplier
}
}
}
// Auto-save criteria (one record per supplier — take first result that has any criteria field)
const criteriaEntry = collectedResults.find(
r => r.flowResult.conditions || r.flowResult.deliveryType || r.flowResult.deadline || r.flowResult.sertificates
);
if (criteriaEntry && resolvedSupplierSpId) {
const ctSpId = formDataRef.current.ct?.id ? parseInt(formDataRef.current.ct.id, 10) : undefined;
service.saveFlowCriteria(resolvedSupplierSpId, criteriaEntry.flowResult, ctSpId).catch(() => undefined);
}
patchFile(localId, { status: 'done', flowResults: collectedResults });
};
const pollSyntex = async (
localId: string,
spId: number,
fileName: string,
retries: number,
savedSupplierSpId?: number
): Promise<void> => {
if (!mountedRef.current) return;
if (retries >= MAX_POLL_RETRIES) {
patchFile(localId, { status: 'error', error: 'Syntex не завершил обработку за 5 минут' });
return;
}
try {
const fields = await service.getKpItemFields(spId);
let currentSupplierSpId = savedSupplierSpId;
if (fields.customer && !currentSupplierSpId) {
patchFile(localId, { supplierName: fields.customer });
try {
currentSupplierSpId = await service.saveSupplierWithOffer(fields.customer);
patchFile(localId, { supplierSpId: currentSupplierSpId });
} catch {
// supplier save failed — continue polling
}
}
if (fields.fileContent) {
await triggerFlowsForFile(localId, spId, fileName, currentSupplierSpId);
} else {
await new Promise<void>(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
await pollSyntex(localId, spId, fileName, retries + 1, currentSupplierSpId);
}
} catch {
await new Promise<void>(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
await pollSyntex(localId, spId, fileName, retries + 1, savedSupplierSpId);
}
};
const handleFilesAdded = useCallback(async (files: File[]): Promise<void> => {
const existingNames = new Set([
...kpFilesRef.current.map(f => f.file.name),
...(formDataRef.current.uploadedKpFiles ?? []).map(f => f.name),
]);
const newFiles = files.filter(f => !existingNames.has(f.name));
const skipped = files.filter(f => existingNames.has(f.name));
if (skipped.length > 0) {
setDuplicateWarning(`Файл(ы) уже загружен(ы) в рамках этой СТ: ${skipped.map(f => f.name).join(', ')}`);
} else {
setDuplicateWarning(null);
}
if (newFiles.length === 0) return;
const entries: KpFile[] = newFiles.map(file => ({
localId: `kp_${++localIdCounter}`,
file,
status: 'uploading' as const,
flowDone: 0,
flowTotal: 0,
currencyCode: 'KZT',
flowResults: [],
}));
if (mountedRef.current) setKpFiles(prev => [...prev, ...entries]);
for (const entry of entries) {
try {
let cachedSpId: number | undefined;
try {
const existing = await service.findExistingKpFile(entry.file.name, entry.file.size);
if (existing) cachedSpId = existing.spId;
} catch {
// lookup failed — proceed with normal upload
}
if (cachedSpId !== undefined) {
patchFile(entry.localId, { spId: cachedSpId, status: 'extracting' });
pollSyntex(entry.localId, cachedSpId, entry.file.name, 0).catch(err => {
patchFile(entry.localId, { status: 'error', error: (err as Error).message });
});
continue;
}
const { spId, name } = await service.uploadKpFile(entry.file);
patchFile(entry.localId, { spId, status: 'extracting' });
const isWord = /\.(doc|docx|xls|xlsx)$/i.test(entry.file.name);
if (isWord) {
try {
await service.extractWordText(spId);
} catch (err) {
patchFile(entry.localId, { status: 'error', error: `Ошибка извлечения текста Word: ${(err as Error).message}` });
continue;
}
}
pollSyntex(entry.localId, spId, name, 0).catch(err => {
patchFile(entry.localId, { status: 'error', error: (err as Error).message });
});
} catch (err) {
patchFile(entry.localId, { status: 'error', error: (err as Error).message });
}
}
}, [service]);
const handleFileRemove = (localId: string): void => {
setKpFiles(prev => prev.filter(f => f.localId !== localId));
};
const handleCurrencyChange = (localId: string, currency: string): void => {
setKpFiles(prev => prev.map(f => f.localId === localId ? { ...f, currencyCode: currency } : f));
};
const isProcessing = kpFiles.some(f => f.status === 'extracting' || f.status === 'flowing');
return {
kpFiles,
duplicateWarning,
setDuplicateWarning,
handleFilesAdded,
handleFileRemove,
handleCurrencyChange,
isProcessing,
};
}
@@ -0,0 +1,78 @@
import * as React from 'react';
import { Button, TextInput, Icon } from '@tnsu/ui-kit';
import { Step3Matrix } from './useStep3Matrix';
import { getSubCols } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
matrix: Step3Matrix;
isRestrictedView: boolean;
colSpan: number;
}
// Замыкающая строка таблицы: инлайн-форма добавления позиции (Доп. расходы) + кнопка «Добавить логистику».
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
export function AddExtraRow({ matrix, isRestrictedView, colSpan }: Props): JSX.Element | null {
const {
isReadOnly, showAddPosRow, newPosName, newPosUnit, setNewPosName, setNewPosUnit, setShowAddPosRow,
handleAddPosition, handleAddDelivery, suppliersWithOffer, supplierCurrencies,
} = matrix;
if (isReadOnly) return null;
if (showAddPosRow) {
return (
<tr className={styles.addPositionRow}>
<td colSpan={3}>
<div className={styles.addPosInlineForm}>
<div className={styles.addInput}>
<TextInput
placeholder="Наименование позиции"
value={newPosName}
onChange={setNewPosName}
ariaLabel="Наименование позиции"
onKeyDown={e => { if (e.key === 'Enter') handleAddPosition(); }}
autoFocus
/>
</div>
<div className={styles.addInputSmall}>
<TextInput
placeholder="Ед."
value={newPosUnit}
onChange={setNewPosUnit}
ariaLabel="Единица измерения"
/>
</div>
<Button variant="secondary" onClick={handleAddPosition} disabled={!newPosName.trim()}>
<Icon name="check" size="sm" />
Добавить
</Button>
<Button
variant="secondary"
onClick={() => { setShowAddPosRow(false); setNewPosName(''); setNewPosUnit(''); }}
>
<Icon name="close" size="sm" />
Отмена
</Button>
</div>
</td>
{suppliersWithOffer.map(s => (
<td key={s.id} colSpan={getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView)} />
))}
</tr>
);
}
return (
<tr className={styles.addPositionRow}>
<td colSpan={colSpan}>
<button className={styles.addPositionBtn} onClick={() => setShowAddPosRow(true)}>
+ Добавить позицию (Доп. расходы)
</button>
<button className={styles.addPositionBtn} onClick={handleAddDelivery}>
+ Добавить логистику
</button>
</td>
</tr>
);
}
@@ -0,0 +1,43 @@
import * as React from 'react';
import { IconButton, Alert, Spinner } from '@tnsu/ui-kit';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
comment?: string;
decodedComment?: string;
isLoading: boolean;
error?: string;
onDelete: () => void;
}
// Блок «Комментарий ИИ» (Шаг 3): показывается только если есть комментарий, идёт загрузка или ошибка.
export const AiCommentBlock: React.FC<Props> = ({ comment, decodedComment, isLoading, error, onDelete }) => {
if (!comment && !isLoading && !error) return null;
return (
<div className={styles.aiCommentBlock}>
<div className={styles.aiCommentTitle}>
Комментарий ИИ
{comment && !isLoading && (
<IconButton
icon="trash"
title="Удалить комментарий"
className={styles.aiCommentDeleteBtn}
onClick={onDelete}
/>
)}
</div>
{isLoading && (
<div className={styles.aiCommentBody}>
<Spinner size="small" label="Генерация комментария..." />
</div>
)}
{error && (
<div className={styles.aiCommentBody}>
<Alert variant="err">{error}</Alert>
</div>
)}
{comment && <p className={styles.aiCommentText}>{decodedComment}</p>}
</div>
);
};
@@ -0,0 +1,230 @@
import * as React from 'react';
import { CurrencyRate } from '../types/domain';
import { Step3Matrix } from './useStep3Matrix';
import { MatrixRowView } from './MatrixRowView';
import { ExtraPositionRow } from './ExtraPositionRow';
import { AddExtraRow } from './AddExtraRow';
import { SUPPLIER_STATUS_OPTIONS, getSubCols, formatKzt } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss';
const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во
interface Props {
matrix: Step3Matrix;
currencyRates: CurrencyRate[];
isRestrictedView: boolean;
}
// Таблица сравнения целиком: шапка (фикс-колонки + заголовки поставщиков) + строки позиций
// (MatrixRowView/ExtraPositionRow) + итоговые строки + строка добавления (AddExtraRow).
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView }: Props): JSX.Element {
const {
isReadOnly, suppliersWithOffer, supplierCurrencies, selectedSupplierId, recommendedSupplierId,
supplierStatuses, nameColWidth, regularPositions, extraPositionsRows, allPositions, computedOffers,
setSelectedSupplierId, handleDeleteSupplier, handleStatusChange, handleNameColResizeMouseDown,
} = matrix;
const totalColSpan = FIXED_COLS + suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0);
return (
<div className={styles.tableScrollWrapper}>
<table className={styles.ctTable}>
<thead>
{/* Row 1: fixed headers (rowSpan=2) + supplier group headers (colSpan varies by currency) */}
<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>
{suppliersWithOffer.map(s => {
const currency = supplierCurrencies[s.id] ?? 'KZT';
const subCols = getSubCols(currency, isRestrictedView);
const isRec = s.id === recommendedSupplierId;
const isSel = s.id === selectedSupplierId;
return (
<th
key={s.id}
colSpan={subCols}
className={[
styles.supplierGroupHeader,
isRec ? styles.colRecommended : '',
isSel ? styles.colSelected : '',
].filter(Boolean).join(' ')}
>
<div className={styles.supplierHeader}>
<span className={styles.supplierName}>{s.name}</span>
{isRec && <span className={styles.recommendedBadge}>Рекомендуется</span>}
{s.isNoOffer && <span className={styles.noOfferTag}>Без КП</span>}
{currency !== 'KZT' && <span className={styles.currencyTag}>{currency}</span>}
{!isReadOnly ? (
<select
className={styles.statusInput}
value={supplierStatuses[s.id] ?? ''}
onChange={e => handleStatusChange(s.id, e.target.value)}
aria-label={`Статус поставщика ${s.name}`}
>
<option value="">Статус…</option>
{SUPPLIER_STATUS_OPTIONS.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
) : (
supplierStatuses[s.id] ? <span className={styles.statusBadge}>{supplierStatuses[s.id]}</span> : undefined
)}
<div className={styles.supplierActions}>
{!isReadOnly ? (
<>
<button
className={[styles.selectSupplierBtn, isSel ? styles.selectSupplierBtnActive : ''].filter(Boolean).join(' ')}
onClick={() => setSelectedSupplierId(s.id)}
aria-pressed={isSel}
>
{isSel ? 'Выбран' : 'Выбрать'}
</button>
<button
className={styles.deleteSupplierBtn}
onClick={() => handleDeleteSupplier(s.id, s.isExtraSupplier)}
title="Удалить потенциального поставщика"
>×</button>
</>
) : (
isSel && <span className={styles.selectedBadge}>Выбран</span>
)}
</div>
</div>
</th>
);
})}
</tr>
{/* Row 2: sub-headers — 4 cols for KZT, 6 cols for foreign currency */}
<tr className={styles.headerRow2}>
{suppliersWithOffer.map(s => {
const currency = supplierCurrencies[s.id] ?? 'KZT';
const isForeign = currency !== 'KZT';
const isSel = s.id === selectedSupplierId;
const selCls = isSel ? styles.colSelected : '';
return (
<React.Fragment key={s.id}>
<th className={[styles.subHeader, styles.subHeaderName, selCls].filter(Boolean).join(' ')} style={{ width: nameColWidth, minWidth: nameColWidth }}>
Наименование ТМЦ
<span className={styles.colResizeHandle} onMouseDown={handleNameColResizeMouseDown} />
</th>
<th className={[styles.subHeader, selCls].filter(Boolean).join(' ')}>Ед. изм.</th>
<th className={[styles.subHeader, selCls].filter(Boolean).join(' ')}>Кол-во</th>
{!isRestrictedView && isForeign && (
<>
<th className={[styles.subHeader, selCls].filter(Boolean).join(' ')}>Цена, {currency}</th>
<th className={[styles.subHeader, selCls].filter(Boolean).join(' ')}>Сумма, {currency}</th>
</>
)}
{!isRestrictedView && <th className={[styles.subHeader, selCls].filter(Boolean).join(' ')}>Цена, ₸</th>}
{!isRestrictedView && <th className={[styles.subHeader, selCls].filter(Boolean).join(' ')}>Сумма, ₸</th>}
</React.Fragment>
);
})}
</tr>
</thead>
<tbody>
{/* Regular positions — with bundle (комплект) support: multiple rows per position */}
{regularPositions.map((pos, posIndex) => (
<MatrixRowView
key={pos.id}
pos={pos}
posIndex={posIndex}
matrix={matrix}
currencyRates={currencyRates}
isRestrictedView={isRestrictedView}
/>
))}
{/* Сумма потребностей */}
<tr className={styles.subtotalRow}>
<td colSpan={FIXED_COLS} className={styles.subtotalLabel}><strong>Итого, ₸</strong></td>
{suppliersWithOffer.map(s => {
const currency = supplierCurrencies[s.id] ?? 'KZT';
const subCols = getSubCols(currency, isRestrictedView);
const isSel = s.id === selectedSupplierId;
const total = regularPositions.reduce((sum, pos) => {
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
return sum + posOffers.reduce((s2, o) => s2 + (o.totalWithExtraKzt ?? 0), 0);
}, 0);
return (
<React.Fragment key={s.id}>
{isRestrictedView
? <td colSpan={subCols} className={isSel ? styles.colSelected : undefined} />
: <><td colSpan={subCols - 1} className={isSel ? styles.colSelected : undefined} /><td className={[styles.subtotalCell, isSel ? styles.colSelected : ''].filter(Boolean).join(' ')}><strong>{formatKzt(total)}</strong></td></>
}
</React.Fragment>
);
})}
</tr>
{/* Доп. расходы positions — single row each (no bundle support needed) */}
{extraPositionsRows.map((pos, posIndex) => (
<ExtraPositionRow
key={pos.id}
pos={pos}
posIndex={posIndex}
regularPositionsCount={regularPositions.length}
matrix={matrix}
currencyRates={currencyRates}
isRestrictedView={isRestrictedView}
/>
))}
{/* Сумма доп. расходов */}
{extraPositionsRows.length > 0 && (
<tr className={styles.subtotalRow}>
<td colSpan={FIXED_COLS} className={styles.subtotalLabel}><strong>Сумма доп. расходов, ₸</strong></td>
{suppliersWithOffer.map(s => {
const currency = supplierCurrencies[s.id] ?? 'KZT';
const subCols = getSubCols(currency, isRestrictedView);
const isSel = s.id === selectedSupplierId;
const total = extraPositionsRows.reduce((sum, pos) => {
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
return sum + posOffers.reduce((s2, o) => s2 + (o.totalWithExtraKzt ?? 0), 0);
}, 0);
return (
<React.Fragment key={s.id}>
{isRestrictedView
? <td colSpan={subCols} className={isSel ? styles.colSelected : undefined} />
: <><td colSpan={subCols - 1} className={isSel ? styles.colSelected : undefined} /><td className={[styles.subtotalCell, isSel ? styles.colSelected : ''].filter(Boolean).join(' ')}><strong>{formatKzt(total)}</strong></td></>
}
</React.Fragment>
);
})}
</tr>
)}
{/* Общая сумма */}
<tr className={styles.grandTotalRow}>
<td colSpan={FIXED_COLS}><strong>Общая сумма, ₸</strong></td>
{suppliersWithOffer.map(s => {
const currency = supplierCurrencies[s.id] ?? 'KZT';
const subCols = getSubCols(currency, isRestrictedView);
const total = allPositions.reduce((sum, pos) => {
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
return sum + posOffers.reduce((s2, o) => s2 + (o.totalWithExtraKzt ?? 0), 0);
}, 0);
const isRec = s.id === recommendedSupplierId;
const isSel = s.id === selectedSupplierId;
return (
<React.Fragment key={s.id}>
{isRestrictedView
? <td colSpan={subCols} className={isSel ? styles.colSelected : undefined} />
: <><td colSpan={subCols - 1} className={isSel ? styles.colSelected : undefined} /><td className={[styles.grandTotalCell, isRec && !isSel ? styles.grandTotalRecommended : '', isSel ? styles.colSelected : ''].filter(Boolean).join(' ')}><strong>{formatKzt(total)}</strong></td></>
}
</React.Fragment>
);
})}
</tr>
{/* Inline add-position row */}
<AddExtraRow matrix={matrix} isRestrictedView={isRestrictedView} colSpan={totalColSpan} />
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,141 @@
import * as React from 'react';
import { Button, IconButton, TextInput, TextArea, Icon } from '@tnsu/ui-kit';
import { Step3Matrix } from './useStep3Matrix';
import { CriteriaEntry } from './matrixTypes';
import { DELIVERY_TYPE_OPTIONS } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
matrix: Step3Matrix;
}
// Блок «Дополнительная информация» (условия оплаты/поставки/сроки/сертификаты + произвольные условия).
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
export function CriteriaTable({ matrix }: Props): JSX.Element | null {
const {
isReadOnly, suppliersWithOffer, criteriaMap, customCriteria, newCriteriaLabel,
setNewCriteriaLabel, updateCriteriaValue, updateCustomCriteriaValue,
handleAddCustomCriteria, handleRemoveCustomCriteria,
} = matrix;
const fixedRows: Array<{ key: keyof CriteriaEntry; label: string }> = [
{ key: 'paymentType', label: 'Условия оплаты' },
{ key: 'deliveryType', label: 'Условия поставки' },
{ key: 'deadline', label: 'Сроки изготовления и доставки' },
{ key: 'sertificates', label: 'Сертификаты' },
];
const hasAnyData = fixedRows.some(row => suppliersWithOffer.some(s => criteriaMap[s.id]?.[row.key]?.trim()))
|| customCriteria.length > 0;
if (!isReadOnly && suppliersWithOffer.length === 0) return null;
if (isReadOnly && !hasAnyData) return null;
return (
<div className={styles.criteriaSection}>
<h4 className={styles.criteriaSectionTitle}>Дополнительная информация</h4>
<div className={styles.criteriaTableWrapper}>
<table className={styles.criteriaTable}>
<thead>
<tr>
<th className={styles.criteriaLabelHeader} />
{suppliersWithOffer.map(s => (
<th key={s.id} className={styles.criteriaSupplierHeader}>{s.name}</th>
))}
</tr>
</thead>
<tbody>
{fixedRows.map(row => (
<tr key={row.key}>
<td className={styles.criteriaLabel}>{row.label}</td>
{suppliersWithOffer.map(s => (
<td key={s.id} className={styles.criteriaValue}>
{!isReadOnly ? (
row.key === 'deliveryType' ? (
(criteriaMap[s.id]?.deliveryType ?? '').trim() ? (
// Уже заполнено (в т.ч. ИИ) — показываем как текст, без селекта.
criteriaMap[s.id]?.deliveryType
) : (
// Пусто — даём выбрать условие поставки из списка.
<select
className={styles.criteriaSelect}
value=""
onChange={e => updateCriteriaValue(s.id, 'deliveryType', e.target.value)}
aria-label={`${row.label} — ${s.name}`}
>
<option value="">—</option>
{DELIVERY_TYPE_OPTIONS.map(o => (
<option key={o.code} value={o.code}>{o.label}</option>
))}
</select>
)
) : (
<div className={styles.criteriaInput}>
<TextArea
value={criteriaMap[s.id]?.[row.key] ?? ''}
onChange={val => updateCriteriaValue(s.id, row.key, val)}
placeholder="—"
borderless
rows={1}
ariaLabel={`${row.label} — ${s.name}`}
/>
</div>
)
) : (
criteriaMap[s.id]?.[row.key] || '—'
)}
</td>
))}
</tr>
))}
{customCriteria.map(row => (
<tr key={row.id}>
<td className={styles.criteriaLabel}>
<div className={styles.criteriaCustomLabel}>
<span>{row.label}</span>
{!isReadOnly && (
<IconButton icon="trash" className={styles.criteriaRemoveBtn} onClick={() => handleRemoveCustomCriteria(row.id)} title="Удалить строку" />
)}
</div>
</td>
{suppliersWithOffer.map(s => (
<td key={s.id} className={styles.criteriaValue}>
{!isReadOnly ? (
<div className={styles.criteriaInput}>
<TextArea
value={row.values[s.id] ?? ''}
onChange={val => updateCustomCriteriaValue(row.id, s.id, val)}
placeholder="—"
borderless
rows={1}
ariaLabel={`${row.label} — ${s.name}`}
/>
</div>
) : (
row.values[s.id] || '—'
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{!isReadOnly && (
<div className={styles.criteriaAddPanel}>
<div className={styles.criteriaAddPanelInput}>
<TextInput
value={newCriteriaLabel}
onChange={setNewCriteriaLabel}
placeholder="Название нового условия"
ariaLabel="Название нового условия"
onKeyDown={e => { if (e.key === 'Enter') handleAddCustomCriteria(); }}
/>
</div>
<Button variant="secondary" onClick={handleAddCustomCriteria} disabled={!newCriteriaLabel.trim()}>
<Icon name="plus" size="sm" />
Добавить условие
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,218 @@
import * as React from 'react';
import { IconButton, TextInput } from '@tnsu/ui-kit';
import { CurrencyRate } from '../types/domain';
import { PositionRow } from './matrixTypes';
import { Step3Matrix } from './useStep3Matrix';
import { convertQuantity, unitsEquivalent } from '../utils/unitConversion';
import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
pos: PositionRow;
posIndex: number;
regularPositionsCount: number;
matrix: Step3Matrix;
currencyRates: CurrencyRate[];
isRestrictedView: boolean;
}
// Одна строка «Доп. расходы» / «Логистика» (без поддержки комплекта — один item на поставщика).
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix, currencyRates, isRestrictedView }: Props): JSX.Element {
const {
isReadOnly, edits, extraPositions, translateUnits, rawValues, deliveryKm, selectedSupplierId, nameColWidth,
suppliersWithOffer, supplierCurrencies, computedOffers, minTotalByPosition,
updateSupplierProductName, updateUnit, updateEdit, updatePriceKzt, handleDeliveryKmChange,
handleDeleteExtraPosition,
} = matrix;
const ep = extraPositions.find(e => e.id === pos.id);
const key0 = (s: { id: string }): string => buildOfferKey(s.id, pos.id);
const minTotal = minTotalByPosition.get(pos.id);
return (
<tr className={styles.extraRow}>
<td className={styles.positionNameCell}>
<span className={styles.positionIndex}>{regularPositionsCount + posIndex + 1}.</span>{' '}
{pos.materialName}
{!ep?.isDelivery && <span className={styles.extraBadge}>Доп. расходы</span>}
{!isReadOnly && (
<IconButton
icon="trash"
title="Удалить"
className={styles.deleteExtraBtn}
onClick={() => handleDeleteExtraPosition(pos.id)}
/>
)}
</td>
<td className={styles.unitCell}>{pos.unit}</td>
<td className={styles.qtyCell}>—</td>
{suppliersWithOffer.map(s => {
const key = key0(s);
const item = (edits[key] ?? [])[0];
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
const currency = supplierCurrencies[s.id] ?? 'KZT';
const isForeign = currency !== 'KZT';
const qty = item?.quantity;
const price = item?.pricePerUnit;
const rate = isForeign ? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1) : 1;
const totalInCurrency = (price ?? 0) * (qty ?? 0);
const priceKzt = (price ?? 0) * rate;
const priceKztEffective = item?.priceKztOverride !== undefined ? item.priceKztOverride : priceKzt;
const kztTotal = posOffers.reduce((sum, o) => sum + (o.totalWithExtraKzt ?? 0), 0);
const isMin = minTotal !== undefined && kztTotal === minTotal && kztTotal > 0;
const isSel = s.id === selectedSupplierId;
const selCls = isSel ? styles.colSelected : '';
// Перевод в ед. изм. заявки (тумблер) — как в обычных позициях.
const reqUnit = pos.unit;
const supUnit = item?.unit || pos.unit;
const tRatio = translateUnits && !unitsEquivalent(supUnit, reqUnit) ? convertQuantity(1, supUnit, reqUnit) : null;
const cannotTranslate = translateUnits && !unitsEquivalent(supUnit, reqUnit) && tRatio === null;
const dispUnit = tRatio !== null ? reqUnit : (item?.unit || pos.unit);
const dispQty = (tRatio !== null && qty !== undefined) ? qty * tRatio : qty;
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
const showInput = !isReadOnly && !translateUnits;
return (
<React.Fragment key={s.id}>
<td className={[styles.dataCell, styles.dataCellName, selCls].filter(Boolean).join(' ')} style={{ width: nameColWidth, minWidth: nameColWidth }}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={item?.supplierProductName || pos.materialName}
onChange={val => updateSupplierProductName(s.id, pos.id, 0, val)}
borderless
ariaLabel={`Наименование ТМЦ ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{item?.supplierProductName || pos.materialName}</span>
)}
</td>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={item?.unit || pos.unit}
onChange={val => updateUnit(s.id, pos.id, 0, val)}
borderless
ariaLabel={`Ед. изм. ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{(dispUnit || '—') + (cannotTranslate ? ' *' : '')}</span>
)}
</td>
<td className={[styles.dataCell, ep?.isDelivery ? styles.deliveryQtyCell : '', selCls].filter(Boolean).join(' ')}>
{ep?.isDelivery ? (
<div className={styles.deliveryQtyFlex}>
<div className={styles.deliveryQtyBox}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={rawValues[`${s.id}::${pos.id}::0::quantity`] ?? (qty !== undefined ? String(qty) : '')}
onChange={val => updateEdit(s.id, pos.id, 0, 'quantity', val)}
placeholder="рейсы"
borderless
ariaLabel={`Кол-во рейсов ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{dispQty !== undefined ? formatQty(dispQty) : '—'}</span>
)}
</div>
<div className={styles.deliveryKmBox}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
type="number"
value={deliveryKm[`${pos.id}::${s.id}`] !== undefined ? String(deliveryKm[`${pos.id}::${s.id}`]) : ''}
onChange={val => handleDeliveryKmChange(pos.id, s.id, val)}
placeholder="—"
borderless
ariaLabel={`Километраж ${s.name}`}
/>
</div>
) : (
<span className={styles.computedValue}>{deliveryKm[`${pos.id}::${s.id}`] ?? '—'}</span>
)}
<span className={styles.kmSuffix}>км</span>
</div>
</div>
) : (
showInput ? (
<div className={styles.offerInput}>
<TextInput
value={rawValues[`${s.id}::${pos.id}::0::quantity`] ?? (qty !== undefined ? String(qty) : '')}
onChange={val => updateEdit(s.id, pos.id, 0, 'quantity', val)}
placeholder="—"
borderless
ariaLabel={`Кол-во ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{dispQty !== undefined ? formatQty(dispQty) : '—'}</span>
)
)}
</td>
{isForeign && (
<>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={rawValues[`${s.id}::${pos.id}::0::pricePerUnit`] ?? (price !== undefined ? String(price) : '')}
onChange={val => updateEdit(s.id, pos.id, 0, 'pricePerUnit', val)}
placeholder="0"
borderless
ariaLabel={`Цена ${currency} ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{dispPrice ? formatNum(dispPrice) : '—'}</span>
)}
</td>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
<span className={styles.totalValue}>{totalInCurrency > 0 ? formatNum(totalInCurrency) : '—'}</span>
</td>
</>
)}
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={
isForeign
? (rawValues[`${s.id}::${pos.id}::0::priceKzt`] ?? (priceKztEffective > 0 ? round2Str(priceKztEffective) : ''))
: (rawValues[`${s.id}::${pos.id}::0::pricePerUnit`] ?? (price !== undefined ? String(price) : ''))
}
onChange={val => isForeign
? updatePriceKzt(s.id, pos.id, 0, val)
: updateEdit(s.id, pos.id, 0, 'pricePerUnit', val)
}
placeholder="0"
borderless
ariaLabel={`Цена ₸ ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>
{isForeign ? (dispPriceKzt > 0 ? formatNum(dispPriceKzt) : '—') : (dispPrice ? formatNum(dispPrice) : '—')}
</span>
)}
</td>
<td className={[
styles.dataCell,
isMin ? styles.minPriceCell : '',
selCls,
].filter(Boolean).join(' ')}>
<span className={styles.totalValue}>{kztTotal > 0 ? formatKzt(kztTotal) : '—'}</span>
</td>
</React.Fragment>
);
})}
</tr>
);
}
@@ -0,0 +1,169 @@
import * as React from 'react';
import { Button, Alert, Spinner, Dialog, TextArea, TextInput } from '@tnsu/ui-kit';
import { ApprovalMemberStatus } from '../types/domain';
import { useStep3Approval } from './useStep3Approval';
import styles from './Step3ComparisonTable.module.scss';
const APPROVAL_STATUS_LABELS: Record<ApprovalMemberStatus, string> = {
pending: 'На согласовании',
recommends: 'Рекомендую',
approved: 'Согласовано',
rejected: 'Отклонено',
skipped: 'Пропущено',
};
const APPROVAL_STATUS_STYLE: Record<ApprovalMemberStatus, { bg: string; color: string }> = {
pending: { bg: '#f3f2f1', color: '#605e5c' },
recommends: { bg: '#dff6dd', color: '#107c10' },
approved: { bg: '#dff6dd', color: '#107c10' },
rejected: { bg: '#fde7e9', color: '#a4262c' },
skipped: { bg: '#edebe9', color: '#a19f9d' },
};
function formatApprovalDate(iso: string): string {
return new Date(iso).toLocaleString('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
interface Props {
approval: ReturnType<typeof useStep3Approval>;
isNotificationLink: boolean;
isReadOnly: boolean;
}
// Инлайн-цепочка согласования Шага 3 + диалоги (решение/делегат) + баннер уведомлений.
// Показывается только по Teams-ссылке (?ctid) в read-only. Логика — в useStep3Approval.
export const InlineApprovalChain: React.FC<Props> = ({ approval: a, isNotificationLink, isReadOnly }) => {
return (
<>
{a.notifyWarning && <Alert variant="warn">{a.notifyWarning}</Alert>}
{/* Цепочка согласования — только по Teams-ссылке (ctid); обычный reload → согласование на Шаге 4 */}
{isNotificationLink && isReadOnly && a.approvalChain.length > 0 && (
<div className={styles.approvalChainSection}>
<h4 className={styles.approvalChainTitle}>Цепочка согласования</h4>
<ol className={styles.approvalChainList}>
{a.approvalChain.map((member) => {
const st = APPROVAL_STATUS_STYLE[member.status];
return (
<li key={member.id} className={styles.approvalChainRow}>
<span className={styles.approvalChainOrder}>{member.order}</span>
<span className={styles.approvalChainName}>{member.userName}</span>
{member.role && <span className={styles.approvalChainRole}>{member.role}</span>}
<span className={styles.approvalChainStatusBadge} style={{ background: st.bg, color: st.color }}>
{APPROVAL_STATUS_LABELS[member.status]}
</span>
{member.comment && <span className={styles.approvalChainComment}>«{member.comment}»</span>}
{member.actionDate && <span className={styles.approvalChainDate}>{formatApprovalDate(member.actionDate)}</span>}
{a.isOnApproval && a.currentUserMember?.id === member.id && (
<div className={styles.approvalChainActions}>
{member.canAddApprover && (
<Button variant="secondary" onClick={() => a.openDelegate(member)}>
Добавить делегата
</Button>
)}
<Button variant="primary" onClick={() => a.openApprove(member)}>
Согласовать
</Button>
<Button variant="danger" onClick={() => a.openReject(member)}>
Отклонить
</Button>
</div>
)}
</li>
);
})}
</ol>
</div>
)}
{a.delegateDialog && (
<Dialog
open
title="Добавить делегата"
subText="Выбранный пользователь будет вставлен перед вами в цепочку согласования."
onDismiss={a.closeDelegate}
footer={
<>
<Button variant="secondary" onClick={a.closeDelegate} disabled={a.isDelegateSubmitting}>
Отмена
</Button>
<Button
variant="primary"
onClick={a.handleAddDelegate}
disabled={a.isDelegateSubmitting || !a.delegateDialog.selected}
>
{a.isDelegateSubmitting ? <Spinner size="small" /> : 'Добавить'}
</Button>
</>
}
>
<div className={styles.searchBox}>
<TextInput
placeholder="Поиск делегата (имя)…"
ariaLabel="Поиск делегата"
value={a.delegateDialog.query}
onChange={a.setDelegateQuery}
/>
{a.delegateDialog.suggestions.length > 0 && (
<ul className={styles.suggestions}>
{a.delegateDialog.suggestions.map((s) => (
<li key={s.spId} className={styles.suggestion} onClick={() => a.selectDelegate(s)}>
<span className={styles.suggestionName}>{s.displayName}</span>
<span className={styles.suggestionEmail}>{s.email}</span>
</li>
))}
</ul>
)}
</div>
{a.delegateError && (
<div className={styles.approvalDialogError}>
<Alert variant="err">{a.delegateError}</Alert>
</div>
)}
</Dialog>
)}
{a.actionDialog && (
<Dialog
open
title={a.actionDialog.action === 'approve' ? 'Согласование' : 'Отклонение'}
subText={
a.actionDialog.action === 'approve'
? 'Вы подтверждаете согласование СТ? Комментарий необязателен.'
: 'Укажите причину отклонения (обязательно).'
}
onDismiss={a.closeAction}
footer={
<>
<Button variant="secondary" onClick={a.closeAction} disabled={a.isApprovalSubmitting}>
Отмена
</Button>
<Button
variant={a.actionDialog.action === 'approve' ? 'primary' : 'danger'}
onClick={a.handleApprovalConfirm}
disabled={a.isApprovalSubmitting || (a.actionDialog.action === 'reject' && !a.actionDialog.comment.trim())}
>
{a.isApprovalSubmitting ? <Spinner size="small" /> : a.actionDialog.action === 'approve' ? 'Согласовать' : 'Отклонить'}
</Button>
</>
}
>
<TextArea
value={a.actionDialog.comment}
onChange={a.setActionComment}
rows={3}
ariaLabel="Комментарий"
placeholder={a.actionDialog.action === 'reject' ? 'Укажите причину отклонения...' : 'Комментарий (необязательно)...'}
/>
{a.approvalError && (
<div className={styles.approvalDialogError}>
<Alert variant="err">{a.approvalError}</Alert>
</div>
)}
</Dialog>
)}
</>
);
};
@@ -0,0 +1,240 @@
import * as React from 'react';
import { TextInput } from '@tnsu/ui-kit';
import { CurrencyRate } from '../types/domain';
import { PositionRow } from './matrixTypes';
import { Step3Matrix } from './useStep3Matrix';
import { convertQuantity, unitsEquivalent } from '../utils/unitConversion';
import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
pos: PositionRow;
posIndex: number;
matrix: Step3Matrix;
currencyRates: CurrencyRate[];
isRestrictedView: boolean;
}
// Одна обычная позиция матрицы: строка(и) с поддержкой комплекта (bundle) + подытог по позиции при
// комплекте. Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrictedView }: Props): JSX.Element {
const {
isReadOnly, edits, translateUnits, rawValues, posQtyOverrides, selectedSupplierId, nameColWidth,
suppliersWithOffer, supplierCurrencies, minTotalByPosition,
updatePosQty, updateSupplierProductName, updateUnit, updateEdit, updatePriceKzt,
removeBundleItem, addBundleItem,
} = matrix;
const maxSubRows = Math.max(1, ...suppliersWithOffer.map(s => {
const items = edits[buildOfferKey(s.id, pos.id)];
return items ? items.length : 1;
}));
const hasBundle = maxSubRows > 1;
const posRowSpan = hasBundle ? maxSubRows + 1 : maxSubRows;
const minTotal = minTotalByPosition.get(pos.id);
const rowEls: JSX.Element[] = [];
for (let rowIdx = 0; rowIdx < maxSubRows; rowIdx++) {
rowEls.push(
<tr key={rowIdx} className={[styles.positionRow, rowIdx > 0 ? styles.bundleSubRow : ''].filter(Boolean).join(' ')}>
{rowIdx === 0 && (
<>
<td className={styles.positionNameCell} rowSpan={posRowSpan}>
<span className={styles.positionIndex}>{posIndex + 1}.</span>{' '}{pos.materialName}
</td>
<td className={styles.unitCell} rowSpan={posRowSpan}>{pos.unit}</td>
<td className={styles.qtyCell} rowSpan={posRowSpan}>
{!isReadOnly ? (
<div className={styles.qtyInput}>
<TextInput
value={posQtyOverrides[pos.id] ?? String(pos.quantity)}
onChange={val => updatePosQty(pos.id, val)}
borderless
ariaLabel={`Кол-во ${pos.materialName}`}
/>
</div>
) : (
posQtyOverrides[pos.id] !== undefined ? parseFloat(posQtyOverrides[pos.id]) : pos.quantity
)}
</td>
</>
)}
{suppliersWithOffer.map(s => {
const key = buildOfferKey(s.id, pos.id);
const items = edits[key] ?? [{ supplierId: s.id, positionId: pos.id, itemIndex: 0, unit: pos.unit, supplierProductName: pos.materialName, quantity: pos.quantity || undefined }];
const item = items[rowIdx];
const currency = supplierCurrencies[s.id] ?? 'KZT';
const isForeign = currency !== 'KZT';
const isSel = s.id === selectedSupplierId;
const selCls = isSel ? styles.colSelected : '';
const isLastItemRow = rowIdx === items.length - 1;
if (!item) {
return <td key={s.id} colSpan={getSubCols(currency, isRestrictedView)} className={[styles.dataCell, selCls].filter(Boolean).join(' ')} />;
}
const rate = isForeign ? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1) : 1;
const qty = item.quantity ?? ((s.isNoOffer || s.isExtraSupplier) && pos.quantity ? pos.quantity : undefined);
const price = item.pricePerUnit;
const totalInCurrency = (price ?? 0) * (qty ?? 0);
const priceKzt = (price ?? 0) * rate;
const priceKztEffective = item.priceKztOverride !== undefined ? item.priceKztOverride : priceKzt;
const kztTotal = priceKztEffective * (qty ?? 0);
// Перевод в ед. изм. заявки (тумблер). tRatio = коэффициент кол-ва;
// null — единицы совпадают или пересчёт невозможен.
const reqUnit = pos.unit;
const supUnit = item.unit || pos.unit;
const tRatio = translateUnits && !unitsEquivalent(supUnit, reqUnit) ? convertQuantity(1, supUnit, reqUnit) : null;
const cannotTranslate = translateUnits && !unitsEquivalent(supUnit, reqUnit) && tRatio === null;
const dispUnit = tRatio !== null ? reqUnit : (item.unit || pos.unit);
const dispQty = (tRatio !== null && qty !== undefined) ? qty * tRatio : qty;
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
const showInput = !isReadOnly && !translateUnits;
return (
<React.Fragment key={s.id}>
<td className={[styles.dataCell, styles.dataCellName, selCls].filter(Boolean).join(' ')} style={{ width: nameColWidth, minWidth: nameColWidth }}>
<div className={styles.bundleNameCell}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={item.supplierProductName || pos.materialName}
onChange={val => updateSupplierProductName(s.id, pos.id, rowIdx, val)}
borderless
ariaLabel={`Наименование ТМЦ ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{item.supplierProductName || pos.materialName}</span>
)}
{showInput && (
<div className={styles.bundleBtns}>
{items.length > 1 && (
<button className={styles.bundleRemoveBtn} onClick={() => removeBundleItem(s.id, pos.id, rowIdx)} title="Удалить строку">×</button>
)}
{isLastItemRow && (
<button className={styles.bundleAddBtn} onClick={() => addBundleItem(s.id, pos.id)} title="Добавить строку комплекта">+</button>
)}
</div>
)}
</div>
</td>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={item.unit || pos.unit}
onChange={val => updateUnit(s.id, pos.id, rowIdx, val)}
borderless
ariaLabel={`Ед. изм. ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{(dispUnit || '—') + (cannotTranslate ? ' *' : '')}</span>
)}
</td>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={rawValues[`${s.id}::${pos.id}::${rowIdx}::quantity`] ?? (qty !== undefined ? String(qty) : '')}
onChange={val => updateEdit(s.id, pos.id, rowIdx, 'quantity', val)}
placeholder="—"
borderless
ariaLabel={`Кол-во ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{dispQty !== undefined ? formatQty(dispQty) : '—'}</span>
)}
</td>
{!isRestrictedView && isForeign && (
<>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={rawValues[`${s.id}::${pos.id}::${rowIdx}::pricePerUnit`] ?? (price !== undefined ? String(price) : '')}
onChange={val => updateEdit(s.id, pos.id, rowIdx, 'pricePerUnit', val)}
placeholder="0"
borderless
ariaLabel={`Цена ${currency} ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{dispPrice ? formatNum(dispPrice) : '—'}</span>
)}
</td>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
<span className={styles.totalValue}>{totalInCurrency > 0 ? formatNum(totalInCurrency) : '—'}</span>
</td>
</>
)}
{!isRestrictedView && <td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={
isForeign
? (rawValues[`${s.id}::${pos.id}::${rowIdx}::priceKzt`] ?? (priceKztEffective > 0 ? round2Str(priceKztEffective) : ''))
: (rawValues[`${s.id}::${pos.id}::${rowIdx}::pricePerUnit`] ?? (price !== undefined ? String(price) : ''))
}
onChange={val => isForeign
? updatePriceKzt(s.id, pos.id, rowIdx, val)
: updateEdit(s.id, pos.id, rowIdx, 'pricePerUnit', val)
}
placeholder="0"
borderless
ariaLabel={`Цена ₸ ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>
{isForeign ? (dispPriceKzt > 0 ? formatNum(dispPriceKzt) : '—') : (dispPrice ? formatNum(dispPrice) : '—')}
</span>
)}
</td>}
{!isRestrictedView && <td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
<span className={styles.totalValue}>{kztTotal > 0 ? formatKzt(kztTotal) : '—'}</span>
</td>}
</React.Fragment>
);
})}
</tr>
);
}
// Bundle subtotal row — shows position total per supplier when there are multiple items
if (hasBundle) {
rowEls.push(
<tr key="subtotal" className={styles.bundleSubtotalRow}>
{suppliersWithOffer.map(s => {
const key = buildOfferKey(s.id, pos.id);
const items = edits[key] ?? [];
const currency = supplierCurrencies[s.id] ?? 'KZT';
const isForeign = currency !== 'KZT';
const isSel = s.id === selectedSupplierId;
const selCls = isSel ? styles.colSelected : '';
const subCols = getSubCols(currency, isRestrictedView);
const rate = isForeign ? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1) : 1;
const totalKzt = items.reduce((sum, it) => sum + (it.pricePerUnit ?? 0) * rate * (it.quantity ?? 0), 0);
const isMin = minTotal !== undefined && totalKzt === minTotal && totalKzt > 0;
return (
<React.Fragment key={s.id}>
<td colSpan={isRestrictedView ? subCols : subCols - 1} className={[styles.bundleSubtotalCell, selCls].filter(Boolean).join(' ')}>
<span className={styles.bundleSubtotalLabel}>Итого по позиции:</span>
</td>
{!isRestrictedView && <td className={[styles.bundleSubtotalCell, isMin ? styles.minPriceCell : '', selCls].filter(Boolean).join(' ')}>
<span className={styles.totalValue}>{totalKzt > 0 ? formatKzt(totalKzt) : '—'}</span>
</td>}
</React.Fragment>
);
})}
</tr>
);
}
return <React.Fragment>{rowEls}</React.Fragment>;
}

Some files were not shown because too many files have changed in this diff Show More