diff --git a/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx b/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx index 60869e7..653a1df 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx @@ -11,6 +11,7 @@ import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTabl import { Step4Approval } from './Step4Approval/Step4Approval'; import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns'; import { buildOffersFromSpRecords } from './utils/spOffers'; +import { excludedItemsToFormData } from './utils/excludedColumns'; import { isUserInApprovalChain } from './utils/approvalAccess'; import { CtAccess, getCtAccess, isCtAuthor } from './utils/ctAccess'; import { noOfferRecordsToSuppliers, withNoOfferSuppliers } from './utils/noOfferSuppliers'; @@ -82,6 +83,12 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, const preferSp = loadedAccess.isReview; const positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined); const rawOffers = await service.loadFullOffersForPositions(positionSpIds); + // Исключённые из сравнения (docs/adr/0001). Не грузятся — СТ открываем без них, а не падаем: + // таблица сравнения важнее, но молча не глотаем. + const excludedItems = await service.loadExcludedOffers(initialCtId).catch(err => { + console.warn('[CtCreateForm] не удалось загрузить исключённых поставщиков:', err); + return []; + }); // Поставщики без КП этой СТ — для списка под таблицей сравнения (согласующий по ссылке). const noOfferFromSp = noOfferRecordsToSuppliers( await service.loadNoOfferSuppliers(initialCtId).catch(err => { @@ -103,7 +110,7 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, // 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 + const valutaIds = [...rawOffers, ...excludedItems] .map(o => o.valutaSpId) .filter((id): id is number => id !== undefined && id > 0) .filter((id, idx, arr) => arr.indexOf(id) === idx); @@ -154,6 +161,15 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, currencyCodeOf, fallbackOfferFileId: supplierSpId => offerIdBySupplierSpId.get(supplierSpId), }); + // Исключённые — отдельными колонками с exclusion: Шаг 3 их не сравнивает, но может вернуть. + // Колонка есть и среди обычных — её вернули, а уборка прежних исключённых строк не прошла: + // обычная важнее, иначе колонка задвоится. + const allExcluded = excludedItemsToFormData(excludedItems, currencyCodeOf); + const activeColumnIds = new Set(spSuppliers.map(s => s.id)); + const excluded = { + suppliers: allExcluded.suppliers.filter(s => !activeColumnIds.has(s.id)), + offers: allExcluded.offers.filter(o => !activeColumnIds.has(o.supplierId)), + }; // Reconstruct uploadedKpFiles for users without session (other users opening CT via link) let uploadedKpFiles = formData.uploadedKpFiles; @@ -176,8 +192,11 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, ...(preferSp || 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 in review mode (always fresh). - suppliers: withNoOfferSuppliers(formData.suppliers.length === 0 ? spSuppliers : formData.suppliers, noOfferFromSp), - ...(preferSp || formData.offers.length === 0 ? { offers } : {}), + suppliers: withNoOfferSuppliers( + formData.suppliers.length === 0 ? [...spSuppliers, ...excluded.suppliers] : formData.suppliers, + noOfferFromSp, + ), + ...(preferSp || formData.offers.length === 0 ? { offers: [...offers, ...excluded.offers] } : {}), ...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}), ...(uploadedKpFiles.length > 0 ? { uploadedKpFiles } : {}), ...(Object.keys(step3DeliveryKm).length > 0 ? { step3DeliveryKm } : {}), @@ -196,7 +215,7 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, // Автор открыл свою СТ — открываем шаги, до которых она уже дошла (позиции → 2, КП → 3, маршрут → 4). ensureHighestStep(reachableStep({ hasPositions: positions.length > 0, - hasKp: rawOffers.length > 0 || uploadedKpFiles.length > 0, + hasKp: rawOffers.length > 0 || excludedItems.length > 0 || uploadedKpFiles.length > 0, hasRoute: approvalMatrix.length > 0, })); if (ct.status === 'approved' || ct.status === 'rejected') { diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts index fbbdb7b..2ff4b8b 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts @@ -4,6 +4,7 @@ import { CTFormData } from '../hooks/useCtForm'; import { KpFile, NoOfferEntry } from './kpTypes'; import { buildSupplierColumnId } from '../utils/supplierColumns'; import { getNoOfferSuppliers } from '../utils/noOfferSuppliers'; +import { keepExclusions } from '../utils/excludedColumns'; export interface PersistStep2Args { service: CtService; @@ -77,7 +78,8 @@ export async function persistStep2(args: PersistStep2Args): Promise ({ id: s.localId, @@ -94,7 +96,11 @@ export async function persistStep2(args: PersistStep2Args): Promise { const pos = formData.selectedPositions.find(p => p.id === o.positionId); - const supplierSpId = newSuppliersWithOffer.find(s => s.id === o.supplierId)?.spId; + const column = newSuppliersWithOffer.find(s => s.id === o.supplierId); + const supplierSpId = column?.spId; + // Исключённый поставщик не попадает в «Оценку поставщиков» — его строки пишет Шаг 3 + // в «Исключённые предложения». + if (column?.exclusion) return null; if (pos?.spId === undefined || supplierSpId === undefined) return null; return { ...o, positionId: String(pos.spId), supplierId: String(supplierSpId) }; }) diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpProcessing.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpProcessing.ts index 4244e7e..ae90a69 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpProcessing.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpProcessing.ts @@ -5,6 +5,7 @@ import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from ' import { buildKpSuppliersAndOffers } from './step2Persist'; import { buildKpOffersToSave } from './kpOfferRecords'; import { buildKpStorageName } from '../utils/kpFileName'; +import { keepExclusions } from '../utils/excludedColumns'; let localIdCounter = 0; @@ -62,7 +63,7 @@ export function useKpProcessing( // Заменяем колонки этих же КП (поставщик + файл), остальные — сохраняем: другой КП того же // поставщика — отдельное предложение, своя колонка. const others = (fd.suppliers ?? []).filter(s => !kpSuppliers.some(k => k.id === s.id)); - patch.suppliers = [...others, ...kpSuppliers]; + patch.suppliers = [...others, ...keepExclusions(kpSuppliers, fd.suppliers ?? [])]; const kpSupplierIds = new Set(kpSuppliers.map(s => s.id)); const otherOffers = (fd.offers ?? []).filter(o => !kpSupplierIds.has(o.supplierId)); patch.offers = [...otherOffers, ...kpOffers]; diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ComparisonMatrix.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ComparisonMatrix.tsx index 930a10a..04743b7 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ComparisonMatrix.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ComparisonMatrix.tsx @@ -7,6 +7,7 @@ import { AddExtraRow } from './AddExtraRow'; import { SUPPLIER_STATUS_OPTIONS, getSubCols, formatKzt } from './matrixHelpers'; import { UseAdataCheckResult } from './useAdataCheck'; import { AdataIndicator } from './AdataIndicator'; +import { ExcludeSupplierDialog } from './ExcludeSupplierDialog'; import styles from './Step3ComparisonTable.module.scss'; const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во @@ -26,9 +27,13 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat const { isReadOnly, suppliersWithOffer, supplierCurrencies, selectedSupplierId, supplierStatuses, nameColWidth, regularPositions, extraPositionsRows, allPositions, computedOffers, - setSelectedSupplierId, handleDeleteSupplier, handleStatusChange, handleNameColResizeMouseDown, + setSelectedSupplierId, handleExcludeSupplier, handleStatusChange, handleNameColResizeMouseDown, } = matrix; + // Колонка, для которой открыто окно «Исключить из сравнения». + const [excludingSupplierId, setExcludingSupplierId] = React.useState(undefined); + const excludingSupplier = suppliersWithOffer.find(s => s.id === excludingSupplierId); + const totalColSpan = FIXED_COLS + suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0); // Высота row1 (шапка поставщика) переменная (теги валют, выбор поставщика, состояние Adata), @@ -115,8 +120,8 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat ) : ( @@ -257,6 +262,17 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat + + {excludingSupplier && ( + { + handleExcludeSupplier(excludingSupplier.id, reason); + setExcludingSupplierId(undefined); + }} + onCancel={() => setExcludingSupplierId(undefined)} + /> + )} ); } diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ExcludeSupplierDialog.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ExcludeSupplierDialog.tsx new file mode 100644 index 0000000..424c555 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ExcludeSupplierDialog.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { Button, Dialog, TextArea } from '@tnsu/ui-kit'; + +interface Props { + supplierName: string; + onConfirm: (reason: string) => void; + onCancel: () => void; +} + +const REASON_ROWS = 3; + +// Исключение поставщика из сравнения (docs/adr/0001): причина обязательна — по ней потом видно, +// почему поставщик не рассматривался. +export const ExcludeSupplierDialog: React.FC = ({ supplierName, onConfirm, onCancel }) => { + const [reason, setReason] = useState(''); + const isReasonEmpty = reason.trim().length === 0; + + return ( + + + + + } + > +