fix: сохранение данных шагов 2 и 4 при навигации хлебными крошками

Навигация через goToStep (хлебные крошки) переключает шаг, не вызывая
per-step save, поэтому локальный стейт, который писался в formData только
по кнопке «Сохранить и продолжить», терялся при переходе на другой шаг.

- Шаг 2: поставщики без КП теперь гидратируются из formData и пишутся
  в formData.suppliers сразу при изменении (а не только в persistStep2).
- Шаг 2: курсы валют пишутся в formData при каждой правке.
- Шаг 2: поставщик с КП и его офферы (с валютой файла) пишутся в formData
  инкрементально при завершении обработки КП, иначе загруженный после
  первого сохранения поставщик не попадал колонкой в таблицу шага 3.
  Построение поставщиков/офферов вынесено в общую buildKpSuppliersAndOffers.
- Шаг 4: переупорядочивание согласующих (moveMember) пишет порядок
  в formData сразу, иначе он сбрасывался при переходе на другой шаг.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-09-02 11:35:08 +05:00
parent 5c9392fe49
commit c1a5c6fde7
4 changed files with 116 additions and 56 deletions
@@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { Button, Alert, Spinner } from '@tnsu/ui-kit'; import { Button, Alert, Spinner } from '@tnsu/ui-kit';
import { CurrencyRate, FormStep } from '../types/domain'; import { CurrencyRate, FormStep, Supplier } from '../types/domain';
import { CtService } from '../../services/CtService'; import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm'; import { CTFormData } from '../hooks/useCtForm';
import { FileUploadArea, UploadedFile } from './FileUploadArea'; import { FileUploadArea, UploadedFile } from './FileUploadArea';
@@ -41,12 +41,39 @@ export const Step2CommercialOffers: React.FC<Props> = ({
isRestrictedView = false, isRestrictedView = false,
}) => { }) => {
const [currencyRates, setCurrencyRates] = useState<CurrencyRate[]>(formData.currencyRates); const [currencyRates, setCurrencyRates] = useState<CurrencyRate[]>(formData.currencyRates);
const [noOfferSuppliers, setNoOfferSuppliers] = useState<NoOfferEntry[]>([]); // Гидратируем из уже сохранённых поставщиков без КП, чтобы список не был пустым при возврате на шаг.
const [noOfferSuppliers, setNoOfferSuppliers] = useState<NoOfferEntry[]>(() =>
(formData.suppliers ?? [])
.filter(s => !s.hasOffer)
.map(s => ({ localId: s.id, name: s.name, comment: '' })),
);
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 kp = useKpProcessing(service, formData, onSave); const kp = useKpProcessing(service, formData, onSave);
// Курсы валют пишем в formData сразу, а не только по кнопке «Сохранить и продолжить»:
// иначе при переходе на Шаг 3 через хлебные крошки (в обход persistStep2) правка курса
// теряется, и таблица сравнения считает суммы по старому курсу.
const handleCurrencyRatesChange = (rates: CurrencyRate[]): void => {
setCurrencyRates(rates);
onSave({ currencyRates: rates });
};
// Поставщиков без КП сразу пишем в formData.suppliers (hasOffer:false), а не только по кнопке
// «Сохранить и продолжить». Иначе при переходе на Шаг 3 через хлебные крошки (goToStep в обход
// persistStep2) колонка поставщика без КП теряется и не попадает в таблицу сравнения.
const handleNoOfferChange = (items: NoOfferEntry[]): void => {
setNoOfferSuppliers(items);
const withOffer = (formData.suppliers ?? []).filter(s => s.hasOffer);
const withoutOffer: Supplier[] = items.map(s => ({
id: s.localId,
name: s.name,
hasOffer: false,
}));
onSave({ suppliers: [...withOffer, ...withoutOffer] });
};
const handleSave = async (): Promise<void> => { const handleSave = async (): Promise<void> => {
setIsSaving(true); setIsSaving(true);
setSaveError(undefined); setSaveError(undefined);
@@ -98,11 +125,11 @@ export const Step2CommercialOffers: React.FC<Props> = ({
<section className={`${styles.section} ${styles.currencySection}`}> <section className={`${styles.section} ${styles.currencySection}`}>
<h3 className={styles.sectionTitle}>Курсы валют</h3> <h3 className={styles.sectionTitle}>Курсы валют</h3>
<CurrencyRatesForm rates={currencyRates} onChange={setCurrencyRates} readOnly={isReadOnly} /> <CurrencyRatesForm rates={currencyRates} onChange={handleCurrencyRatesChange} readOnly={isReadOnly} />
</section> </section>
</div> </div>
<SuppliersWithoutKp items={noOfferSuppliers} onChange={setNoOfferSuppliers} disabled={isReadOnly} /> <SuppliersWithoutKp items={noOfferSuppliers} onChange={handleNoOfferChange} disabled={isReadOnly} />
{!isRestrictedView && (formData.uploadedKpFiles?.length ?? 0) > 0 && ( {!isRestrictedView && (formData.uploadedKpFiles?.length ?? 0) > 0 && (
<UploadedKpList <UploadedKpList
@@ -12,6 +12,55 @@ export interface PersistStep2Args {
userDisplayName: string; userDisplayName: string;
} }
// Строит поставщиков с КП и их офферы из результатов потока (Syntex). Вынесено из persistStep2,
// чтобы ту же логику использовал инкрементальный persist в useKpProcessing (поставщик должен
// доходить до Шага 3 даже без кнопки «Сохранить и продолжить»).
export function buildKpSuppliersAndOffers(
kpFiles: KpFile[],
currencyRates: CurrencyRate[],
): { suppliers: Supplier[]; offers: SupplierOffer[] } {
const suppliers: Supplier[] = kpFiles
.filter(f => f.supplierName)
.map(f => ({
id: f.supplierSpId ? String(f.supplierSpId) : f.localId,
spId: f.supplierSpId,
name: f.supplierName as string,
hasOffer: true,
currency: f.currencyCode,
}));
const offers: SupplierOffer[] = [];
for (const f of kpFiles) {
if (!f.supplierSpId || f.flowResults.length === 0) continue;
const currency = f.currencyCode;
const rate = currency !== 'KZT'
? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1)
: 1;
for (const fr of f.flowResults) {
const pricePerUnit = fr.flowResult.price ?? 0;
const quantity = fr.flowResult.quantity ?? 0;
const pricePerUnitKzt = pricePerUnit * rate;
const totalPriceKzt = pricePerUnitKzt * quantity;
offers.push({
id: '',
supplierId: String(f.supplierSpId),
positionId: fr.positionId,
supplierPosition: fr.flowResult.position,
unit: fr.flowResult.unit,
pricePerUnit,
quantity,
currencyCode: currency,
pricePerUnitKzt,
totalPriceKzt,
totalWithExtraKzt: totalPriceKzt,
offerId: f.spId ? String(f.spId) : undefined,
});
}
}
return { suppliers, offers };
}
// Сохранение Шага 2: курсы валют, поставщики без КП, поставщики с КП + офферы из результатов потока, // Сохранение Шага 2: курсы валют, поставщики без КП, поставщики с КП + офферы из результатов потока,
// пересборка цепочки согласования из матрицы. Возвращает patch для onSave. Логика 1:1 из исходного handleSave. // пересборка цепочки согласования из матрицы. Возвращает patch для onSave. Логика 1:1 из исходного handleSave.
export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFormData>> { export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFormData>> {
@@ -26,15 +75,7 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
await service.saveNoOfferSupplierItem(s.name, s.comment); await service.saveNoOfferSupplierItem(s.name, s.comment);
} }
const newSuppliersWithOffer: Supplier[] = kpFiles const { suppliers: newSuppliersWithOffer, offers: newOffers } = buildKpSuppliersAndOffers(kpFiles, savedRates);
.filter(f => f.supplierName)
.map(f => ({
id: f.supplierSpId ? String(f.supplierSpId) : f.localId,
spId: f.supplierSpId,
name: f.supplierName as string,
hasOffer: true,
currency: f.currencyCode,
}));
const suppliersWithoutOffer: Supplier[] = noOfferSuppliers.map(s => ({ const suppliersWithoutOffer: Supplier[] = noOfferSuppliers.map(s => ({
id: s.localId, id: s.localId,
@@ -42,36 +83,6 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
hasOffer: false, hasOffer: false,
})); }));
// Build offers from flow results so Step 3 can pre-fill the table
const newOffers: SupplierOffer[] = [];
for (const f of kpFiles) {
if (!f.supplierSpId || f.flowResults.length === 0) continue;
const currency = f.currencyCode;
const rate = currency !== 'KZT'
? (savedRates.find(r => r.code === currency)?.rateToKzt ?? 1)
: 1;
for (const fr of f.flowResults) {
const pricePerUnit = fr.flowResult.price ?? 0;
const quantity = fr.flowResult.quantity ?? 0;
const pricePerUnitKzt = pricePerUnit * rate;
const totalPriceKzt = pricePerUnitKzt * quantity;
newOffers.push({
id: '',
supplierId: String(f.supplierSpId),
positionId: fr.positionId,
supplierPosition: fr.flowResult.position,
unit: fr.flowResult.unit,
pricePerUnit,
quantity,
currencyCode: currency,
pricePerUnitKzt,
totalPriceKzt,
totalWithExtraKzt: totalPriceKzt,
offerId: f.spId ? String(f.spId) : undefined,
});
}
}
// Persist offers with currency data to SP so shared links show correct currency columns. // Persist offers with currency data to SP so shared links show correct currency columns.
// saveKpOffer (called during flow) omits valutaId and stores price as priceTenge — fix here // saveKpOffer (called during flow) omits valutaId and stores price as priceTenge — fix here
// before proceeding to Step 3. // before proceeding to Step 3.
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react';
import { CtService } from '../../services/CtService'; import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm'; import { CTFormData } from '../hooks/useCtForm';
import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from './kpTypes'; import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from './kpTypes';
import { buildKpSuppliersAndOffers } from './step2Persist';
let localIdCounter = 0; let localIdCounter = 0;
@@ -34,17 +35,35 @@ export function useKpProcessing(
useEffect(() => { kpFilesRef.current = kpFiles; }, [kpFiles]); useEffect(() => { kpFilesRef.current = kpFiles; }, [kpFiles]);
useEffect(() => () => { mountedRef.current = false; }, []); useEffect(() => () => { mountedRef.current = false; }, []);
// Сохраняем завершённые файлы в formData при изменении статуса // Сохраняем завершённые файлы в formData при изменении статуса: список загруженных КП, а также
// поставщиков с офферами. Иначе поставщик, чей КП загрузили после первого сохранения Шага 2,
// не доходит до formData.suppliers и не появляется колонкой в таблице сравнения на Шаге 3
// (переход крошкой минует persistStep2). Валюта, выбранная для файла, тоже попадает сюда.
useEffect(() => { useEffect(() => {
const doneFiles = kpFiles.filter(f => f.status === 'done' && f.spId !== undefined); const doneFiles = kpFiles.filter(f => f.status === 'done' && f.spId !== undefined);
if (doneFiles.length === 0) return; if (doneFiles.length === 0) return;
const currentUploaded = formDataRef.current.uploadedKpFiles ?? []; const fd = formDataRef.current;
const patch: Partial<CTFormData> = {};
const currentUploaded = fd.uploadedKpFiles ?? [];
const newEntries = doneFiles const newEntries = doneFiles
.filter(f => !currentUploaded.some(u => u.spId === f.spId)) .filter(f => !currentUploaded.some(u => u.spId === f.spId))
.map(f => ({ name: f.file.name, spId: f.spId as number, supplierName: f.supplierName })); .map(f => ({ name: f.file.name, spId: f.spId as number, supplierName: f.supplierName }));
if (newEntries.length > 0) { if (newEntries.length > 0) {
onSave({ uploadedKpFiles: [...currentUploaded, ...newEntries] }); patch.uploadedKpFiles = [...currentUploaded, ...newEntries];
} }
const { suppliers: kpSuppliers, offers: kpOffers } = buildKpSuppliersAndOffers(doneFiles, fd.currencyRates);
if (kpSuppliers.length > 0) {
// Заменяем поставщика с тем же именем (повторная загрузка КП), остальных — сохраняем.
const others = (fd.suppliers ?? []).filter(s => !kpSuppliers.some(k => k.name === s.name));
patch.suppliers = [...others, ...kpSuppliers];
const kpSupplierIds = new Set(kpSuppliers.map(s => s.id));
const otherOffers = (fd.offers ?? []).filter(o => !kpSupplierIds.has(o.supplierId));
patch.offers = [...otherOffers, ...kpOffers];
}
if (Object.keys(patch).length > 0) onSave(patch);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [kpFiles]); }, [kpFiles]);
@@ -60,16 +60,19 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) {
const replaceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const replaceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const moveMember = useCallback((id: string, direction: 'up' | 'down'): void => { const moveMember = useCallback((id: string, direction: 'up' | 'down'): void => {
setChain((prev) => { const idx = chain.findIndex((m) => m.id === id);
const idx = prev.findIndex((m) => m.id === id); if (idx === -1) return;
if (idx === -1) return prev; const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
const next = [...prev]; if (swapIdx < 0 || swapIdx >= chain.length) return;
const swapIdx = direction === 'up' ? idx - 1 : idx + 1; const next = [...chain];
if (swapIdx < 0 || swapIdx >= next.length) return prev; [next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]]; const reordered = next.map((m, i) => ({ ...m, order: i + 1 }));
return next.map((m, i) => ({ ...m, order: i + 1 })); setChain(reordered);
}); // Пишем порядок в formData сразу, иначе переупорядочивание теряется при переходе на другой шаг
}, []); // (chain реинициализируется из formData.approvalChain при возврате). В SP порядок фиксируется
// на отправке на согласование (activateApprovalChain).
onSave({ approvalChain: reordered });
}, [chain, onSave]);
const openActionDialog = useCallback((member: ApprovalMember, action: ApprovalAction): void => { const openActionDialog = useCallback((member: ApprovalMember, action: ApprovalAction): void => {
setActionDialog({ setActionDialog({