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:
+31
-4
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
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 { CTFormData } from '../hooks/useCtForm';
|
||||
import { FileUploadArea, UploadedFile } from './FileUploadArea';
|
||||
@@ -41,12 +41,39 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
isRestrictedView = false,
|
||||
}) => {
|
||||
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 [saveError, setSaveError] = useState<string | undefined>(undefined);
|
||||
|
||||
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> => {
|
||||
setIsSaving(true);
|
||||
setSaveError(undefined);
|
||||
@@ -98,11 +125,11 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
|
||||
<section className={`${styles.section} ${styles.currencySection}`}>
|
||||
<h3 className={styles.sectionTitle}>Курсы валют</h3>
|
||||
<CurrencyRatesForm rates={currencyRates} onChange={setCurrencyRates} readOnly={isReadOnly} />
|
||||
<CurrencyRatesForm rates={currencyRates} onChange={handleCurrencyRatesChange} readOnly={isReadOnly} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<SuppliersWithoutKp items={noOfferSuppliers} onChange={setNoOfferSuppliers} disabled={isReadOnly} />
|
||||
<SuppliersWithoutKp items={noOfferSuppliers} onChange={handleNoOfferChange} disabled={isReadOnly} />
|
||||
|
||||
{!isRestrictedView && (formData.uploadedKpFiles?.length ?? 0) > 0 && (
|
||||
<UploadedKpList
|
||||
|
||||
+50
-39
@@ -12,6 +12,55 @@ export interface PersistStep2Args {
|
||||
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: курсы валют, поставщики без КП, поставщики с КП + офферы из результатов потока,
|
||||
// пересборка цепочки согласования из матрицы. Возвращает patch для onSave. Логика 1:1 из исходного handleSave.
|
||||
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);
|
||||
}
|
||||
|
||||
const newSuppliersWithOffer: 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 { suppliers: newSuppliersWithOffer, offers: newOffers } = buildKpSuppliersAndOffers(kpFiles, savedRates);
|
||||
|
||||
const suppliersWithoutOffer: Supplier[] = noOfferSuppliers.map(s => ({
|
||||
id: s.localId,
|
||||
@@ -42,36 +83,6 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
|
||||
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.
|
||||
// saveKpOffer (called during flow) omits valutaId and stores price as priceTenge — fix here
|
||||
// before proceeding to Step 3.
|
||||
|
||||
+22
-3
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { CtService } from '../../services/CtService';
|
||||
import { CTFormData } from '../hooks/useCtForm';
|
||||
import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from './kpTypes';
|
||||
import { buildKpSuppliersAndOffers } from './step2Persist';
|
||||
|
||||
let localIdCounter = 0;
|
||||
|
||||
@@ -34,17 +35,35 @@ export function useKpProcessing(
|
||||
useEffect(() => { kpFilesRef.current = kpFiles; }, [kpFiles]);
|
||||
useEffect(() => () => { mountedRef.current = false; }, []);
|
||||
|
||||
// Сохраняем завершённые файлы в formData при изменении статуса
|
||||
// Сохраняем завершённые файлы в formData при изменении статуса: список загруженных КП, а также
|
||||
// поставщиков с офферами. Иначе поставщик, чей КП загрузили после первого сохранения Шага 2,
|
||||
// не доходит до formData.suppliers и не появляется колонкой в таблице сравнения на Шаге 3
|
||||
// (переход крошкой минует persistStep2). Валюта, выбранная для файла, тоже попадает сюда.
|
||||
useEffect(() => {
|
||||
const doneFiles = kpFiles.filter(f => f.status === 'done' && f.spId !== undefined);
|
||||
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
|
||||
.filter(f => !currentUploaded.some(u => u.spId === f.spId))
|
||||
.map(f => ({ name: f.file.name, spId: f.spId as number, supplierName: f.supplierName }));
|
||||
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
|
||||
}, [kpFiles]);
|
||||
|
||||
|
||||
@@ -60,16 +60,19 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) {
|
||||
const replaceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const moveMember = useCallback((id: string, direction: 'up' | 'down'): void => {
|
||||
setChain((prev) => {
|
||||
const idx = prev.findIndex((m) => m.id === id);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev];
|
||||
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||
if (swapIdx < 0 || swapIdx >= next.length) return prev;
|
||||
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
|
||||
return next.map((m, i) => ({ ...m, order: i + 1 }));
|
||||
});
|
||||
}, []);
|
||||
const idx = chain.findIndex((m) => m.id === id);
|
||||
if (idx === -1) return;
|
||||
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||
if (swapIdx < 0 || swapIdx >= chain.length) return;
|
||||
const next = [...chain];
|
||||
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
|
||||
const reordered = 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 => {
|
||||
setActionDialog({
|
||||
|
||||
Reference in New Issue
Block a user