feat(ct): «Сохранить черновик» на Шагах 2 и 3 (#8)

Несохранённые правки жили в памяти вкладки и пропадали при её закрытии, а
«Сохранить и продолжить» на Шаге 3 не пускает без победителя и километража.
Черновик пишет шаг в SharePoint так же, без проверок, и оставляет на шаге;
рядом — время последнего сохранения.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-27 16:16:23 +05:00
parent 7341774e50
commit 09b1df87c3
7 changed files with 200 additions and 29 deletions
@@ -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>
);
@@ -13,6 +13,7 @@ import { SuppliersWithoutKp } from './SuppliersWithoutKp';
import { UploadedKpList } from './UploadedKpList';
import { removeKpFileFromComparison } from './kpRemoval';
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
import { SaveDraftButton } from '../SaveDraftButton/SaveDraftButton';
import styles from './Step2CommercialOffers.module.scss';
interface Props {
@@ -50,6 +51,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined);
const [draftSavedAt, setDraftSavedAt] = useState<Date | undefined>(undefined);
const kp = useKpProcessing(service, formData, onSave);
@@ -136,12 +138,18 @@ export const Step2CommercialOffers: React.FC<Props> = ({
kp.handleFileRemove(localId);
};
const handleSave = async (): Promise<void> => {
// 'draft' — записать шаг в SP и остаться: распознанные КП попадают в SP только здесь, и без
// черновика пропадали при закрытии вкладки.
const handleSave = async (mode: 'next' | 'draft' = 'next'): Promise<void> => {
setIsSaving(true);
setSaveError(undefined);
try {
const patch = await persistStep2({ service, formData, kpFiles: kp.kpFiles, currencyRates, noOfferSuppliers });
onSave(patch);
if (mode === 'draft') {
setDraftSavedAt(new Date());
return;
}
onStepComplete(2);
onNext();
} catch (err) {
@@ -212,9 +220,19 @@ export const Step2CommercialOffers: React.FC<Props> = ({
<div className={styles.footerRight}>
{saveError && <Alert variant="err">{saveError}</Alert>}
{canEdit && !isReadOnly && (
<Button variant="accent" onClick={handleSave} disabled={!canProceed}>
{isSaving ? <Spinner size="small" /> : 'Сохранить и продолжить'}
</Button>
<>
<SaveDraftButton
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>
@@ -19,6 +19,7 @@ import { useAdataCheck } from './useAdataCheck';
import { useRecommendedPick } from './useRecommendedPick';
import { VendorPickerDialog } from './VendorPickerDialog';
import { ExcludedSuppliersList } from './ExcludedSuppliersList';
import { SaveDraftButton } from '../SaveDraftButton/SaveDraftButton';
// Исключённых поставщиков в режиме просмотра (согласующий, отправленная СТ) пока не показываем —
// решение от 2026-10-27 (docs/adr/0001). Данные грузятся и так: включить — поменять на true.
@@ -57,7 +58,7 @@ export const Step3ComparisonTable: React.FC<Props> = ({
const {
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError,
validateBeforeSave, handleSave,
validateBeforeSave, handleSave, draftSavedAt,
extraPositions, suppliersWithOffer, columnDisplayNames, allPositions, computedOffers, selectedSupplierId,
excludedColumns, handleRestoreSupplier,
} = matrix;
@@ -226,6 +227,9 @@ export const Step3ComparisonTable: React.FC<Props> = ({
</Button>
<div className={styles.footerRight}>
{saveError && <Alert variant="err">{saveError}</Alert>}
{!isReadOnly && (
<SaveDraftButton onSave={() => { handleSave('draft').catch(() => undefined /* ошибку показывает handleSave */); }} isSaving={isSaving} savedAt={draftSavedAt} />
)}
<Button
variant="secondary"
onClick={ai.handleGeneratePdfReport}
@@ -247,7 +251,7 @@ export const Step3ComparisonTable: React.FC<Props> = ({
// Сначала проверка (победитель, километраж), потом предложение комментария ИИ.
if (!validateBeforeSave()) return;
if (!ai.aiComment && !ai.isAiLoading) ai.setShowAiPromptDialog(true);
else void handleSave();
else handleSave().catch(() => undefined /* ошибку показывает handleSave */);
}}
disabled={isSaving || isReadOnly}
>
@@ -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: [] };
}
@@ -8,6 +8,7 @@ import { applyCreatedIds, buildOfferKey, computeColumnsOffers, sumPositionTotals
import { buildExcludedRecordsForSave } from '../utils/excludedColumns';
import { parseNumberInput } from '../utils/parseNumberInput';
import { getStep3SaveError } from './step3Validation';
import { Step3SaveMode, buildSuppliersPatch } from './step3SavePatch';
import { IVendorRecord } from '../../services/VendorDatabaseService';
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
@@ -106,6 +107,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
}, [onSave]);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined);
// Когда последний раз сохранили черновик — показываем рядом с кнопкой.
const [draftSavedAt, setDraftSavedAt] = useState<Date | undefined>(undefined);
// Тумблер «Перевести в ед. изм. заявки»: переводит кол-во и цену поставщика в ед. заявки.
// При включении блоки поставщиков показываются как вычисленные значения (без редактирования).
const [translateUnits, setTranslateUnits] = useState(false);
@@ -726,9 +729,11 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
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 (!validateBeforeSave()) return;
if (mode === 'next' && !validateBeforeSave()) return;
setIsSaving(true);
setSaveError(undefined);
@@ -880,28 +885,15 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
onSave({
offers: offersForSession,
suppliers: [
...suppliers.map(s => ({
...s,
status: supplierStatuses[s.id] ?? s.status,
})),
// Move extras into suppliers (with React ID kept as `id` — required so formData.offers
// entries with React IDs still match sup.id on remount). Skip extras that are already in
// 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,
})),
],
// Поставщики Шага 3 переезжают в suppliers только при переходе на Шаг 4 (см. buildSuppliersPatch).
...buildSuppliersPatch({
mode,
suppliers,
extraSuppliers,
statuses: supplierStatuses,
extraSupplierSpIds: extraSupSpIds,
}),
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
// "доп.расходы" rows continue to render after Step 4 → breadcrumb back. Track spId to
// skip re-creating SP rows on subsequent saves.
@@ -918,6 +910,10 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
step3CriteriaEdits: criteriaMap,
step3CustomCriteria: customCriteria,
});
if (mode === 'draft') {
setDraftSavedAt(new Date());
return;
}
saveCompletedRef.current = true;
onStepComplete(3);
onNext();
@@ -997,6 +993,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
handleDeleteExtraPosition,
validateBeforeSave,
handleSave,
draftSavedAt,
};
}