Compare commits

...

4 Commits

Author SHA1 Message Date
Тимур Городецкий 2591d94414 docs: CHANGELOG — заметное добавление поставщика (#12)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 08:59:42 +05:00
Тимур Городецкий 2eaa75e876 feat(ct): заметное добавление потенциального поставщика на Шаге 3 (#12)
Блок добавления не замечали: поле без подписи и серая заблокированная кнопка
читались как «не работает», а сам блок стоял в зоне настроек. Теперь у блока
заголовок и подсказка, кнопка «Добавить» не блокируется (с пустым названием
подсказывает, что ввести), «Из базы поставщиков» рядом. И вторая точка входа
там, где ищут, — колонка «+ Добавить поставщика» в шапке таблицы с окном
«вручную или из базы». Добавление вручную — одна функция на обе кнопки.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 08:59:10 +05:00
Тимур Городецкий eaef011b73 docs: CHANGELOG — своя единица у доп. расходов (#11)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 17:36:01 +05:00
Тимур Городецкий 99c720f26c feat(ct): своя единица у логистики и доп. расходов на Шаге 3 (#11)
У логистики единица была зашита — «рейс», у доп. расходов её задавали только
при добавлении. Теперь единица строки — поле ввода; поставщики, у которых её
не меняли, получают новую, своя единица поставщика остаётся. Подсказка в
количестве логистики — единица строки. Строка уже в SP — единица обновляется
в «Позиции СТ» при сохранении.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 17:35:53 +05:00
13 changed files with 415 additions and 59 deletions
+4
View File
@@ -13,6 +13,10 @@
и оставляет на шаге, чтобы правки не пропали при закрытии вкладки. (#8)
- СТ ОМТС, Шаг 3: незаполненное подсвечивается сразу. Красная рамка — то, без чего дальше не
пустит (победитель, километраж, условия оплаты); бледная заливка — пустая цена. (#9)
- СТ ОМТС, Шаг 3: у логистики и доп. расходов можно вписать свою единицу измерения — у логистики
вместо «рейс» может быть «тонна», «машина» и т.п. (#11)
- СТ ОМТС, Шаг 3: добавить потенциального поставщика проще найти — блок над таблицей с заголовком
и активной кнопкой, и кнопка «+ Добавить поставщика» прямо в шапке таблицы. (#12)
### Изменено
@@ -59,6 +59,8 @@ export function AddExtraRow({ matrix, isRestrictedView, colSpan }: Props): JSX.E
{suppliersWithOffer.map(s => (
<td key={s.id} colSpan={getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView)} />
))}
{/* Под колонкой «+ Добавить поставщика» (видна там же, где эта строка, — не в режиме просмотра). */}
<td className={styles.addSupplierFillerCell} />
</tr>
);
}
@@ -0,0 +1,69 @@
import * as React from 'react';
import { useState } from 'react';
import { Button, Dialog, Icon, TextInput } from '@tnsu/ui-kit';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
currencyOptions: string[];
onAddManual: (name: string, currency: string) => void;
onOpenDatabase: () => void;
onClose: () => void;
}
const DEFAULT_CURRENCY = 'KZT';
// Окно кнопки «+ Добавить поставщика» в шапке таблицы: вручную (название и валюта) или из базы
// поставщиков. Добавление — те же функции, что у блока над таблицей.
export const AddSupplierDialog: React.FC<Props> = ({ currencyOptions, onAddManual, onOpenDatabase, onClose }) => {
const [name, setName] = useState('');
const [currency, setCurrency] = useState(DEFAULT_CURRENCY);
const canAdd = name.trim().length > 0;
const handleAdd = (): void => {
if (!canAdd) return;
onAddManual(name, currency);
onClose();
};
return (
<Dialog
open
title="Добавить потенциального поставщика"
subText="Поставщик, у которого нет загруженного КП: цены вносятся в таблицу вручную."
onDismiss={onClose}
footer={<Button variant="secondary" onClick={onClose}>Отмена</Button>}
>
<div className={styles.addSupplierDialogBody}>
<div className={styles.addSupplierRow}>
<div className={styles.addInput}>
<TextInput
value={name}
onChange={setName}
placeholder="Наименование поставщика"
ariaLabel="Наименование поставщика"
autoFocus
onKeyDown={e => { if (e.key === 'Enter') handleAdd(); }}
/>
</div>
<select
className={styles.currencySelect}
value={currency}
onChange={e => setCurrency(e.target.value)}
aria-label="Валюта поставщика"
>
{currencyOptions.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<Button variant="accent" onClick={handleAdd} disabled={!canAdd}>
<Icon name="plus" size="sm" />
Добавить
</Button>
</div>
<div className={styles.addSupplierDivider}>или</div>
<Button variant="secondary" onClick={() => { onClose(); onOpenDatabase(); }}>
<Icon name="building" size="sm" />
Выбрать из базы поставщиков
</Button>
</div>
</Dialog>
);
};
@@ -0,0 +1,82 @@
import * as React from 'react';
import { useState } from 'react';
import { Button, Icon, TextInput } from '@tnsu/ui-kit';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
name: string;
currency: string;
currencyOptions: string[];
onNameChange: (value: string) => void;
onCurrencyChange: (value: string) => void;
onAdd: () => void;
onOpenDatabase: () => void;
}
const NAME_INPUT_ID = 'step3-new-supplier-name';
// Блок «Потенциальные поставщики» над таблицей. Раньше это были поле, выпадашка и серая кнопка без
// подписи — их не замечали: заблокированная кнопка читалась как «не работает». Теперь у блока есть
// заголовок, кнопка не блокируется: с пустым названием подсказывает, что ввести.
export const AddSupplierToolbar: React.FC<Props> = ({
name, currency, currencyOptions, onNameChange, onCurrencyChange, onAdd, onOpenDatabase,
}) => {
const [isNameMissing, setIsNameMissing] = useState(false);
const handleAdd = (): void => {
if (!name.trim()) {
setIsNameMissing(true);
// Поле — в ui-kit, ref он не отдаёт; id задаём сами.
document.getElementById(NAME_INPUT_ID)?.focus();
return;
}
setIsNameMissing(false);
onAdd();
};
const handleNameChange = (value: string): void => {
if (value.trim()) setIsNameMissing(false);
onNameChange(value);
};
return (
<section className={styles.addSupplierBlock}>
<div className={styles.addSupplierHeading}>
<h4 className={styles.addSupplierTitle}>Потенциальные поставщики</h4>
<span className={styles.addSupplierHint}>
Добавьте поставщика, у которого нет загруженного КП, — вручную или из базы поставщиков
</span>
</div>
<div className={styles.addSupplierRow}>
<div className={styles.addInput}>
<TextInput
id={NAME_INPUT_ID}
placeholder="Наименование поставщика"
value={name}
onChange={handleNameChange}
ariaLabel="Наименование нового потенциального поставщика"
onKeyDown={e => { if (e.key === 'Enter') handleAdd(); }}
/>
</div>
<select
className={styles.currencySelect}
value={currency}
onChange={e => onCurrencyChange(e.target.value)}
aria-label="Валюта нового потенциального поставщика"
>
{currencyOptions.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<Button variant="accent" onClick={handleAdd}>
<Icon name="plus" size="sm" />
Добавить
</Button>
<span className={styles.addSupplierOr}>или</span>
<Button variant="secondary" onClick={onOpenDatabase}>
<Icon name="building" size="sm" />
Из базы поставщиков
</Button>
</div>
{isNameMissing && <span className={styles.addSupplierError}>Введите наименование поставщика</span>}
</section>
);
};
@@ -19,12 +19,14 @@ interface Props {
isRestrictedView: boolean;
adata: UseAdataCheckResult;
recommendedId?: string; // победитель формулы 80% цена / 20% условия оплаты (useRecommendedPick)
// Кнопка «+ Добавить поставщика» в последней колонке шапки.
onAddSupplierClick: () => void;
}
// Таблица сравнения целиком: шапка (фикс-колонки + заголовки поставщиков) + строки позиций
// (MatrixRowView/ExtraPositionRow) + итоговые строки + строка добавления (AddExtraRow).
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adata, recommendedId }: Props): JSX.Element {
export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adata, recommendedId, onAddSupplierClick }: Props): JSX.Element {
const {
isReadOnly, suppliersWithOffer, supplierCurrencies, selectedSupplierId,
supplierStatuses, nameColWidth, regularPositions, extraPositionsRows, allPositions, computedOffers,
@@ -37,7 +39,12 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
// Как в проверке перед сохранением (getStep3SaveError): выбор на исключённом — не победитель.
const hasWinner = suppliersWithOffer.some(s => s.id === selectedSupplierId);
const totalColSpan = FIXED_COLS + suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0);
// Последняя колонка «+ Добавить поставщика» — там, где ищут, как добавить колонку. Во всех строках
// под ней пустая ячейка (addSupplierFillerCell), иначе строки таблицы были бы разной длины.
const showAddColumn = !isReadOnly;
const totalColSpan = FIXED_COLS
+ suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0)
+ (showAddColumn ? 1 : 0);
// Высота row1 (шапка поставщика) переменная (теги валют, выбор поставщика, состояние Adata),
// а row2 прибит sticky сразу под ней. Измеряем реальную высоту и отдаём в CSS-переменную,
@@ -142,6 +149,13 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
</th>
);
})}
{showAddColumn && (
<th rowSpan={2} className={styles.addSupplierHeaderCell}>
<button className={styles.addSupplierHeaderBtn} onClick={onAddSupplierClick} title="Добавить потенциального поставщика">
+ Добавить поставщика
</button>
</th>
)}
</tr>
{/* Row 2: sub-headers — 4 cols for KZT, 6 cols for foreign currency */}
@@ -206,6 +220,7 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
</React.Fragment>
);
})}
{showAddColumn && <td className={styles.addSupplierFillerCell} />}
</tr>
{/* Доп. расходы positions — single row each (no bundle support needed) */}
@@ -242,6 +257,7 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
</React.Fragment>
);
})}
{showAddColumn && <td className={styles.addSupplierFillerCell} />}
</tr>
)}
@@ -265,6 +281,7 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
</React.Fragment>
);
})}
{showAddColumn && <td className={styles.addSupplierFillerCell} />}
</tr>
{/* Inline add-position row */}
@@ -25,7 +25,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
isReadOnly, edits, extraPositions, translateUnits, rawValues, deliveryKm, selectedSupplierId, nameColWidth,
suppliersWithOffer, supplierCurrencies, computedOffers, minTotalByPosition,
updateSupplierProductName, updateUnit, updateEdit, updatePriceKzt, handleDeliveryKmChange,
handleDeleteExtraPosition,
handleDeleteExtraPosition, handleExtraPositionUnitChange,
} = matrix;
const ep = extraPositions.find(e => e.id === pos.id);
@@ -47,7 +47,22 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
/>
)}
</td>
<td className={styles.unitCell}>{pos.unit}</td>
{/* Единица строки — своя (у логистики по умолчанию «рейс»: бывает «тонна», «машина», «услуга»). */}
<td className={styles.unitCell}>
{!isReadOnly ? (
<div className={styles.offerInput}>
<TextInput
value={pos.unit}
onChange={val => handleExtraPositionUnitChange(pos.id, val)}
placeholder="ед. изм."
borderless
ariaLabel={`Ед. изм. строки ${pos.materialName}`}
/>
</div>
) : (
pos.unit
)}
</td>
<td className={styles.qtyCell}>—</td>
{suppliersWithOffer.map(s => {
const key = key0(s);
@@ -120,9 +135,9 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
<TextInput
value={rawValues[`${s.id}::${pos.id}::0::quantity`] ?? (qty !== undefined ? String(qty) : '')}
onChange={val => updateEdit(s.id, pos.id, 0, 'quantity', val)}
placeholder="рейсы"
placeholder={pos.unit || 'кол-во'}
borderless
ariaLabel={`Кол-во рейсов ${s.name} ${pos.materialName}`}
ariaLabel={`Кол-во (${pos.unit || 'ед.'}) ${s.name} ${pos.materialName}`}
/>
</div>
) : (
@@ -219,6 +234,8 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
</React.Fragment>
);
})}
{/* Под колонкой «+ Добавить поставщика». */}
{!isReadOnly && <td className={styles.addSupplierFillerCell} />}
</tr>
);
}
@@ -218,6 +218,8 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
</React.Fragment>
);
})}
{/* Под колонкой «+ Добавить поставщика»: одна ячейка на позицию, включая строки комплекта. */}
{rowIdx === 0 && !isReadOnly && <td className={styles.addSupplierFillerCell} rowSpan={posRowSpan} />}
</tr>
);
}
@@ -866,20 +866,6 @@ $delivery-km-min-width: 76px;
color: $text;
}
.supplierToolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 4px 0 8px;
max-width: 30%;
> :first-child {
flex: 1 1 0;
min-width: 160px;
}
}
.addRow {
display: flex;
align-items: flex-end;
@@ -1369,3 +1355,94 @@ $delivery-km-min-width: 76px;
border-color: $danger;
color: $danger;
}
// ─── Добавление потенциального поставщика ───────────────────────────────────
// Блок над таблицей (AddSupplierToolbar): раньше без заголовка и с серой кнопкой — его не замечали.
.addSupplierBlock {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
border: 1px solid $accent-border;
border-radius: 4px;
background: $accent-bg;
}
.addSupplierHeading {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 12px;
}
.addSupplierTitle {
margin: 0;
font-size: 14px;
font-weight: 600;
color: $accent-dark;
}
.addSupplierHint {
font-size: 12px;
color: $muted;
}
.addSupplierRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.addSupplierOr {
font-size: 12px;
color: $muted;
}
.addSupplierError {
font-size: 12px;
color: $danger;
}
.addSupplierDialogBody {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
> .addSupplierRow { align-self: stretch; }
}
.addSupplierDivider {
font-size: 12px;
color: $muted;
}
// Последняя колонка таблицы «+ Добавить поставщика» — там, где ищут, как добавить колонку.
.addSupplierHeaderCell {
min-width: 140px;
vertical-align: middle;
text-align: center;
background: $head-bg;
border-left: 2px dashed $accent-border !important;
}
.addSupplierHeaderBtn {
padding: 8px 12px;
border: 1px dashed $accent;
border-radius: 4px;
background: $surface;
color: $accent;
font-family: inherit;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
&:hover { background: $accent-bg; }
}
.addSupplierFillerCell {
border-left: 2px dashed $accent-border !important;
}
@@ -1,6 +1,6 @@
import * as React from 'react';
import { useMemo, useState } from 'react';
import { Button, Alert, TextInput, Toggle, Spinner, Dialog, Icon } from '@tnsu/ui-kit';
import { Button, Alert, Toggle, Spinner, Dialog, Icon } from '@tnsu/ui-kit';
import { FormStep } from '../types/domain';
import { CtService } from '../../services/CtService';
import { VendorDatabaseService, IVendorRecord } from '../../services/VendorDatabaseService';
@@ -19,6 +19,8 @@ import { useAdataCheck } from './useAdataCheck';
import { useRecommendedPick } from './useRecommendedPick';
import { VendorPickerDialog } from './VendorPickerDialog';
import { ExcludedSuppliersList } from './ExcludedSuppliersList';
import { AddSupplierToolbar } from './AddSupplierToolbar';
import { AddSupplierDialog } from './AddSupplierDialog';
import { SaveDraftButton } from '../SaveDraftButton/SaveDraftButton';
import { withoutPlaceholders } from '../utils/placeholderKpFile';
@@ -58,7 +60,7 @@ export const Step3ComparisonTable: React.FC<Props> = ({
const matrix = useStep3Matrix({ service, formData, isReadOnly: !canEdit, onSave, onNext, onStepComplete });
const {
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError,
addManualSupplier, handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError,
validateBeforeSave, handleSave, draftSavedAt,
extraPositions, suppliersWithOffer, columnDisplayNames, allPositions, computedOffers, selectedSupplierId,
excludedColumns, handleRestoreSupplier,
@@ -70,6 +72,8 @@ export const Step3ComparisonTable: React.FC<Props> = ({
// Модалка выбора поставщика из «База данных поставщиков» (см. VendorPickerDialog).
const [showVendorPicker, setShowVendorPicker] = useState(false);
// Окно кнопки «+ Добавить поставщика» в шапке таблицы.
const [showAddSupplierDialog, setShowAddSupplierDialog] = useState(false);
const vendorDbService = useMemo(() => new VendorDatabaseService(service.webPartContext), [service]);
const handleAddVendorFromDatabase = (vendor: IVendorRecord): void => {
const id = handleAddSupplierFromDatabase(vendor);
@@ -117,35 +121,17 @@ export const Step3ComparisonTable: React.FC<Props> = ({
return (
<div className={styles.root}>
{/* Toolbar: add supplier — always visible above the table */}
{/* Добавление потенциального поставщика: блок над таблицей + кнопка в шапке таблицы. */}
{!isReadOnly && (
<div className={styles.supplierToolbar}>
<div className={styles.addInput}>
<TextInput
placeholder="Наименование потенциального поставщика"
value={newSupplierName}
onChange={setNewSupplierName}
ariaLabel="Наименование нового потенциального поставщика"
onKeyDown={e => { if (e.key === 'Enter') handleAddSupplier(); }}
<AddSupplierToolbar
name={newSupplierName}
currency={newSupplierCurrency}
currencyOptions={currencyOptions}
onNameChange={setNewSupplierName}
onCurrencyChange={setNewSupplierCurrency}
onAdd={handleAddSupplier}
onOpenDatabase={() => setShowVendorPicker(true)}
/>
</div>
<select
className={styles.currencySelect}
value={newSupplierCurrency}
onChange={e => setNewSupplierCurrency(e.target.value)}
aria-label="Валюта нового потенциального поставщика"
>
{currencyOptions.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<Button variant="secondary" onClick={handleAddSupplier} disabled={!newSupplierName.trim()}>
<Icon name="plus" size="sm" />
Добавить потенциального поставщика
</Button>
<Button variant="secondary" onClick={() => setShowVendorPicker(true)}>
<Icon name="building" size="sm" />
Добавить из базы поставщиков
</Button>
</div>
)}
{/* Тумблер пересчёта единиц измерения поставщика в ед. изм. заявки */}
@@ -185,7 +171,14 @@ export const Step3ComparisonTable: React.FC<Props> = ({
{recPick.pickError && <Alert variant="err">{recPick.pickError}</Alert>}
<ComparisonMatrix matrix={matrix} currencyRates={currencyRates} isRestrictedView={isRestrictedView} adata={adata} recommendedId={recPick.recommendedId} />
<ComparisonMatrix
matrix={matrix}
currencyRates={currencyRates}
isRestrictedView={isRestrictedView}
adata={adata}
recommendedId={recPick.recommendedId}
onAddSupplierClick={() => setShowAddSupplierDialog(true)}
/>
<CriteriaTable matrix={matrix} />
@@ -269,6 +262,15 @@ export const Step3ComparisonTable: React.FC<Props> = ({
</div>
</div>
{showAddSupplierDialog && (
<AddSupplierDialog
currencyOptions={currencyOptions}
onAddManual={addManualSupplier}
onOpenDatabase={() => setShowVendorPicker(true)}
onClose={() => setShowAddSupplierDialog(false)}
/>
)}
{showVendorPicker && (
<VendorPickerDialog
vendorDbService={vendorDbService}
@@ -1,7 +1,34 @@
import { applyCreatedIds, buildOfferKey, computeOffer, getSubCols, isMinPositionTotal, round2Str, sumPositionTotals } from './matrixHelpers';
import { ExtraPosition } from './matrixTypes';
import { applyCreatedIds, applyPositionUnitChange, buildOfferKey, computeOffer, getSubCols, isMinPositionTotal, round2Str, sumPositionTotals } from './matrixHelpers';
import { EditableOffers, ExtraPosition } from './matrixTypes';
import { SupplierOffer } from '../types/domain';
describe('applyPositionUnitChange (единица доп. позиции стала своей вместо «рейс»)', () => {
const edits: EditableOffers = {
'sup_a::extra_pos_1': [{ supplierId: 'sup_a', positionId: 'extra_pos_1', itemIndex: 0, unit: 'рейс', pricePerUnit: 50000 }],
'sup_b::extra_pos_1': [{ supplierId: 'sup_b', positionId: 'extra_pos_1', itemIndex: 0, unit: 'машина' }],
'sup_c::extra_pos_1': [{ supplierId: 'sup_c', positionId: 'extra_pos_1', itemIndex: 0 }],
'sup_a::pos_1': [{ supplierId: 'sup_a', positionId: 'pos_1', itemIndex: 0, unit: 'рейс' }],
};
const result = applyPositionUnitChange(edits, 'extra_pos_1', 'рейс', 'тонна');
it('у поставщика с прежней единицей позиции — новая, цена не теряется', () => {
expect(result['sup_a::extra_pos_1'][0]).toMatchObject({ unit: 'тонна', pricePerUnit: 50000 });
});
it('у поставщика без своей единицы — новая', () => {
expect(result['sup_c::extra_pos_1'][0].unit).toBe('тонна');
});
it('свою единицу поставщика не трогает', () => {
expect(result['sup_b::extra_pos_1'][0].unit).toBe('машина');
});
it('другие позиции не трогает, даже с той же единицей', () => {
expect(result['sup_a::pos_1'][0].unit).toBe('рейс');
});
});
describe('computeOffer', () => {
it('считает сумму в тенге для КП в тенге', () => {
const offer = computeOffer(
@@ -87,6 +87,25 @@ export function buildOfferKey(supplierId: string, positionId: string): string {
return `${supplierId}::${positionId}`;
}
// Поменяли единицу доп. позиции (логистика, доп. расходы): у поставщиков, у которых единица та же,
// что была у позиции (её не трогали), — новая. Свою единицу поставщика не меняем. Ячейки без правок
// и так показывают единицу позиции.
export function applyPositionUnitChange(
edits: EditableOffers,
positionId: string,
oldUnit: string,
newUnit: string,
): EditableOffers {
const next: EditableOffers = {};
Object.keys(edits).forEach(key => {
const isThisPosition = key.split('::')[1] === positionId;
next[key] = isThisPosition
? edits[key].map(item => (!item.unit || item.unit === oldUnit ? { ...item, unit: newUnit } : item))
: edits[key];
});
return next;
}
export interface ColumnOffersInput {
columns: Array<{ id: string; isExtraSupplier: boolean }>;
positions: PositionRow[];
@@ -4,7 +4,7 @@ import { SupplierOffer, CurrencyRate, FormStep, SupplierExclusion } from '../typ
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
import { applyCreatedIds, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers';
import { applyCreatedIds, applyPositionUnitChange, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers';
import { buildExcludedRecordsForSave } from '../utils/excludedColumns';
import { parseNumberInput } from '../utils/parseNumberInput';
import { getStep3SaveError } from './step3Validation';
@@ -587,16 +587,23 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
}
};
const handleAddSupplier = (): void => {
if (!newSupplierName.trim()) return;
// Поставщик, добавленный на Шаге 3 вручную (без КП). Общая точка для обеих кнопок добавления:
// блока над таблицей и колонки «+ Добавить поставщика» в самой таблице.
const addManualSupplier = (name: string, currency: string): void => {
if (isReadOnly || !name.trim()) return;
const id = `extra_sup_${++extraIdCounter}`;
const newEntry = { id, name: newSupplierName.trim(), currency: newSupplierCurrency };
const updated = [...extraSuppliers, newEntry];
const updated = [...extraSuppliers, { id, name: name.trim(), currency }];
setExtraSuppliers(updated);
setSupplierStatuses(prev => ({ ...prev, [id]: '' }));
onSave({ step3ExtraSuppliers: updated });
};
// Блок над таблицей: добавить из своих полей и очистить их.
const handleAddSupplier = (): void => {
if (!newSupplierName.trim()) return;
addManualSupplier(newSupplierName, newSupplierCurrency);
setNewSupplierName('');
setNewSupplierCurrency('KZT');
onSave({ step3ExtraSuppliers: updated });
};
// Добавление поставщика из VendorPickerDialog («База данных поставщиков»): та же механика
@@ -681,6 +688,17 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
onSave({ step3DeliveryKm: updated });
};
// Своя единица доп. позиции вместо заданной при добавлении (у логистики — «рейс»). Поставщики, у
// которых единицу не меняли, получают новую. В SP уходит при «Сохранить» (см. handleSave).
const handleExtraPositionUnitChange = (positionId: string, unit: string): void => {
if (isReadOnly) return;
const oldUnit = extraPositions.find(ep => ep.id === positionId)?.unit ?? '';
const updated = extraPositions.map(ep => (ep.id === positionId ? { ...ep, unit } : ep));
setExtraPositions(updated);
setEdits(prev => applyPositionUnitChange(prev, positionId, oldUnit, unit));
onSave({ step3ExtraPositions: updated });
};
const handleDeleteExtraPosition = (positionId: string): void => {
if (isReadOnly) return;
const updated = extraPositions.filter(ep => ep.id !== positionId);
@@ -760,6 +778,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
if (ep.spId) {
// Already created in SP on a previous save — reuse to avoid duplicate Позиции СТ rows.
extraPosSpIds[ep.id] = ep.spId;
// Единицу могли поменять после создания. Пишем всегда — строк доп. расходов единицы,
// а отслеживать «менялась ли» — лишнее состояние.
await service.updateCTPositionUnit(ep.spId, ep.unit);
continue;
}
const saved = await service.createCTPositions(ct.id, [{
@@ -986,12 +1007,14 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
handleRemoveCustomCriteria,
handleNameColResizeMouseDown,
handleStatusChange,
addManualSupplier,
handleAddSupplier,
handleAddSupplierFromDatabase,
handleAddPosition,
handleAddDelivery,
handleDeliveryKmChange,
handleDeleteExtraPosition,
handleExtraPositionUnitChange,
validateBeforeSave,
handleSave,
draftSavedAt,
@@ -397,6 +397,21 @@ export class CtService {
}));
}
// Единица доп. позиции Шага 3 (логистика, доп. расходы) — её можно поменять после создания.
// MERGE на «Позиции СТ» ресивер не блокирует (блокирует только на «Оценка поставщиков»).
async updateCTPositionUnit(positionSpId: number, unit: string): Promise<void> {
const res = await this.http.fetch(this._liById(LISTS.CT_POSITIONS, positionSpId), SPHttpClient.configurations.v1, {
method: "POST",
headers: {
"Content-Type": "application/json;odata=nometadata",
"IF-MATCH": "*",
"X-HTTP-Method": "MERGE",
},
body: JSON.stringify({ unit }),
});
if (!res.ok) throw new Error(`Ошибка обновления единицы позиции: ${res.status}`);
}
// Пакетное создание элементов списка через SharePoint OData $batch: вместо N POST —
// один HTTP-запрос (до 100 операций/changeset, при большем числе бьём на чанки).
// Возвращает Id созданных элементов В ТОМ ЖЕ порядке, что и bodies. Changeset атомарен: