feat(ct): «Сохранить черновик» на Шагах 2 и 3 (#8)
Запись шага в SharePoint без проверок и без перехода — правки не пропадают при закрытии вкладки. Проверено в воркбенче, дублей поставщиков в SP нет. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,11 @@
|
||||
|
||||
## [Не выпущено]
|
||||
|
||||
### Добавлено
|
||||
|
||||
- СТ ОМТС, Шаги 2 и 3: кнопка «Сохранить черновик» — записывает шаг в SharePoint без проверок
|
||||
и оставляет на шаге, чтобы правки не пропали при закрытии вкладки. (#8)
|
||||
|
||||
### Изменено
|
||||
|
||||
- СТ ОМТС, Шаг 3: крестик у поставщика не удаляет его, а исключает из сравнения с обязательной
|
||||
|
||||
+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>
|
||||
);
|
||||
+22
-4
@@ -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>
|
||||
|
||||
+6
-2
@@ -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}
|
||||
>
|
||||
|
||||
+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: [] };
|
||||
}
|
||||
+20
-23
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user