Compare commits
15 Commits
9da03f931c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 06dff47f70 | |||
| 842b9ad678 | |||
| 2591d94414 | |||
| 2eaa75e876 | |||
| eaef011b73 | |||
| 99c720f26c | |||
| 107110b2b8 | |||
| da2c86b4a9 | |||
| 09a0889741 | |||
| 5c21718914 | |||
| 78e5eec9b6 | |||
| ad76e59ca0 | |||
| 053e4d4c3e | |||
| 09b1df87c3 | |||
| 7341774e50 |
@@ -1 +1 @@
|
|||||||
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
|
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
|
|
||||||
## [Не выпущено]
|
## [Не выпущено]
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
|
||||||
|
- СТ ОМТС, Шаги 2 и 3: кнопка «Сохранить черновик» — записывает шаг в SharePoint без проверок
|
||||||
|
и оставляет на шаге, чтобы правки не пропали при закрытии вкладки. (#8)
|
||||||
|
- СТ ОМТС, Шаг 3: незаполненное подсвечивается сразу. Красная рамка — то, без чего дальше не
|
||||||
|
пустит (победитель, километраж, условия оплаты); бледная заливка — пустая цена. (#9)
|
||||||
|
- СТ ОМТС, Шаг 3: у логистики и доп. расходов можно вписать свою единицу измерения — у логистики
|
||||||
|
вместо «рейс» может быть «тонна», «машина» и т.п. (#11)
|
||||||
|
- СТ ОМТС, Шаг 3: добавить потенциального поставщика проще найти — блок над таблицей с заголовком
|
||||||
|
и активной кнопкой, и кнопка «+ Добавить поставщика» прямо в шапке таблицы. (#12)
|
||||||
|
|
||||||
### Изменено
|
### Изменено
|
||||||
|
|
||||||
- СТ ОМТС, Шаг 3: крестик у поставщика не удаляет его, а исключает из сравнения с обязательной
|
- СТ ОМТС, Шаг 3: крестик у поставщика не удаляет его, а исключает из сравнения с обязательной
|
||||||
@@ -16,9 +27,16 @@
|
|||||||
периода. Старую потребность можно найти, не загружая весь период. (#5)
|
периода. Старую потребность можно найти, не загружая весь период. (#5)
|
||||||
- СТ ОМТС, Шаг 3: без выбранного поставщика-победителя «Сохранить и продолжить» не пускает
|
- СТ ОМТС, Шаг 3: без выбранного поставщика-победителя «Сохранить и продолжить» не пускает
|
||||||
дальше. (#7)
|
дальше. (#7)
|
||||||
|
- СТ ОМТС, Шаг 3: условия оплаты обязательны для «Сохранить и продолжить» — раньше были помечены
|
||||||
|
обязательными, но сохранять не мешали. (#9)
|
||||||
|
|
||||||
### Исправлено
|
### Исправлено
|
||||||
|
|
||||||
|
- СТ ОМТС: у поставщика, добавленного на Шаге 3 вручную, не видно служебного файла `.txt` —
|
||||||
|
ни в списках КП на Шагах 2 и 3, ни в подписи колонки, ни в пакете для INCORE. (#10)
|
||||||
|
- СТ ОМТС, Шаг 3: поставщик, добавленный на шаге и сохранённый черновиком, не задваивается при
|
||||||
|
открытии СТ из реестра. (#8)
|
||||||
|
|
||||||
- СТ ОМТС, Шаг 1: позиции, выбранные до поиска или смены периода, больше не пропадают при
|
- СТ ОМТС, Шаг 1: позиции, выбранные до поиска или смены периода, больше не пропадают при
|
||||||
«Сохранить и продолжить». (#5)
|
«Сохранить и продолжить». (#5)
|
||||||
- СТ ОМТС: цена, скопированная с пробелами между разрядами («100 000»), считается целиком, а не
|
- СТ ОМТС: цена, скопированная с пробелами между разрядами («100 000»), считается целиком, а не
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ Info "Подключаем @tnsu/ui-kit..."
|
|||||||
# .npmrc: адрес registry для скоупа @tnsu (НЕ секрет, можно коммитить).
|
# .npmrc: адрес registry для скоупа @tnsu (НЕ секрет, можно коммитить).
|
||||||
# Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём).
|
# Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём).
|
||||||
$npmrc = @'
|
$npmrc = @'
|
||||||
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
|
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
|
||||||
'@
|
'@
|
||||||
$npmrc | Set-Content -Encoding UTF8 ".npmrc"
|
$npmrc | Set-Content -Encoding UTF8 ".npmrc"
|
||||||
Success ".npmrc создан (адрес registry @tnsu)."
|
Success ".npmrc создан (адрес registry @tnsu)."
|
||||||
@@ -282,7 +282,7 @@ try {
|
|||||||
} catch {
|
} catch {
|
||||||
Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc."
|
Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc."
|
||||||
Warn "Создай токен (Gitea → Настройки → Приложения → package: Read) и пропиши в ~/.npmrc:"
|
Warn "Создай токен (Gitea → Настройки → Приложения → package: Read) и пропиши в ~/.npmrc:"
|
||||||
Warn " //git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=ТВОЙ_ТОКЕН"
|
Warn " //git.tnsu.kz/api/packages/tansu/npm/:_authToken=ТВОЙ_ТОКЕН"
|
||||||
Warn "Потом выполни: npm install @tnsu/ui-kit"
|
Warn "Потом выполни: npm install @tnsu/ui-kit"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+4
-4
@@ -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.11.0",
|
"@tnsu/ui-kit": "^0.14.0",
|
||||||
"ncalayer-js-client": "^1.6.0",
|
"ncalayer-js-client": "^1.6.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
@@ -6269,9 +6269,9 @@
|
|||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/@tnsu/ui-kit": {
|
"node_modules/@tnsu/ui-kit": {
|
||||||
"version": "0.11.0",
|
"version": "0.14.0",
|
||||||
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.11.0/ui-kit-0.11.0.tgz",
|
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.14.0/ui-kit-0.14.0.tgz",
|
||||||
"integrity": "sha512-Yv4hUmCsDNvGMj32ygNhBZiurhe5lU0IHvWSZMkG15h+xmCBmsTzgrItmKenKkKiOM2R4cjOo4D2W8SsTNHleQ==",
|
"integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@fluentui/react": "^8.106.4",
|
"@fluentui/react": "^8.106.4",
|
||||||
|
|||||||
+1
-1
@@ -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.11.0",
|
"@tnsu/ui-kit": "^0.14.0",
|
||||||
"ncalayer-js-client": "^1.6.0",
|
"ncalayer-js-client": "^1.6.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSe
|
|||||||
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
|
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
|
||||||
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
|
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
|
||||||
import { Step4Approval } from './Step4Approval/Step4Approval';
|
import { Step4Approval } from './Step4Approval/Step4Approval';
|
||||||
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findWinnerColumnId } from './utils/supplierColumns';
|
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findWinnerColumnId, withoutSessionExtras } from './utils/supplierColumns';
|
||||||
import { buildOffersFromSpRecords } from './utils/spOffers';
|
import { buildOffersFromSpRecords } from './utils/spOffers';
|
||||||
import { excludedItemsToFormData } from './utils/excludedColumns';
|
import { excludedItemsToFormData } from './utils/excludedColumns';
|
||||||
import { isUserInApprovalChain } from './utils/approvalAccess';
|
import { isUserInApprovalChain } from './utils/approvalAccess';
|
||||||
@@ -122,16 +122,22 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
// КП в разных валютах. Используются, только когда в сессии колонок нет.
|
// КП в разных валютах. Используются, только когда в сессии колонок нет.
|
||||||
const currencyCodeOf = (valutaSpId?: number): string =>
|
const currencyCodeOf = (valutaSpId?: number): string =>
|
||||||
valutaSpId ? (rateById.get(valutaSpId)?.code ?? 'KZT') : 'KZT';
|
valutaSpId ? (rateById.get(valutaSpId)?.code ?? 'KZT') : 'KZT';
|
||||||
const spSuppliers = buildColumnsFromSpOffers(rawOffers.map(o => ({
|
// Поставщики Шага 3 из сессии (после «Сохранить черновик») уже лежат в SP — их колонки из SP
|
||||||
|
// не дублируем.
|
||||||
|
const sessionExtras = formData.step3ExtraSuppliers ?? [];
|
||||||
|
const spSuppliers = withoutSessionExtras(buildColumnsFromSpOffers(rawOffers.map(o => ({
|
||||||
supplierSpId: o.supplierSpId,
|
supplierSpId: o.supplierSpId,
|
||||||
supplierName: o.supplierName,
|
supplierName: o.supplierName,
|
||||||
offerFileSpId: o.offerFileSpId,
|
offerFileSpId: o.offerFileSpId,
|
||||||
currencyCode: currencyCodeOf(o.valutaSpId),
|
currencyCode: currencyCodeOf(o.valutaSpId),
|
||||||
})));
|
}))), sessionExtras);
|
||||||
const supplierMap = new Map<number, string>();
|
const supplierMap = new Map<number, string>();
|
||||||
rawOffers.forEach(o => { if (!supplierMap.has(o.supplierSpId)) supplierMap.set(o.supplierSpId, o.supplierName); });
|
rawOffers.forEach(o => { if (!supplierMap.has(o.supplierSpId)) supplierMap.set(o.supplierSpId, o.supplierName); });
|
||||||
// Колонка оффера: из сессии, если она есть (в т.ч. старая колонка без файла КП), иначе — из SP.
|
// Колонка оффера: из сессии, если она есть (в т.ч. старая колонка без файла КП), иначе — из SP.
|
||||||
const visibleColumns = formData.suppliers.length > 0 ? formData.suppliers : spSuppliers;
|
// Поставщики Шага 3 — тоже колонки: их офферы из SP должны попасть к ним, а не в убранную колонку.
|
||||||
|
const visibleColumns = formData.suppliers.length > 0
|
||||||
|
? formData.suppliers
|
||||||
|
: [...spSuppliers, ...sessionExtras.map(es => ({ id: es.id, spId: es.spId, name: es.name, hasOffer: true }))];
|
||||||
const columnIdOf = (supplierSpId: number, offerFileSpId?: number): string =>
|
const columnIdOf = (supplierSpId: number, offerFileSpId?: number): string =>
|
||||||
findColumnForRecord(visibleColumns, supplierSpId, offerFileSpId)?.id
|
findColumnForRecord(visibleColumns, supplierSpId, offerFileSpId)?.id
|
||||||
?? buildSupplierColumnId(supplierSpId, offerFileSpId);
|
?? buildSupplierColumnId(supplierSpId, offerFileSpId);
|
||||||
@@ -191,8 +197,9 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
noOfferFromSp,
|
noOfferFromSp,
|
||||||
);
|
);
|
||||||
// Победитель обязателен для «Сохранить» на Шаге 3: без сессии берём его из шапки СТ, иначе
|
// Победитель обязателен для «Сохранить» на Шаге 3: без сессии берём его из шапки СТ, иначе
|
||||||
// в другом браузере пришлось бы выбирать заново.
|
// в другом браузере пришлось бы выбирать заново. Только когда сессии нет (колонки из SP):
|
||||||
const winnerColumnId = formData.recommendedSupplierId === undefined
|
// в сессии «не выбран» может быть осознанно снятым выбором — его SP не перебивает.
|
||||||
|
const winnerColumnId = formData.suppliers.length === 0
|
||||||
? findWinnerColumnId(suppliers, ct.winSupplierSpId)
|
? findWinnerColumnId(suppliers, ct.winSupplierSpId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
@import '../common/theme';
|
||||||
|
|
||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.savedAt {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $muted;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Button, Spinner } from '@tnsu/ui-kit';
|
||||||
|
import styles from './SaveDraftButton.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSave: () => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
// Подсказка, почему кнопка недоступна.
|
||||||
|
disabledReason?: string;
|
||||||
|
// Время последнего сохранения черновика в этой вкладке.
|
||||||
|
savedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIME_FORMAT: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
|
||||||
|
|
||||||
|
// «Сохранить черновик» на шагах СТ: записать недоделанный шаг в SharePoint без проверок и остаться
|
||||||
|
// на шаге — чтобы правки не пропали при закрытии вкладки и были видны на другом компьютере.
|
||||||
|
export const SaveDraftButton: React.FC<Props> = ({ onSave, isSaving, disabled = false, disabledReason, savedAt }) => (
|
||||||
|
<div className={styles.root}>
|
||||||
|
{savedAt && (
|
||||||
|
<span className={styles.savedAt}>
|
||||||
|
Черновик сохранён в {savedAt.toLocaleTimeString('ru-RU', TIME_FORMAT)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={disabled || isSaving}
|
||||||
|
title={disabled ? disabledReason : 'Сохранить в SharePoint и остаться на этом шаге'}
|
||||||
|
>
|
||||||
|
{isSaving ? <Spinner size="small" /> : 'Сохранить черновик'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
+27
-6
@@ -13,6 +13,8 @@ import { SuppliersWithoutKp } from './SuppliersWithoutKp';
|
|||||||
import { UploadedKpList } from './UploadedKpList';
|
import { UploadedKpList } from './UploadedKpList';
|
||||||
import { removeKpFileFromComparison } from './kpRemoval';
|
import { removeKpFileFromComparison } from './kpRemoval';
|
||||||
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
|
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
|
||||||
|
import { SaveDraftButton } from '../SaveDraftButton/SaveDraftButton';
|
||||||
|
import { withoutPlaceholders } from '../utils/placeholderKpFile';
|
||||||
import styles from './Step2CommercialOffers.module.scss';
|
import styles from './Step2CommercialOffers.module.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -50,6 +52,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | undefined>(undefined);
|
const [saveError, setSaveError] = useState<string | undefined>(undefined);
|
||||||
|
const [draftSavedAt, setDraftSavedAt] = useState<Date | undefined>(undefined);
|
||||||
|
|
||||||
const kp = useKpProcessing(service, formData, onSave);
|
const kp = useKpProcessing(service, formData, onSave);
|
||||||
|
|
||||||
@@ -136,12 +139,18 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
kp.handleFileRemove(localId);
|
kp.handleFileRemove(localId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
// 'draft' — записать шаг в SP и остаться: распознанные КП попадают в SP только здесь, и без
|
||||||
|
// черновика пропадали при закрытии вкладки.
|
||||||
|
const handleSave = async (mode: 'next' | 'draft' = 'next'): Promise<void> => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setSaveError(undefined);
|
setSaveError(undefined);
|
||||||
try {
|
try {
|
||||||
const patch = await persistStep2({ service, formData, kpFiles: kp.kpFiles, currencyRates, noOfferSuppliers });
|
const patch = await persistStep2({ service, formData, kpFiles: kp.kpFiles, currencyRates, noOfferSuppliers });
|
||||||
onSave(patch);
|
onSave(patch);
|
||||||
|
if (mode === 'draft') {
|
||||||
|
setDraftSavedAt(new Date());
|
||||||
|
return;
|
||||||
|
}
|
||||||
onStepComplete(2);
|
onStepComplete(2);
|
||||||
onNext();
|
onNext();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -152,6 +161,8 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const canProceed = !isSaving && currencyRates.every(r => r.rateToKzt > 0);
|
const canProceed = !isSaving && currencyRates.every(r => r.rateToKzt > 0);
|
||||||
|
// Заглушки поставщиков, добавленных на Шаге 3 вручную, — не КП: в списке не показываем.
|
||||||
|
const realKpFiles = withoutPlaceholders(formData.uploadedKpFiles ?? []);
|
||||||
const currencyOptions = ['KZT', ...currencyRates.filter(r => r.code !== 'KZT').map(r => r.code)];
|
const currencyOptions = ['KZT', ...currencyRates.filter(r => r.code !== 'KZT').map(r => r.code)];
|
||||||
|
|
||||||
const uploadedFiles: UploadedFile[] = kp.kpFiles.map(f => ({
|
const uploadedFiles: UploadedFile[] = kp.kpFiles.map(f => ({
|
||||||
@@ -193,9 +204,9 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
|
|
||||||
<SuppliersWithoutKp items={noOfferSuppliers} onChange={handleNoOfferChange} disabled={isReadOnly} />
|
<SuppliersWithoutKp items={noOfferSuppliers} onChange={handleNoOfferChange} disabled={isReadOnly} />
|
||||||
|
|
||||||
{!isRestrictedView && (formData.uploadedKpFiles?.length ?? 0) > 0 && (
|
{!isRestrictedView && realKpFiles.length > 0 && (
|
||||||
<UploadedKpList
|
<UploadedKpList
|
||||||
files={formData.uploadedKpFiles}
|
files={realKpFiles}
|
||||||
service={service}
|
service={service}
|
||||||
currencyOptions={currencyOptions}
|
currencyOptions={currencyOptions}
|
||||||
getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)}
|
getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)}
|
||||||
@@ -212,9 +223,19 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
<div className={styles.footerRight}>
|
<div className={styles.footerRight}>
|
||||||
{saveError && <Alert variant="err">{saveError}</Alert>}
|
{saveError && <Alert variant="err">{saveError}</Alert>}
|
||||||
{canEdit && !isReadOnly && (
|
{canEdit && !isReadOnly && (
|
||||||
<Button variant="accent" onClick={handleSave} disabled={!canProceed}>
|
<>
|
||||||
{isSaving ? <Spinner size="small" /> : 'Сохранить и продолжить'}
|
<SaveDraftButton
|
||||||
</Button>
|
onSave={() => { handleSave('draft').catch(() => undefined /* ошибку показывает handleSave */); }}
|
||||||
|
isSaving={isSaving}
|
||||||
|
// Курсы пишутся в общий список «Курс Валют» — нулевой курс туда писать нельзя.
|
||||||
|
disabled={!canProceed}
|
||||||
|
disabledReason="Укажите курсы всех валют"
|
||||||
|
savedAt={draftSavedAt}
|
||||||
|
/>
|
||||||
|
<Button variant="accent" onClick={() => { handleSave().catch(() => undefined /* ошибку показывает handleSave */); }} disabled={!canProceed}>
|
||||||
|
{isSaving ? <Spinner size="small" /> : 'Сохранить и продолжить'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export function AddExtraRow({ matrix, isRestrictedView, colSpan }: Props): JSX.E
|
|||||||
{suppliersWithOffer.map(s => (
|
{suppliersWithOffer.map(s => (
|
||||||
<td key={s.id} colSpan={getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView)} />
|
<td key={s.id} colSpan={getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView)} />
|
||||||
))}
|
))}
|
||||||
|
{/* Под колонкой «+ Добавить поставщика» (видна там же, где эта строка, — не в режиме просмотра). */}
|
||||||
|
<td className={styles.addSupplierFillerCell} />
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+69
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+82
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+27
-3
@@ -19,12 +19,14 @@ interface Props {
|
|||||||
isRestrictedView: boolean;
|
isRestrictedView: boolean;
|
||||||
adata: UseAdataCheckResult;
|
adata: UseAdataCheckResult;
|
||||||
recommendedId?: string; // победитель формулы 80% цена / 20% условия оплаты (useRecommendedPick)
|
recommendedId?: string; // победитель формулы 80% цена / 20% условия оплаты (useRecommendedPick)
|
||||||
|
// Кнопка «+ Добавить поставщика» в последней колонке шапки.
|
||||||
|
onAddSupplierClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Таблица сравнения целиком: шапка (фикс-колонки + заголовки поставщиков) + строки позиций
|
// Таблица сравнения целиком: шапка (фикс-колонки + заголовки поставщиков) + строки позиций
|
||||||
// (MatrixRowView/ExtraPositionRow) + итоговые строки + строка добавления (AddExtraRow).
|
// (MatrixRowView/ExtraPositionRow) + итоговые строки + строка добавления (AddExtraRow).
|
||||||
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
|
// Перенесено 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 {
|
const {
|
||||||
isReadOnly, suppliersWithOffer, supplierCurrencies, selectedSupplierId,
|
isReadOnly, suppliersWithOffer, supplierCurrencies, selectedSupplierId,
|
||||||
supplierStatuses, nameColWidth, regularPositions, extraPositionsRows, allPositions, computedOffers,
|
supplierStatuses, nameColWidth, regularPositions, extraPositionsRows, allPositions, computedOffers,
|
||||||
@@ -34,8 +36,15 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
|||||||
// Колонка, для которой открыто окно «Исключить из сравнения».
|
// Колонка, для которой открыто окно «Исключить из сравнения».
|
||||||
const [excludingSupplierId, setExcludingSupplierId] = React.useState<string | undefined>(undefined);
|
const [excludingSupplierId, setExcludingSupplierId] = React.useState<string | undefined>(undefined);
|
||||||
const excludingSupplier = suppliersWithOffer.find(s => s.id === excludingSupplierId);
|
const excludingSupplier = suppliersWithOffer.find(s => s.id === excludingSupplierId);
|
||||||
|
// Как в проверке перед сохранением (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),
|
// Высота row1 (шапка поставщика) переменная (теги валют, выбор поставщика, состояние Adata),
|
||||||
// а row2 прибит sticky сразу под ней. Измеряем реальную высоту и отдаём в CSS-переменную,
|
// а row2 прибит sticky сразу под ней. Измеряем реальную высоту и отдаём в CSS-переменную,
|
||||||
@@ -113,7 +122,12 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
|||||||
{!isReadOnly ? (
|
{!isReadOnly ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className={[styles.selectSupplierBtn, isSel ? styles.selectSupplierBtnActive : ''].filter(Boolean).join(' ')}
|
className={[
|
||||||
|
styles.selectSupplierBtn,
|
||||||
|
isSel ? styles.selectSupplierBtnActive : '',
|
||||||
|
// Победитель не выбран — красная рамка у всех «Выбрать»: без него дальше Шага 3 не пускает.
|
||||||
|
!hasWinner ? styles.selectSupplierBtnRequired : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
onClick={() => setSelectedSupplierId(toggleWinner(selectedSupplierId, s.id))}
|
onClick={() => setSelectedSupplierId(toggleWinner(selectedSupplierId, s.id))}
|
||||||
aria-pressed={isSel}
|
aria-pressed={isSel}
|
||||||
title={isSel ? 'Снять выбор' : 'Выбрать победителем'}
|
title={isSel ? 'Снять выбор' : 'Выбрать победителем'}
|
||||||
@@ -135,6 +149,13 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
|||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{showAddColumn && (
|
||||||
|
<th rowSpan={2} className={styles.addSupplierHeaderCell}>
|
||||||
|
<button className={styles.addSupplierHeaderBtn} onClick={onAddSupplierClick} title="Добавить потенциального поставщика">
|
||||||
|
+ Добавить поставщика
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{/* Row 2: sub-headers — 4 cols for KZT, 6 cols for foreign currency */}
|
{/* Row 2: sub-headers — 4 cols for KZT, 6 cols for foreign currency */}
|
||||||
@@ -199,6 +220,7 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{showAddColumn && <td className={styles.addSupplierFillerCell} />}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{/* Доп. расходы positions — single row each (no bundle support needed) */}
|
{/* Доп. расходы positions — single row each (no bundle support needed) */}
|
||||||
@@ -235,6 +257,7 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{showAddColumn && <td className={styles.addSupplierFillerCell} />}
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -258,6 +281,7 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{showAddColumn && <td className={styles.addSupplierFillerCell} />}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{/* Inline add-position row */}
|
{/* Inline add-position row */}
|
||||||
|
|||||||
+29
-7
@@ -5,6 +5,7 @@ import { PositionRow } from './matrixTypes';
|
|||||||
import { Step3Matrix } from './useStep3Matrix';
|
import { Step3Matrix } from './useStep3Matrix';
|
||||||
import { getUnitStatus } from '../utils/unitConversion';
|
import { getUnitStatus } from '../utils/unitConversion';
|
||||||
import { unitStatusClass } from './unitStatusStyles';
|
import { unitStatusClass } from './unitStatusStyles';
|
||||||
|
import { isPriceMissing } from './step3Validation';
|
||||||
import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
|
import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
|
||||||
import styles from './Step3ComparisonTable.module.scss';
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
|||||||
isReadOnly, edits, extraPositions, translateUnits, rawValues, deliveryKm, selectedSupplierId, nameColWidth,
|
isReadOnly, edits, extraPositions, translateUnits, rawValues, deliveryKm, selectedSupplierId, nameColWidth,
|
||||||
suppliersWithOffer, supplierCurrencies, computedOffers, minTotalByPosition,
|
suppliersWithOffer, supplierCurrencies, computedOffers, minTotalByPosition,
|
||||||
updateSupplierProductName, updateUnit, updateEdit, updatePriceKzt, handleDeliveryKmChange,
|
updateSupplierProductName, updateUnit, updateEdit, updatePriceKzt, handleDeliveryKmChange,
|
||||||
handleDeleteExtraPosition,
|
handleDeleteExtraPosition, handleExtraPositionUnitChange,
|
||||||
} = matrix;
|
} = matrix;
|
||||||
|
|
||||||
const ep = extraPositions.find(e => e.id === pos.id);
|
const ep = extraPositions.find(e => e.id === pos.id);
|
||||||
@@ -46,7 +47,22 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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>
|
<td className={styles.qtyCell}>—</td>
|
||||||
{suppliersWithOffer.map(s => {
|
{suppliersWithOffer.map(s => {
|
||||||
const key = key0(s);
|
const key = key0(s);
|
||||||
@@ -75,6 +91,10 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
|||||||
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
|
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
|
||||||
const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
|
const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
|
||||||
const showInput = !isReadOnly && !translateUnits;
|
const showInput = !isReadOnly && !translateUnits;
|
||||||
|
// Подсветка сразу, пока заполняют: пустая цена — заливкой (сохранять не мешает),
|
||||||
|
// пустой километраж логистики — рамкой (не пускает дальше Шага 3).
|
||||||
|
const priceMissingCls = !isReadOnly && isPriceMissing(price, item?.priceKztOverride) ? styles.missingPriceCell : '';
|
||||||
|
const kmMissingCls = !isReadOnly && deliveryKm[`${pos.id}::${s.id}`] === undefined ? styles.requiredMissingBox : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={s.id}>
|
<React.Fragment key={s.id}>
|
||||||
@@ -115,16 +135,16 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
|||||||
<TextInput
|
<TextInput
|
||||||
value={rawValues[`${s.id}::${pos.id}::0::quantity`] ?? (qty !== undefined ? String(qty) : '')}
|
value={rawValues[`${s.id}::${pos.id}::0::quantity`] ?? (qty !== undefined ? String(qty) : '')}
|
||||||
onChange={val => updateEdit(s.id, pos.id, 0, 'quantity', val)}
|
onChange={val => updateEdit(s.id, pos.id, 0, 'quantity', val)}
|
||||||
placeholder="рейсы"
|
placeholder={pos.unit || 'кол-во'}
|
||||||
borderless
|
borderless
|
||||||
ariaLabel={`Кол-во рейсов ${s.name} ${pos.materialName}`}
|
ariaLabel={`Кол-во (${pos.unit || 'ед.'}) ${s.name} ${pos.materialName}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className={styles.computedValue}>{dispQty !== undefined ? formatQty(dispQty) : '—'}</span>
|
<span className={styles.computedValue}>{dispQty !== undefined ? formatQty(dispQty) : '—'}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.deliveryKmBox}>
|
<div className={[styles.deliveryKmBox, kmMissingCls].filter(Boolean).join(' ')}>
|
||||||
{showInput ? (
|
{showInput ? (
|
||||||
<div className={styles.offerInput}>
|
<div className={styles.offerInput}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -160,7 +180,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
|||||||
</td>
|
</td>
|
||||||
{isForeign && (
|
{isForeign && (
|
||||||
<>
|
<>
|
||||||
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
|
<td className={[styles.dataCell, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||||
{showInput ? (
|
{showInput ? (
|
||||||
<div className={styles.offerInput}>
|
<div className={styles.offerInput}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -180,7 +200,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
|||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
|
<td className={[styles.dataCell, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||||
{showInput ? (
|
{showInput ? (
|
||||||
<div className={styles.offerInput}>
|
<div className={styles.offerInput}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -214,6 +234,8 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{/* Под колонкой «+ Добавить поставщика». */}
|
||||||
|
{!isReadOnly && <td className={styles.addSupplierFillerCell} />}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -6,6 +6,7 @@ import { Step3Matrix } from './useStep3Matrix';
|
|||||||
import { getUnitStatus } from '../utils/unitConversion';
|
import { getUnitStatus } from '../utils/unitConversion';
|
||||||
import { parseNumberInput } from '../utils/parseNumberInput';
|
import { parseNumberInput } from '../utils/parseNumberInput';
|
||||||
import { unitStatusClass } from './unitStatusStyles';
|
import { unitStatusClass } from './unitStatusStyles';
|
||||||
|
import { isPriceMissing } from './step3Validation';
|
||||||
import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
|
import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
|
||||||
import styles from './Step3ComparisonTable.module.scss';
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
|
|
||||||
@@ -98,6 +99,8 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
|||||||
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
|
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
|
||||||
const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
|
const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
|
||||||
const showInput = !isReadOnly && !translateUnits;
|
const showInput = !isReadOnly && !translateUnits;
|
||||||
|
// Пустая цена — подсветка сразу, пока заполняют; сохранению не мешает (см. isPriceMissing).
|
||||||
|
const priceMissingCls = !isReadOnly && isPriceMissing(price, item.priceKztOverride) ? styles.missingPriceCell : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={s.id}>
|
<React.Fragment key={s.id}>
|
||||||
@@ -160,7 +163,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
|||||||
</td>
|
</td>
|
||||||
{!isRestrictedView && isForeign && (
|
{!isRestrictedView && isForeign && (
|
||||||
<>
|
<>
|
||||||
<td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')}>
|
<td className={[styles.dataCell, valueCls, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||||
{showInput ? (
|
{showInput ? (
|
||||||
<div className={styles.offerInput}>
|
<div className={styles.offerInput}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -180,7 +183,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
|||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isRestrictedView && <td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')}>
|
{!isRestrictedView && <td className={[styles.dataCell, valueCls, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||||
{showInput ? (
|
{showInput ? (
|
||||||
<div className={styles.offerInput}>
|
<div className={styles.offerInput}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -215,6 +218,8 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{/* Под колонкой «+ Добавить поставщика»: одна ячейка на позицию, включая строки комплекта. */}
|
||||||
|
{rowIdx === 0 && !isReadOnly && <td className={styles.addSupplierFillerCell} rowSpan={posRowSpan} />}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-14
@@ -866,20 +866,6 @@ $delivery-km-min-width: 76px;
|
|||||||
color: $text;
|
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 {
|
.addRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -1349,3 +1335,114 @@ $delivery-km-min-width: 76px;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Незаполненное (решение от 2026-10-27: подсвечивать сразу, пока заполняют) ─────
|
||||||
|
// Красная рамка — то, что не пускает дальше Шага 3 (см. step3Validation): победитель, километраж,
|
||||||
|
// условия оплаты (.criteriaInputRequired). Бледная заливка — пустая цена: сохранять не мешает.
|
||||||
|
|
||||||
|
// !important — фон выбранной колонки (.colSelected) тоже с !important, а пустая цена в выбранной
|
||||||
|
// колонке должна оставаться заметной.
|
||||||
|
.missingPriceCell {
|
||||||
|
background: $danger-bg !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requiredMissingBox {
|
||||||
|
box-shadow: inset 0 0 0 1px $danger;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectSupplierBtnRequired {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+42
-34
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useMemo, useState } 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 { FormStep } from '../types/domain';
|
||||||
import { CtService } from '../../services/CtService';
|
import { CtService } from '../../services/CtService';
|
||||||
import { VendorDatabaseService, IVendorRecord } from '../../services/VendorDatabaseService';
|
import { VendorDatabaseService, IVendorRecord } from '../../services/VendorDatabaseService';
|
||||||
@@ -19,6 +19,10 @@ import { useAdataCheck } from './useAdataCheck';
|
|||||||
import { useRecommendedPick } from './useRecommendedPick';
|
import { useRecommendedPick } from './useRecommendedPick';
|
||||||
import { VendorPickerDialog } from './VendorPickerDialog';
|
import { VendorPickerDialog } from './VendorPickerDialog';
|
||||||
import { ExcludedSuppliersList } from './ExcludedSuppliersList';
|
import { ExcludedSuppliersList } from './ExcludedSuppliersList';
|
||||||
|
import { AddSupplierToolbar } from './AddSupplierToolbar';
|
||||||
|
import { AddSupplierDialog } from './AddSupplierDialog';
|
||||||
|
import { SaveDraftButton } from '../SaveDraftButton/SaveDraftButton';
|
||||||
|
import { withoutPlaceholders } from '../utils/placeholderKpFile';
|
||||||
|
|
||||||
// Исключённых поставщиков в режиме просмотра (согласующий, отправленная СТ) пока не показываем —
|
// Исключённых поставщиков в режиме просмотра (согласующий, отправленная СТ) пока не показываем —
|
||||||
// решение от 2026-10-27 (docs/adr/0001). Данные грузятся и так: включить — поменять на true.
|
// решение от 2026-10-27 (docs/adr/0001). Данные грузятся и так: включить — поменять на true.
|
||||||
@@ -56,8 +60,8 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
const matrix = useStep3Matrix({ service, formData, isReadOnly: !canEdit, onSave, onNext, onStepComplete });
|
const matrix = useStep3Matrix({ service, formData, isReadOnly: !canEdit, onSave, onNext, onStepComplete });
|
||||||
const {
|
const {
|
||||||
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
|
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
|
||||||
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError,
|
addManualSupplier, handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError,
|
||||||
validateBeforeSave, handleSave,
|
validateBeforeSave, handleSave, draftSavedAt,
|
||||||
extraPositions, suppliersWithOffer, columnDisplayNames, allPositions, computedOffers, selectedSupplierId,
|
extraPositions, suppliersWithOffer, columnDisplayNames, allPositions, computedOffers, selectedSupplierId,
|
||||||
excludedColumns, handleRestoreSupplier,
|
excludedColumns, handleRestoreSupplier,
|
||||||
} = matrix;
|
} = matrix;
|
||||||
@@ -68,6 +72,8 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
|
|
||||||
// Модалка выбора поставщика из «База данных поставщиков» (см. VendorPickerDialog).
|
// Модалка выбора поставщика из «База данных поставщиков» (см. VendorPickerDialog).
|
||||||
const [showVendorPicker, setShowVendorPicker] = useState(false);
|
const [showVendorPicker, setShowVendorPicker] = useState(false);
|
||||||
|
// Окно кнопки «+ Добавить поставщика» в шапке таблицы.
|
||||||
|
const [showAddSupplierDialog, setShowAddSupplierDialog] = useState(false);
|
||||||
const vendorDbService = useMemo(() => new VendorDatabaseService(service.webPartContext), [service]);
|
const vendorDbService = useMemo(() => new VendorDatabaseService(service.webPartContext), [service]);
|
||||||
const handleAddVendorFromDatabase = (vendor: IVendorRecord): void => {
|
const handleAddVendorFromDatabase = (vendor: IVendorRecord): void => {
|
||||||
const id = handleAddSupplierFromDatabase(vendor);
|
const id = handleAddSupplierFromDatabase(vendor);
|
||||||
@@ -115,35 +121,17 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
|
|
||||||
{/* Toolbar: add supplier — always visible above the table */}
|
{/* Добавление потенциального поставщика: блок над таблицей + кнопка в шапке таблицы. */}
|
||||||
{!isReadOnly && (
|
{!isReadOnly && (
|
||||||
<div className={styles.supplierToolbar}>
|
<AddSupplierToolbar
|
||||||
<div className={styles.addInput}>
|
name={newSupplierName}
|
||||||
<TextInput
|
currency={newSupplierCurrency}
|
||||||
placeholder="Наименование потенциального поставщика"
|
currencyOptions={currencyOptions}
|
||||||
value={newSupplierName}
|
onNameChange={setNewSupplierName}
|
||||||
onChange={setNewSupplierName}
|
onCurrencyChange={setNewSupplierCurrency}
|
||||||
ariaLabel="Наименование нового потенциального поставщика"
|
onAdd={handleAddSupplier}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') 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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Тумблер пересчёта единиц измерения поставщика в ед. изм. заявки */}
|
{/* Тумблер пересчёта единиц измерения поставщика в ед. изм. заявки */}
|
||||||
@@ -183,7 +171,14 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
|
|
||||||
{recPick.pickError && <Alert variant="err">{recPick.pickError}</Alert>}
|
{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} />
|
<CriteriaTable matrix={matrix} />
|
||||||
|
|
||||||
@@ -201,7 +196,8 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* В КП — цены: показываем тем же, кому видны цены (участникам согласования и автору). */}
|
{/* В КП — цены: показываем тем же, кому видны цены (участникам согласования и автору). */}
|
||||||
{!isRestrictedView && <KpFilesList files={formData.uploadedKpFiles ?? []} service={service} />}
|
{/* Заглушки поставщиков, добавленных вручную, — не КП: не показываем. */}
|
||||||
|
{!isRestrictedView && <KpFilesList files={withoutPlaceholders(formData.uploadedKpFiles ?? [])} service={service} />}
|
||||||
|
|
||||||
<AiCommentBlock
|
<AiCommentBlock
|
||||||
comment={ai.aiComment}
|
comment={ai.aiComment}
|
||||||
@@ -226,6 +222,9 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
<div className={styles.footerRight}>
|
<div className={styles.footerRight}>
|
||||||
{saveError && <Alert variant="err">{saveError}</Alert>}
|
{saveError && <Alert variant="err">{saveError}</Alert>}
|
||||||
|
{!isReadOnly && (
|
||||||
|
<SaveDraftButton onSave={() => { handleSave('draft').catch(() => undefined /* ошибку показывает handleSave */); }} isSaving={isSaving} savedAt={draftSavedAt} />
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={ai.handleGeneratePdfReport}
|
onClick={ai.handleGeneratePdfReport}
|
||||||
@@ -247,7 +246,7 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
// Сначала проверка (победитель, километраж), потом предложение комментария ИИ.
|
// Сначала проверка (победитель, километраж), потом предложение комментария ИИ.
|
||||||
if (!validateBeforeSave()) return;
|
if (!validateBeforeSave()) return;
|
||||||
if (!ai.aiComment && !ai.isAiLoading) ai.setShowAiPromptDialog(true);
|
if (!ai.aiComment && !ai.isAiLoading) ai.setShowAiPromptDialog(true);
|
||||||
else void handleSave();
|
else handleSave().catch(() => undefined /* ошибку показывает handleSave */);
|
||||||
}}
|
}}
|
||||||
disabled={isSaving || isReadOnly}
|
disabled={isSaving || isReadOnly}
|
||||||
>
|
>
|
||||||
@@ -263,6 +262,15 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showAddSupplierDialog && (
|
||||||
|
<AddSupplierDialog
|
||||||
|
currencyOptions={currencyOptions}
|
||||||
|
onAddManual={addManualSupplier}
|
||||||
|
onOpenDatabase={() => setShowVendorPicker(true)}
|
||||||
|
onClose={() => setShowAddSupplierDialog(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showVendorPicker && (
|
{showVendorPicker && (
|
||||||
<VendorPickerDialog
|
<VendorPickerDialog
|
||||||
vendorDbService={vendorDbService}
|
vendorDbService={vendorDbService}
|
||||||
|
|||||||
+29
-2
@@ -1,7 +1,34 @@
|
|||||||
import { applyCreatedIds, buildOfferKey, computeOffer, getSubCols, isMinPositionTotal, round2Str, sumPositionTotals } from './matrixHelpers';
|
import { applyCreatedIds, applyPositionUnitChange, buildOfferKey, computeOffer, getSubCols, isMinPositionTotal, round2Str, sumPositionTotals } from './matrixHelpers';
|
||||||
import { ExtraPosition } from './matrixTypes';
|
import { EditableOffers, ExtraPosition } from './matrixTypes';
|
||||||
import { SupplierOffer } from '../types/domain';
|
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', () => {
|
describe('computeOffer', () => {
|
||||||
it('считает сумму в тенге для КП в тенге', () => {
|
it('считает сумму в тенге для КП в тенге', () => {
|
||||||
const offer = computeOffer(
|
const offer = computeOffer(
|
||||||
|
|||||||
@@ -87,6 +87,25 @@ export function buildOfferKey(supplierId: string, positionId: string): string {
|
|||||||
return `${supplierId}::${positionId}`;
|
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 {
|
export interface ColumnOffersInput {
|
||||||
columns: Array<{ id: string; isExtraSupplier: boolean }>;
|
columns: Array<{ id: string; isExtraSupplier: boolean }>;
|
||||||
positions: PositionRow[];
|
positions: PositionRow[];
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
import { Supplier } from '../types/domain';
|
||||||
|
import { ExtraSupplier } from './matrixTypes';
|
||||||
|
import { buildSuppliersPatch } from './step3SavePatch';
|
||||||
|
|
||||||
|
const kpColumn: Supplier = { id: '282_320', spId: 282, kpFileSpId: 320, name: 'Каспий Дорс', hasOffer: true };
|
||||||
|
const extra: ExtraSupplier = { id: 'extra_sup_1', name: 'ООО Новый', currency: 'KZT' };
|
||||||
|
|
||||||
|
describe('buildSuppliersPatch', () => {
|
||||||
|
it('статусы поставщиков — в данные формы в обоих режимах', () => {
|
||||||
|
for (const mode of ['next', 'draft'] as const) {
|
||||||
|
const patch = buildSuppliersPatch({
|
||||||
|
mode, suppliers: [kpColumn], extraSuppliers: [], statuses: { '282_320': 'Производитель' }, extraSupplierSpIds: {},
|
||||||
|
});
|
||||||
|
expect(patch.suppliers[0].status).toBe('Производитель');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('«Сохранить и продолжить»: поставщик Шага 3 переезжает в suppliers с SP Id, список Шага 3 очищается', () => {
|
||||||
|
const patch = buildSuppliersPatch({
|
||||||
|
mode: 'next', suppliers: [kpColumn], extraSuppliers: [extra], statuses: {}, extraSupplierSpIds: { extra_sup_1: 501 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(patch.suppliers.map(s => s.id)).toEqual(['282_320', 'extra_sup_1']);
|
||||||
|
expect(patch.suppliers[1]).toMatchObject({ spId: 501, hasOffer: true });
|
||||||
|
expect(patch.step3ExtraSuppliers).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Остаёмся на шаге: таблица держит поставщика в своём списке Шага 3 — перенос задвоил бы колонку.
|
||||||
|
it('черновик: поставщик Шага 3 остаётся в своём списке, suppliers его не получают', () => {
|
||||||
|
const patch = buildSuppliersPatch({
|
||||||
|
mode: 'draft', suppliers: [kpColumn], extraSuppliers: [extra], statuses: {}, extraSupplierSpIds: { extra_sup_1: 501 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(patch.suppliers.map(s => s.id)).toEqual(['282_320']);
|
||||||
|
expect(patch).not.toHaveProperty('step3ExtraSuppliers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('повторное «Сохранить и продолжить» не дублирует уже перенесённого поставщика', () => {
|
||||||
|
const alreadyMoved: Supplier = { id: 'extra_sup_1', spId: 501, name: 'ООО Новый', hasOffer: true };
|
||||||
|
|
||||||
|
const patch = buildSuppliersPatch({
|
||||||
|
mode: 'next', suppliers: [kpColumn, alreadyMoved], extraSuppliers: [extra], statuses: {}, extraSupplierSpIds: { extra_sup_1: 501 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(patch.suppliers.filter(s => s.id === 'extra_sup_1')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('исключение поставщика Шага 3 переезжает вместе с ним', () => {
|
||||||
|
const patch = buildSuppliersPatch({
|
||||||
|
mode: 'next', suppliers: [], extraSuppliers: [{ ...extra, exclusion: { reason: 'Дорого' } }], statuses: {}, extraSupplierSpIds: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(patch.suppliers[0].exclusion).toEqual({ reason: 'Дорого' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Supplier } from '../types/domain';
|
||||||
|
import { ExtraSupplier } from './matrixTypes';
|
||||||
|
|
||||||
|
// «Сохранить и продолжить» — переход на Шаг 4; «Сохранить черновик» — запись в SP, остаёмся на Шаге 3.
|
||||||
|
export type Step3SaveMode = 'next' | 'draft';
|
||||||
|
|
||||||
|
export interface SuppliersPatchInput {
|
||||||
|
mode: Step3SaveMode;
|
||||||
|
suppliers: Supplier[];
|
||||||
|
extraSuppliers: ExtraSupplier[];
|
||||||
|
statuses: Record<string, string>;
|
||||||
|
// SP Id поставщиков, добавленных на Шаге 3 (созданы при этом сохранении или раньше).
|
||||||
|
extraSupplierSpIds: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuppliersPatch {
|
||||||
|
suppliers: Supplier[];
|
||||||
|
// Есть только при переходе на Шаг 4: поставщики Шага 3 переезжают в suppliers.
|
||||||
|
step3ExtraSuppliers?: ExtraSupplier[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поставщики в данных формы после сохранения Шага 3.
|
||||||
|
//
|
||||||
|
// При переходе на Шаг 4 поставщики, добавленные на Шаге 3, переезжают в suppliers (с тем же id —
|
||||||
|
// по нему к ним привязаны цены в сессии), а список Шага 3 очищается: таблица размонтируется и
|
||||||
|
// при возврате соберётся заново.
|
||||||
|
//
|
||||||
|
// Черновик оставляет нас на шаге, и таблица по-прежнему держит их в своём списке Шага 3. Перенос
|
||||||
|
// задвоил бы колонку (она в обоих списках), а без списка Шага 3 у такого поставщика пропала бы
|
||||||
|
// подстановка количества из позиции — суммы обнулились бы. Поэтому черновик их не переносит:
|
||||||
|
// SP Id уже запомнены (rememberCreatedExtras), повторное сохранение дублей в SP не создаст.
|
||||||
|
export function buildSuppliersPatch(input: SuppliersPatchInput): SuppliersPatch {
|
||||||
|
const { mode, suppliers, extraSuppliers, statuses, extraSupplierSpIds } = input;
|
||||||
|
const withStatuses = suppliers.map(s => ({ ...s, status: statuses[s.id] ?? s.status }));
|
||||||
|
if (mode === 'draft') return { suppliers: withStatuses };
|
||||||
|
|
||||||
|
const movedExtras: Supplier[] = extraSuppliers
|
||||||
|
// Уже в suppliers после прежнего сохранения — не дублируем колонку.
|
||||||
|
.filter(es => !suppliers.some(s => s.id === es.id))
|
||||||
|
.map(es => ({
|
||||||
|
id: es.id,
|
||||||
|
spId: extraSupplierSpIds[es.id] ?? es.spId,
|
||||||
|
name: es.name,
|
||||||
|
hasOffer: true,
|
||||||
|
status: statuses[es.id] ?? '',
|
||||||
|
exclusion: es.exclusion,
|
||||||
|
}));
|
||||||
|
return { suppliers: [...withStatuses, ...movedExtras], step3ExtraSuppliers: [] };
|
||||||
|
}
|
||||||
+26
-5
@@ -1,4 +1,4 @@
|
|||||||
import { STEP3_SAVE_ERRORS, getStep3SaveError, toggleWinner } from './step3Validation';
|
import { STEP3_SAVE_ERRORS, getStep3SaveError, isPriceMissing, toggleWinner } from './step3Validation';
|
||||||
|
|
||||||
describe('toggleWinner', () => {
|
describe('toggleWinner', () => {
|
||||||
it('нажали «Выбрать» — поставщик выбран', () => {
|
it('нажали «Выбрать» — поставщик выбран', () => {
|
||||||
@@ -19,23 +19,44 @@ describe('getStep3SaveError', () => {
|
|||||||
const compared = ['259_278', '282_320'];
|
const compared = ['259_278', '282_320'];
|
||||||
|
|
||||||
it('победитель выбран — сохранять можно', () => {
|
it('победитель выбран — сохранять можно', () => {
|
||||||
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasDeliveryWithoutKm: false }))
|
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: false }))
|
||||||
.toBeUndefined();
|
.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('победитель не выбран — нельзя', () => {
|
it('победитель не выбран — нельзя', () => {
|
||||||
expect(getStep3SaveError({ selectedSupplierId: undefined, comparedColumnIds: compared, hasDeliveryWithoutKm: false }))
|
expect(getStep3SaveError({ selectedSupplierId: undefined, comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: false }))
|
||||||
.toBe(STEP3_SAVE_ERRORS.noWinner);
|
.toBe(STEP3_SAVE_ERRORS.noWinner);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Выбор из сессии мог остаться на поставщике, которого исключили или чей КП убрали.
|
// Выбор из сессии мог остаться на поставщике, которого исключили или чей КП убрали.
|
||||||
it('выбран поставщик не из сравниваемых — как будто не выбран', () => {
|
it('выбран поставщик не из сравниваемых — как будто не выбран', () => {
|
||||||
expect(getStep3SaveError({ selectedSupplierId: '300_400', comparedColumnIds: compared, hasDeliveryWithoutKm: false }))
|
expect(getStep3SaveError({ selectedSupplierId: '300_400', comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: false }))
|
||||||
.toBe(STEP3_SAVE_ERRORS.noWinner);
|
.toBe(STEP3_SAVE_ERRORS.noWinner);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('логистика без километража — нельзя', () => {
|
it('логистика без километража — нельзя', () => {
|
||||||
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasDeliveryWithoutKm: true }))
|
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: true }))
|
||||||
.toBe(STEP3_SAVE_ERRORS.deliveryKmMissing);
|
.toBe(STEP3_SAVE_ERRORS.deliveryKmMissing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Решение от 2026-10-27: «Условия оплаты *» были помечены обязательными, но сохранять не мешали.
|
||||||
|
it('условия оплаты не у всех — нельзя', () => {
|
||||||
|
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasMissingPaymentTerms: true, hasDeliveryWithoutKm: false }))
|
||||||
|
.toBe(STEP3_SAVE_ERRORS.paymentTermsMissing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPriceMissing', () => {
|
||||||
|
it('цены нет ни в валюте, ни в ₸ — пусто', () => {
|
||||||
|
expect(isPriceMissing(undefined, undefined)).toBe(true);
|
||||||
|
expect(isPriceMissing(0, undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('цена в валюте КП есть — не пусто', () => {
|
||||||
|
expect(isPriceMissing(635, undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('цену ввели вручную в ₸ — не пусто', () => {
|
||||||
|
expect(isPriceMissing(undefined, 286575.5)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+16
-4
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
export const STEP3_SAVE_ERRORS = {
|
export const STEP3_SAVE_ERRORS = {
|
||||||
noWinner: 'Выберите поставщика-победителя: кнопка «Выбрать» в шапке его колонки.',
|
noWinner: 'Выберите поставщика-победителя: кнопка «Выбрать» в шапке его колонки.',
|
||||||
|
paymentTermsMissing: 'Заполните условия оплаты у всех поставщиков в «Дополнительной информации».',
|
||||||
deliveryKmMissing: 'Укажите километраж для каждого потенциального поставщика в строке логистики',
|
deliveryKmMissing: 'Укажите километраж для каждого потенциального поставщика в строке логистики',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -9,21 +10,32 @@ export interface Step3SaveCheckInput {
|
|||||||
selectedSupplierId: string | undefined;
|
selectedSupplierId: string | undefined;
|
||||||
// Колонки, которые сравниваются (без исключённых).
|
// Колонки, которые сравниваются (без исключённых).
|
||||||
comparedColumnIds: string[];
|
comparedColumnIds: string[];
|
||||||
|
hasMissingPaymentTerms: boolean;
|
||||||
hasDeliveryWithoutKm: boolean;
|
hasDeliveryWithoutKm: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Победитель обязателен (решение от 2026-10-27): он уходит в шапку СТ (winSupplier) — без него
|
// Что не пускает дальше Шага 3 (решения от 2026-10-27). Всё это подсвечено красной рамкой сразу,
|
||||||
// отчёт и согласование не знают, кого выбрали. Выбранный должен быть среди сравниваемых:
|
// пока заполняют, — подсветка показывает ровно то, что проверяется здесь.
|
||||||
// исключённый или удалённый поставщик победителем не считается.
|
// - Победитель: уходит в шапку СТ (winSupplier) — без него отчёт и согласование не знают, кого
|
||||||
|
// выбрали. Должен быть среди сравниваемых: исключённый поставщик победителем не считается.
|
||||||
|
// - Условия оплаты: по ним считается рекомендация (80% цена / 20% условия оплаты).
|
||||||
|
// - Километраж логистики.
|
||||||
export function getStep3SaveError(input: Step3SaveCheckInput): string | undefined {
|
export function getStep3SaveError(input: Step3SaveCheckInput): string | undefined {
|
||||||
const { selectedSupplierId, comparedColumnIds, hasDeliveryWithoutKm } = input;
|
const { selectedSupplierId, comparedColumnIds, hasMissingPaymentTerms, hasDeliveryWithoutKm } = input;
|
||||||
if (!selectedSupplierId || !comparedColumnIds.includes(selectedSupplierId)) {
|
if (!selectedSupplierId || !comparedColumnIds.includes(selectedSupplierId)) {
|
||||||
return STEP3_SAVE_ERRORS.noWinner;
|
return STEP3_SAVE_ERRORS.noWinner;
|
||||||
}
|
}
|
||||||
|
if (hasMissingPaymentTerms) return STEP3_SAVE_ERRORS.paymentTermsMissing;
|
||||||
if (hasDeliveryWithoutKm) return STEP3_SAVE_ERRORS.deliveryKmMissing;
|
if (hasDeliveryWithoutKm) return STEP3_SAVE_ERRORS.deliveryKmMissing;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Цена в ячейке не введена: ни в валюте КП, ни вручную в ₸. Подсвечивается, но сохранению не
|
||||||
|
// мешает — поставщик мог не предложить позицию, и 0 вместо пустоты исказил бы сравнение.
|
||||||
|
export function isPriceMissing(price: number | undefined, priceKztOverride: number | undefined): boolean {
|
||||||
|
return !(price !== undefined && price > 0) && !(priceKztOverride !== undefined && priceKztOverride > 0);
|
||||||
|
}
|
||||||
|
|
||||||
// Кнопка «Выбрать» / «Выбран»: повторное нажатие на выбранного снимает выбор.
|
// Кнопка «Выбрать» / «Выбран»: повторное нажатие на выбранного снимает выбор.
|
||||||
export function toggleWinner(currentId: string | undefined, clickedId: string): string | undefined {
|
export function toggleWinner(currentId: string | undefined, clickedId: string): string | undefined {
|
||||||
return currentId === clickedId ? undefined : clickedId;
|
return currentId === clickedId ? undefined : clickedId;
|
||||||
|
|||||||
+58
-31
@@ -4,10 +4,11 @@ import { SupplierOffer, CurrencyRate, FormStep, SupplierExclusion } from '../typ
|
|||||||
import { CtService } from '../../services/CtService';
|
import { CtService } from '../../services/CtService';
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
|
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 { buildExcludedRecordsForSave } from '../utils/excludedColumns';
|
||||||
import { parseNumberInput } from '../utils/parseNumberInput';
|
import { parseNumberInput } from '../utils/parseNumberInput';
|
||||||
import { getStep3SaveError } from './step3Validation';
|
import { getStep3SaveError } from './step3Validation';
|
||||||
|
import { Step3SaveMode, buildSuppliersPatch } from './step3SavePatch';
|
||||||
import { IVendorRecord } from '../../services/VendorDatabaseService';
|
import { IVendorRecord } from '../../services/VendorDatabaseService';
|
||||||
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
|
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
|
||||||
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
|
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
|
||||||
@@ -95,11 +96,19 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedSupplierId, setSelectedSupplierId] = useState<string | undefined>(
|
const [selectedSupplierId, setSelectedSupplierIdState] = useState<string | undefined>(
|
||||||
formData.recommendedSupplierId
|
formData.recommendedSupplierId
|
||||||
);
|
);
|
||||||
|
// Выбор победителя сразу пишем в formData, как статус и исключение поставщика: иначе снятый
|
||||||
|
// выбор жил только на экране, а после F5 возвращался из сессии или из шапки СТ.
|
||||||
|
const setSelectedSupplierId = useCallback((id: string | undefined): void => {
|
||||||
|
setSelectedSupplierIdState(id);
|
||||||
|
onSave({ recommendedSupplierId: id });
|
||||||
|
}, [onSave]);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | undefined>(undefined);
|
const [saveError, setSaveError] = useState<string | undefined>(undefined);
|
||||||
|
// Когда последний раз сохранили черновик — показываем рядом с кнопкой.
|
||||||
|
const [draftSavedAt, setDraftSavedAt] = useState<Date | undefined>(undefined);
|
||||||
// Тумблер «Перевести в ед. изм. заявки»: переводит кол-во и цену поставщика в ед. заявки.
|
// Тумблер «Перевести в ед. изм. заявки»: переводит кол-во и цену поставщика в ед. заявки.
|
||||||
// При включении блоки поставщиков показываются как вычисленные значения (без редактирования).
|
// При включении блоки поставщиков показываются как вычисленные значения (без редактирования).
|
||||||
const [translateUnits, setTranslateUnits] = useState(false);
|
const [translateUnits, setTranslateUnits] = useState(false);
|
||||||
@@ -488,7 +497,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
}
|
}
|
||||||
// Исключённый не может остаться выбранным — иначе он уйдёт победителем в шапку СТ.
|
// Исключённый не может остаться выбранным — иначе он уйдёт победителем в шапку СТ.
|
||||||
if (exclusion && selectedSupplierId === supplierId) setSelectedSupplierId(undefined);
|
if (exclusion && selectedSupplierId === supplierId) setSelectedSupplierId(undefined);
|
||||||
}, [isReadOnly, suppliers, extraSuppliers, selectedSupplierId, onSave]);
|
}, [isReadOnly, suppliers, extraSuppliers, selectedSupplierId, setSelectedSupplierId, onSave]);
|
||||||
|
|
||||||
const handleExcludeSupplier = useCallback((supplierId: string, reason: string): void => {
|
const handleExcludeSupplier = useCallback((supplierId: string, reason: string): void => {
|
||||||
if (!reason.trim()) return;
|
if (!reason.trim()) return;
|
||||||
@@ -578,16 +587,23 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddSupplier = (): void => {
|
// Поставщик, добавленный на Шаге 3 вручную (без КП). Общая точка для обеих кнопок добавления:
|
||||||
if (!newSupplierName.trim()) return;
|
// блока над таблицей и колонки «+ Добавить поставщика» в самой таблице.
|
||||||
|
const addManualSupplier = (name: string, currency: string): void => {
|
||||||
|
if (isReadOnly || !name.trim()) return;
|
||||||
const id = `extra_sup_${++extraIdCounter}`;
|
const id = `extra_sup_${++extraIdCounter}`;
|
||||||
const newEntry = { id, name: newSupplierName.trim(), currency: newSupplierCurrency };
|
const updated = [...extraSuppliers, { id, name: name.trim(), currency }];
|
||||||
const updated = [...extraSuppliers, newEntry];
|
|
||||||
setExtraSuppliers(updated);
|
setExtraSuppliers(updated);
|
||||||
setSupplierStatuses(prev => ({ ...prev, [id]: '' }));
|
setSupplierStatuses(prev => ({ ...prev, [id]: '' }));
|
||||||
|
onSave({ step3ExtraSuppliers: updated });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Блок над таблицей: добавить из своих полей и очистить их.
|
||||||
|
const handleAddSupplier = (): void => {
|
||||||
|
if (!newSupplierName.trim()) return;
|
||||||
|
addManualSupplier(newSupplierName, newSupplierCurrency);
|
||||||
setNewSupplierName('');
|
setNewSupplierName('');
|
||||||
setNewSupplierCurrency('KZT');
|
setNewSupplierCurrency('KZT');
|
||||||
onSave({ step3ExtraSuppliers: updated });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Добавление поставщика из VendorPickerDialog («База данных поставщиков»): та же механика
|
// Добавление поставщика из VendorPickerDialog («База данных поставщиков»): та же механика
|
||||||
@@ -672,6 +688,17 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
onSave({ step3DeliveryKm: updated });
|
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 => {
|
const handleDeleteExtraPosition = (positionId: string): void => {
|
||||||
if (isReadOnly) return;
|
if (isReadOnly) return;
|
||||||
const updated = extraPositions.filter(ep => ep.id !== positionId);
|
const updated = extraPositions.filter(ep => ep.id !== positionId);
|
||||||
@@ -712,6 +739,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
const error = getStep3SaveError({
|
const error = getStep3SaveError({
|
||||||
selectedSupplierId,
|
selectedSupplierId,
|
||||||
comparedColumnIds: suppliersWithOffer.map(s => s.id),
|
comparedColumnIds: suppliersWithOffer.map(s => s.id),
|
||||||
|
hasMissingPaymentTerms: suppliersWithOffer.some(s => !(criteriaMap[s.id]?.paymentType ?? '').trim()),
|
||||||
hasDeliveryWithoutKm: extraPositions.some(ep =>
|
hasDeliveryWithoutKm: extraPositions.some(ep =>
|
||||||
ep.isDelivery && suppliersWithOffer.some(s => !deliveryKm[`${ep.id}::${s.id}`])
|
ep.isDelivery && suppliersWithOffer.some(s => !deliveryKm[`${ep.id}::${s.id}`])
|
||||||
),
|
),
|
||||||
@@ -720,9 +748,11 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
return error === undefined;
|
return error === undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
// Запись Шага 3 в SP. 'next' — с проверками и переходом на Шаг 4; 'draft' — без проверок,
|
||||||
|
// остаёмся на шаге (черновик: сохранить недоделанное, чтобы не потерять при закрытии вкладки).
|
||||||
|
const handleSave = async (mode: Step3SaveMode = 'next'): Promise<void> => {
|
||||||
if (!ct) return;
|
if (!ct) return;
|
||||||
if (!validateBeforeSave()) return;
|
if (mode === 'next' && !validateBeforeSave()) return;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setSaveError(undefined);
|
setSaveError(undefined);
|
||||||
|
|
||||||
@@ -748,6 +778,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
if (ep.spId) {
|
if (ep.spId) {
|
||||||
// Already created in SP on a previous save — reuse to avoid duplicate Позиции СТ rows.
|
// Already created in SP on a previous save — reuse to avoid duplicate Позиции СТ rows.
|
||||||
extraPosSpIds[ep.id] = ep.spId;
|
extraPosSpIds[ep.id] = ep.spId;
|
||||||
|
// Единицу могли поменять после создания. Пишем всегда — строк доп. расходов единицы,
|
||||||
|
// а отслеживать «менялась ли» — лишнее состояние.
|
||||||
|
await service.updateCTPositionUnit(ep.spId, ep.unit);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const saved = await service.createCTPositions(ct.id, [{
|
const saved = await service.createCTPositions(ct.id, [{
|
||||||
@@ -874,28 +907,15 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
|
|
||||||
onSave({
|
onSave({
|
||||||
offers: offersForSession,
|
offers: offersForSession,
|
||||||
suppliers: [
|
// Поставщики Шага 3 переезжают в suppliers только при переходе на Шаг 4 (см. buildSuppliersPatch).
|
||||||
...suppliers.map(s => ({
|
...buildSuppliersPatch({
|
||||||
...s,
|
mode,
|
||||||
status: supplierStatuses[s.id] ?? s.status,
|
suppliers,
|
||||||
})),
|
extraSuppliers,
|
||||||
// Move extras into suppliers (with React ID kept as `id` — required so formData.offers
|
statuses: supplierStatuses,
|
||||||
// entries with React IDs still match sup.id on remount). Skip extras that are already in
|
extraSupplierSpIds: extraSupSpIds,
|
||||||
// formData.suppliers from a prior save (avoids duplicate columns).
|
}),
|
||||||
...extraSuppliers
|
|
||||||
.filter(es => !suppliers.some(s => s.id === es.id))
|
|
||||||
.map(es => ({
|
|
||||||
id: es.id,
|
|
||||||
spId: extraSupSpIds[es.id],
|
|
||||||
name: es.name,
|
|
||||||
hasOffer: true as const,
|
|
||||||
status: supplierStatuses[es.id] ?? '',
|
|
||||||
exclusion: es.exclusion,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
recommendedSupplierId: selectedSupplierId,
|
recommendedSupplierId: selectedSupplierId,
|
||||||
// Extras moved into `suppliers` — clear extras state so `suppliersWithOffer` doesn't duplicate columns.
|
|
||||||
step3ExtraSuppliers: [],
|
|
||||||
// Positions don't migrate into selectedPositions — keep them in extras state so the
|
// Positions don't migrate into selectedPositions — keep them in extras state so the
|
||||||
// "доп.расходы" rows continue to render after Step 4 → breadcrumb back. Track spId to
|
// "доп.расходы" rows continue to render after Step 4 → breadcrumb back. Track spId to
|
||||||
// skip re-creating SP rows on subsequent saves.
|
// skip re-creating SP rows on subsequent saves.
|
||||||
@@ -912,6 +932,10 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
step3CriteriaEdits: criteriaMap,
|
step3CriteriaEdits: criteriaMap,
|
||||||
step3CustomCriteria: customCriteria,
|
step3CustomCriteria: customCriteria,
|
||||||
});
|
});
|
||||||
|
if (mode === 'draft') {
|
||||||
|
setDraftSavedAt(new Date());
|
||||||
|
return;
|
||||||
|
}
|
||||||
saveCompletedRef.current = true;
|
saveCompletedRef.current = true;
|
||||||
onStepComplete(3);
|
onStepComplete(3);
|
||||||
onNext();
|
onNext();
|
||||||
@@ -983,14 +1007,17 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
handleRemoveCustomCriteria,
|
handleRemoveCustomCriteria,
|
||||||
handleNameColResizeMouseDown,
|
handleNameColResizeMouseDown,
|
||||||
handleStatusChange,
|
handleStatusChange,
|
||||||
|
addManualSupplier,
|
||||||
handleAddSupplier,
|
handleAddSupplier,
|
||||||
handleAddSupplierFromDatabase,
|
handleAddSupplierFromDatabase,
|
||||||
handleAddPosition,
|
handleAddPosition,
|
||||||
handleAddDelivery,
|
handleAddDelivery,
|
||||||
handleDeliveryKmChange,
|
handleDeliveryKmChange,
|
||||||
handleDeleteExtraPosition,
|
handleDeleteExtraPosition,
|
||||||
|
handleExtraPositionUnitChange,
|
||||||
validateBeforeSave,
|
validateBeforeSave,
|
||||||
handleSave,
|
handleSave,
|
||||||
|
draftSavedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { CtService } from '../../services/CtService';
|
import { CtService } from '../../services/CtService';
|
||||||
import { SigexSignService, SigexSignStep } from '../../services/SigexSignService';
|
import { SigexSignService, SigexSignStep } from '../../services/SigexSignService';
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
|
import { withoutPlaceholders } from '../utils/placeholderKpFile';
|
||||||
|
|
||||||
// Этапы выгрузки пакета в INCORE: формирование отчёта + этапы подписания SIGEX.
|
// Этапы выгрузки пакета в INCORE: формирование отчёта + этапы подписания SIGEX.
|
||||||
export type IncoreStep = 'report' | SigexSignStep;
|
export type IncoreStep = 'report' | SigexSignStep;
|
||||||
@@ -72,9 +73,10 @@ export function useIncore(args: UseIncoreArgs) {
|
|||||||
setCardAttachment(saved);
|
setCardAttachment(saved);
|
||||||
|
|
||||||
// 4. Пакет для INCORE: карточка с ЭЦП + файлы КП — скачиваем пользователю.
|
// 4. Пакет для INCORE: карточка с ЭЦП + файлы КП — скачиваем пользователю.
|
||||||
|
// Заглушки поставщиков, добавленных вручную, — не КП, в пакет не идут.
|
||||||
service.downloadBlob(cardBlob, incoreCardName);
|
service.downloadBlob(cardBlob, incoreCardName);
|
||||||
|
|
||||||
const kpFiles = formData.uploadedKpFiles ?? [];
|
const kpFiles = withoutPlaceholders(formData.uploadedKpFiles ?? []);
|
||||||
for (const f of kpFiles) {
|
for (const f of kpFiles) {
|
||||||
await service.downloadKpFile(f.name);
|
await service.downloadKpFile(f.name);
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 300));
|
await new Promise<void>((resolve) => setTimeout(resolve, 300));
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { isPlaceholderKpFile, withoutPlaceholders } from './placeholderKpFile';
|
||||||
|
|
||||||
|
describe('isPlaceholderKpFile', () => {
|
||||||
|
// СТ 375: поставщик «fghfg» добавлен на Шаге 3 — заглушка видна в «Коммерческих предложениях».
|
||||||
|
it('заглушка поставщика, добавленного вручную', () => {
|
||||||
|
expect(isPlaceholderKpFile('fghfg_1793100061349.txt')).toBe(true);
|
||||||
|
expect(isPlaceholderKpFile('ООО Альфа_1793100061349.TXT')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('настоящие КП — не заглушки', () => {
|
||||||
|
expect(isPlaceholderKpFile('372_KP_SteelGate_Export_USD.pdf')).toBe(false);
|
||||||
|
expect(isPlaceholderKpFile('372_KP_TOO_Kaspiy_Dors.docx')).toBe(false);
|
||||||
|
expect(isPlaceholderKpFile('КП.doc')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('withoutPlaceholders', () => {
|
||||||
|
it('оставляет только настоящие КП, порядок сохраняется', () => {
|
||||||
|
const files = [
|
||||||
|
{ spId: 318, name: '372_KP_SteelGate.pdf' },
|
||||||
|
{ spId: 322, name: 'fghfg_1793100061349.txt' },
|
||||||
|
{ spId: 320, name: '372_KP_Kaspiy.docx' },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(withoutPlaceholders(files).map(f => f.spId)).toEqual([318, 320]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Файл-заглушка КП: создаётся для поставщика, добавленного на Шаге 3 вручную, — оффер в SP должен
|
||||||
|
// ссылаться на файл КП (CtService._createPlaceholderKpFile). Людям он не нужен: в списках КП и в
|
||||||
|
// пакете INCORE его не показываем. Узнаём по расширению: на Шаге 2 загрузить можно только
|
||||||
|
// .pdf/.docx/.doc (FileUploadArea), а заглушка всегда .txt.
|
||||||
|
const PLACEHOLDER_EXTENSION = '.txt';
|
||||||
|
|
||||||
|
// Подпись колонки поставщика, у которого вместо КП — заглушка.
|
||||||
|
export const PLACEHOLDER_COLUMN_LABEL = 'Добавлен вручную';
|
||||||
|
|
||||||
|
export function isPlaceholderKpFile(fileName: string): boolean {
|
||||||
|
return fileName.toLowerCase().endsWith(PLACEHOLDER_EXTENSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настоящие КП — для показа и выгрузки. Данные формы (uploadedKpFiles) не фильтруем: по ним
|
||||||
|
// сохранение находит файл КП колонки, в том числе заглушку.
|
||||||
|
export function withoutPlaceholders<T extends { name: string }>(files: T[]): T[] {
|
||||||
|
return files.filter(file => !isPlaceholderKpFile(file.name));
|
||||||
|
}
|
||||||
@@ -1,6 +1,31 @@
|
|||||||
import { buildColumnDisplayNames, buildColumnLabels, buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findSiblingColumnIds, findWinnerColumnId } from './supplierColumns';
|
import { buildColumnDisplayNames, buildColumnLabels, buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findSiblingColumnIds, findWinnerColumnId, withoutSessionExtras } from './supplierColumns';
|
||||||
import { Supplier } from '../types/domain';
|
import { Supplier } from '../types/domain';
|
||||||
|
|
||||||
|
describe('withoutSessionExtras (поставщик Шага 3 после «Сохранить черновик»)', () => {
|
||||||
|
// СТ 375: единственный поставщик «fghfg» добавлен на Шаге 3, сохранён черновиком. При открытии из
|
||||||
|
// реестра колонка собиралась из SP («285_322», без цен) и ещё раз приходила из сессии (с ценами).
|
||||||
|
const fromSp: Supplier[] = [
|
||||||
|
{ id: '285_322', spId: 285, kpFileSpId: 322, name: 'fghfg', hasOffer: true },
|
||||||
|
{ id: '282_320', spId: 282, kpFileSpId: 320, name: 'Каспий Дорс', hasOffer: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('колонку поставщика Шага 3 из SP не добавляет — она уже есть в сессии', () => {
|
||||||
|
const result = withoutSessionExtras(fromSp, [{ spId: 285, offerId: 322 }]);
|
||||||
|
|
||||||
|
expect(result.map(c => c.id)).toEqual(['282_320']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('другой КП того же поставщика — отдельная колонка, остаётся', () => {
|
||||||
|
const result = withoutSessionExtras(fromSp, [{ spId: 285, offerId: 999 }]);
|
||||||
|
|
||||||
|
expect(result.map(c => c.id)).toEqual(['285_322', '282_320']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('поставщик Шага 3 ещё не сохранён в SP — ничего не убирает', () => {
|
||||||
|
expect(withoutSessionExtras(fromSp, [{}])).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('findWinnerColumnId (победитель из шапки СТ при открытии без сессии)', () => {
|
describe('findWinnerColumnId (победитель из шапки СТ при открытии без сессии)', () => {
|
||||||
const columns: Supplier[] = [
|
const columns: Supplier[] = [
|
||||||
{ id: '281_318', spId: 281, kpFileSpId: 318, name: 'SteelGate', hasOffer: true, exclusion: { reason: 'Дорого' } },
|
{ id: '281_318', spId: 281, kpFileSpId: 318, name: 'SteelGate', hasOffer: true, exclusion: { reason: 'Дорого' } },
|
||||||
@@ -68,6 +93,19 @@ describe('buildColumnsFromSpOffers (открытие по ссылке / без
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('подпись колонки с файлом-заглушкой', () => {
|
||||||
|
// СТ 375: в шапке колонки показывалось имя заглушки «fghfg_1793100061349.txt».
|
||||||
|
it('вместо имени заглушки — «Добавлен вручную»', () => {
|
||||||
|
const columns: Supplier[] = [
|
||||||
|
{ id: '285_322', spId: 285, kpFileSpId: 322, name: 'fghfg', hasOffer: true },
|
||||||
|
{ id: '285_330', spId: 285, kpFileSpId: 330, name: 'fghfg', hasOffer: true },
|
||||||
|
];
|
||||||
|
const files = [{ spId: 322, name: 'fghfg_1793100061349.txt' }, { spId: 330, name: '375_KP_fghfg.pdf' }];
|
||||||
|
|
||||||
|
expect(buildColumnLabels(columns, files)).toEqual({ '285_322': 'Добавлен вручную', '285_330': '375_KP_fghfg.pdf' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('подписи колонок поставщика с несколькими КП', () => {
|
describe('подписи колонок поставщика с несколькими КП', () => {
|
||||||
const ARLAN = 'ТОО «Арлан Офис Снаб»';
|
const ARLAN = 'ТОО «Арлан Офис Снаб»';
|
||||||
const columns: Supplier[] = [
|
const columns: Supplier[] = [
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Supplier } from '../types/domain';
|
import { Supplier } from '../types/domain';
|
||||||
|
import { PLACEHOLDER_COLUMN_LABEL, isPlaceholderKpFile } from './placeholderKpFile';
|
||||||
|
|
||||||
// Колонка таблицы сравнения = поставщик + файл КП: у одного поставщика может быть несколько
|
// Колонка таблицы сравнения = поставщик + файл КП: у одного поставщика может быть несколько
|
||||||
// предложений (напр. в тенге и в долларах). Разделитель «_», а не «::» — «::» уже служит
|
// предложений (напр. в тенге и в долларах). Разделитель «_», а не «::» — «::» уже служит
|
||||||
@@ -44,6 +45,22 @@ export function buildColumnsFromSpOffers(records: SpOfferColumnSource[]): Suppli
|
|||||||
return Array.from(columnsById.values());
|
return Array.from(columnsById.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Поставщик, добавленный на Шаге 3, в сессии: SP Id поставщика и его файла-заглушки КП.
|
||||||
|
export interface SessionExtraColumn {
|
||||||
|
spId?: number;
|
||||||
|
offerId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Колонки из SP без тех, что в сессии уже есть поставщиком Шага 3. «Сохранить черновик» оставляет
|
||||||
|
// такого поставщика в списке Шага 3 (см. buildSuppliersPatch), а в SP он уже лежит колонкой
|
||||||
|
// «поставщик_заглушка». Без этого при открытии СТ, где других поставщиков нет, колонка задваивалась:
|
||||||
|
// одна из SP без цен, вторая из сессии с ценами.
|
||||||
|
export function withoutSessionExtras(spColumns: Supplier[], extras: SessionExtraColumn[]): Supplier[] {
|
||||||
|
return spColumns.filter(column => !extras.some(extra =>
|
||||||
|
extra.spId !== undefined && extra.spId === column.spId && extra.offerId === column.kpFileSpId,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Колонка победителя по winSupplier из шапки СТ (СТ открыта без сессии). В SP хранится поставщик,
|
// Колонка победителя по winSupplier из шапки СТ (СТ открыта без сессии). В SP хранится поставщик,
|
||||||
// а не колонка: у поставщика с несколькими КП берём первую неисключённую колонку.
|
// а не колонка: у поставщика с несколькими КП берём первую неисключённую колонку.
|
||||||
export function findWinnerColumnId(columns: Supplier[], winSupplierSpId: number | undefined): string | undefined {
|
export function findWinnerColumnId(columns: Supplier[], winSupplierSpId: number | undefined): string | undefined {
|
||||||
@@ -75,6 +92,11 @@ export function buildColumnLabels(
|
|||||||
if (group.length < 2) return;
|
if (group.length < 2) return;
|
||||||
group.forEach((c, index) => {
|
group.forEach((c, index) => {
|
||||||
const fileName = uploadedKpFiles.find(f => f.spId === c.kpFileSpId)?.name;
|
const fileName = uploadedKpFiles.find(f => f.spId === c.kpFileSpId)?.name;
|
||||||
|
// Имя файла-заглушки (поставщик добавлен вручную) людям ничего не говорит.
|
||||||
|
if (fileName !== undefined && isPlaceholderKpFile(fileName)) {
|
||||||
|
labels[c.id] = PLACEHOLDER_COLUMN_LABEL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
labels[c.id] = fileName ?? `КП ${index + 1}`;
|
labels[c.id] = fileName ?? `КП ${index + 1}`;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 —
|
// Пакетное создание элементов списка через SharePoint OData $batch: вместо N POST —
|
||||||
// один HTTP-запрос (до 100 операций/changeset, при большем числе бьём на чанки).
|
// один HTTP-запрос (до 100 операций/changeset, при большем числе бьём на чанки).
|
||||||
// Возвращает Id созданных элементов В ТОМ ЖЕ порядке, что и bodies. Changeset атомарен:
|
// Возвращает Id созданных элементов В ТОМ ЖЕ порядке, что и bodies. Changeset атомарен:
|
||||||
|
|||||||
Reference in New Issue
Block a user