feat(ct): подсветка незаполненного на Шаге 3, условия оплаты обязательны (#9)

Красная рамка — то, что не пускает дальше Шага 3: «Выбрать», пока нет
победителя, пустой километраж логистики, пустые условия оплаты. Условия
оплаты были помечены обязательными, но сохранять не мешали — теперь
проверяются. Пустая цена — бледной заливкой: заметно, но сохранению не мешает,
поставщик мог не предложить позицию. Подсветка видна сразу, пока заполняют.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-27 16:43:31 +05:00
parent ad76e59ca0
commit 78e5eec9b6
7 changed files with 84 additions and 15 deletions
@@ -34,6 +34,8 @@ 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); const totalColSpan = FIXED_COLS + suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0);
@@ -113,7 +115,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 ? 'Снять выбор' : 'Выбрать победителем'}
@@ -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';
@@ -75,6 +76,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}>
@@ -124,7 +129,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
<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 +165,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 +185,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
@@ -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
@@ -1349,3 +1349,23 @@ $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;
}
@@ -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);
});
}); });
@@ -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;
@@ -721,6 +721,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}`])
), ),