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 * 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
|
||||||
|
|||||||
+50
-39
@@ -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.
|
||||||
|
|||||||
+22
-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({
|
||||||
|
|||||||
Reference in New Issue
Block a user