feat(ct): подсветка незаполненного на Шаге 3, условия оплаты обязательны (#9)
Красная рамка — то, что не пускает дальше Шага 3: «Выбрать», пока нет победителя, пустой километраж логистики, пустые условия оплаты. Условия оплаты были помечены обязательными, но сохранять не мешали — теперь проверяются. Пустая цена — бледной заливкой: заметно, но сохранению не мешает, поставщик мог не предложить позицию. Подсветка видна сразу, пока заполняют. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+8
-1
@@ -34,6 +34,8 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
||||
// Колонка, для которой открыто окно «Исключить из сравнения».
|
||||
const [excludingSupplierId, setExcludingSupplierId] = React.useState<string | undefined>(undefined);
|
||||
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);
|
||||
|
||||
@@ -113,7 +115,12 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
||||
{!isReadOnly ? (
|
||||
<>
|
||||
<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))}
|
||||
aria-pressed={isSel}
|
||||
title={isSel ? 'Снять выбор' : 'Выбрать победителем'}
|
||||
|
||||
+8
-3
@@ -5,6 +5,7 @@ import { PositionRow } from './matrixTypes';
|
||||
import { Step3Matrix } from './useStep3Matrix';
|
||||
import { getUnitStatus } from '../utils/unitConversion';
|
||||
import { unitStatusClass } from './unitStatusStyles';
|
||||
import { isPriceMissing } from './step3Validation';
|
||||
import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
|
||||
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 dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
|
||||
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 (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.deliveryKmBox}>
|
||||
<div className={[styles.deliveryKmBox, kmMissingCls].filter(Boolean).join(' ')}>
|
||||
{showInput ? (
|
||||
<div className={styles.offerInput}>
|
||||
<TextInput
|
||||
@@ -160,7 +165,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
||||
</td>
|
||||
{isForeign && (
|
||||
<>
|
||||
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
|
||||
<td className={[styles.dataCell, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||
{showInput ? (
|
||||
<div className={styles.offerInput}>
|
||||
<TextInput
|
||||
@@ -180,7 +185,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
|
||||
<td className={[styles.dataCell, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||
{showInput ? (
|
||||
<div className={styles.offerInput}>
|
||||
<TextInput
|
||||
|
||||
+5
-2
@@ -6,6 +6,7 @@ import { Step3Matrix } from './useStep3Matrix';
|
||||
import { getUnitStatus } from '../utils/unitConversion';
|
||||
import { parseNumberInput } from '../utils/parseNumberInput';
|
||||
import { unitStatusClass } from './unitStatusStyles';
|
||||
import { isPriceMissing } from './step3Validation';
|
||||
import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
|
||||
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 dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
|
||||
const showInput = !isReadOnly && !translateUnits;
|
||||
// Пустая цена — подсветка сразу, пока заполняют; сохранению не мешает (см. isPriceMissing).
|
||||
const priceMissingCls = !isReadOnly && isPriceMissing(price, item.priceKztOverride) ? styles.missingPriceCell : '';
|
||||
|
||||
return (
|
||||
<React.Fragment key={s.id}>
|
||||
@@ -160,7 +163,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
||||
</td>
|
||||
{!isRestrictedView && isForeign && (
|
||||
<>
|
||||
<td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')}>
|
||||
<td className={[styles.dataCell, valueCls, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||
{showInput ? (
|
||||
<div className={styles.offerInput}>
|
||||
<TextInput
|
||||
@@ -180,7 +183,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
{!isRestrictedView && <td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')}>
|
||||
{!isRestrictedView && <td className={[styles.dataCell, valueCls, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||
{showInput ? (
|
||||
<div className={styles.offerInput}>
|
||||
<TextInput
|
||||
|
||||
+20
@@ -1349,3 +1349,23 @@ $delivery-km-min-width: 76px;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
|
||||
+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', () => {
|
||||
it('нажали «Выбрать» — поставщик выбран', () => {
|
||||
@@ -19,23 +19,44 @@ describe('getStep3SaveError', () => {
|
||||
const compared = ['259_278', '282_320'];
|
||||
|
||||
it('победитель выбран — сохранять можно', () => {
|
||||
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasDeliveryWithoutKm: false }))
|
||||
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: false }))
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
it('победитель не выбран — нельзя', () => {
|
||||
expect(getStep3SaveError({ selectedSupplierId: undefined, comparedColumnIds: compared, hasDeliveryWithoutKm: false }))
|
||||
expect(getStep3SaveError({ selectedSupplierId: undefined, comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: false }))
|
||||
.toBe(STEP3_SAVE_ERRORS.noWinner);
|
||||
});
|
||||
|
||||
// Выбор из сессии мог остаться на поставщике, которого исключили или чей КП убрали.
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// Решение от 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 = {
|
||||
noWinner: 'Выберите поставщика-победителя: кнопка «Выбрать» в шапке его колонки.',
|
||||
paymentTermsMissing: 'Заполните условия оплаты у всех поставщиков в «Дополнительной информации».',
|
||||
deliveryKmMissing: 'Укажите километраж для каждого потенциального поставщика в строке логистики',
|
||||
};
|
||||
|
||||
@@ -9,21 +10,32 @@ export interface Step3SaveCheckInput {
|
||||
selectedSupplierId: string | undefined;
|
||||
// Колонки, которые сравниваются (без исключённых).
|
||||
comparedColumnIds: string[];
|
||||
hasMissingPaymentTerms: boolean;
|
||||
hasDeliveryWithoutKm: boolean;
|
||||
}
|
||||
|
||||
// Победитель обязателен (решение от 2026-10-27): он уходит в шапку СТ (winSupplier) — без него
|
||||
// отчёт и согласование не знают, кого выбрали. Выбранный должен быть среди сравниваемых:
|
||||
// исключённый или удалённый поставщик победителем не считается.
|
||||
// Что не пускает дальше Шага 3 (решения от 2026-10-27). Всё это подсвечено красной рамкой сразу,
|
||||
// пока заполняют, — подсветка показывает ровно то, что проверяется здесь.
|
||||
// - Победитель: уходит в шапку СТ (winSupplier) — без него отчёт и согласование не знают, кого
|
||||
// выбрали. Должен быть среди сравниваемых: исключённый поставщик победителем не считается.
|
||||
// - Условия оплаты: по ним считается рекомендация (80% цена / 20% условия оплаты).
|
||||
// - Километраж логистики.
|
||||
export function getStep3SaveError(input: Step3SaveCheckInput): string | undefined {
|
||||
const { selectedSupplierId, comparedColumnIds, hasDeliveryWithoutKm } = input;
|
||||
const { selectedSupplierId, comparedColumnIds, hasMissingPaymentTerms, hasDeliveryWithoutKm } = input;
|
||||
if (!selectedSupplierId || !comparedColumnIds.includes(selectedSupplierId)) {
|
||||
return STEP3_SAVE_ERRORS.noWinner;
|
||||
}
|
||||
if (hasMissingPaymentTerms) return STEP3_SAVE_ERRORS.paymentTermsMissing;
|
||||
if (hasDeliveryWithoutKm) return STEP3_SAVE_ERRORS.deliveryKmMissing;
|
||||
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 {
|
||||
return currentId === clickedId ? undefined : clickedId;
|
||||
|
||||
@@ -721,6 +721,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
const error = getStep3SaveError({
|
||||
selectedSupplierId,
|
||||
comparedColumnIds: suppliersWithOffer.map(s => s.id),
|
||||
hasMissingPaymentTerms: suppliersWithOffer.some(s => !(criteriaMap[s.id]?.paymentType ?? '').trim()),
|
||||
hasDeliveryWithoutKm: extraPositions.some(ep =>
|
||||
ep.isDelivery && suppliersWithOffer.some(s => !deliveryKm[`${ep.id}::${s.id}`])
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user