Compare commits
3 Commits
5c9392fe49
...
f615ca3c95
| Author | SHA1 | Date | |
|---|---|---|---|
| f615ca3c95 | |||
| 158c007c15 | |||
| c1a5c6fde7 |
Generated
+10
-1
@@ -16,6 +16,7 @@
|
|||||||
"@microsoft/sp-property-pane": "1.22.1",
|
"@microsoft/sp-property-pane": "1.22.1",
|
||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@tnsu/ui-kit": "^0.11.0",
|
"@tnsu/ui-kit": "^0.11.0",
|
||||||
|
"ncalayer-js-client": "^1.6.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
@@ -15087,6 +15088,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ncalayer-js-client": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ncalayer-js-client/-/ncalayer-js-client-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-poSneFadh+I8630ZmycLhKPZTn7Gqju0/TiMmWXnqEsG0mQKrEH7ZbeLwYrynryotReHnLYTA5VLZr8TnE3X5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.4.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
@@ -19960,7 +19970,6 @@
|
|||||||
"version": "8.21.3",
|
"version": "8.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@microsoft/sp-property-pane": "1.22.1",
|
"@microsoft/sp-property-pane": "1.22.1",
|
||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@tnsu/ui-kit": "^0.11.0",
|
"@tnsu/ui-kit": "^0.11.0",
|
||||||
|
"ncalayer-js-client": "^1.6.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
|
|||||||
Vendored
+39
@@ -0,0 +1,39 @@
|
|||||||
|
// Ambient-типы для NCALayer JS client (у пакета нет собственных .d.ts).
|
||||||
|
// Используется только CMS-подписание через десктопный NCALayer (см. SigexSignService).
|
||||||
|
declare module 'ncalayer-js-client' {
|
||||||
|
export class NCALayerError extends Error {
|
||||||
|
canceledByUser?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CMSParams {
|
||||||
|
decode?: boolean;
|
||||||
|
encapsulate?: boolean;
|
||||||
|
digested?: boolean;
|
||||||
|
tsaProfile?: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignerParams {
|
||||||
|
extKeyUsageOids: string[];
|
||||||
|
chain?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NCALayerClient {
|
||||||
|
constructor(url?: string, allowKmdHttpApi?: boolean);
|
||||||
|
|
||||||
|
static basicsStorageAll: null;
|
||||||
|
|
||||||
|
static basicsCMSParamsDetachedNoTSP: CMSParams;
|
||||||
|
|
||||||
|
static basicsSignerSignAny: SignerParams;
|
||||||
|
|
||||||
|
connect(): Promise<string>;
|
||||||
|
|
||||||
|
basicsSignCMS(
|
||||||
|
allowedStorages: string[] | null,
|
||||||
|
data: string | ArrayBuffer | Blob | File,
|
||||||
|
signingParams: CMSParams,
|
||||||
|
signerParams: SignerParams,
|
||||||
|
locale?: string,
|
||||||
|
): Promise<string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
+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]);
|
||||||
|
|
||||||
|
|||||||
+21
-2
@@ -31,12 +31,31 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
|||||||
|
|
||||||
const totalColSpan = FIXED_COLS + suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0);
|
const totalColSpan = FIXED_COLS + suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0);
|
||||||
|
|
||||||
|
// Высота row1 (шапка поставщика) переменная (теги валют, выбор поставщика, состояние Adata),
|
||||||
|
// а row2 прибит sticky сразу под ней. Измеряем реальную высоту и отдаём в CSS-переменную,
|
||||||
|
// иначе подзаголовки наезжают на первую строку таблицы.
|
||||||
|
const headerRow1Ref = React.useRef<HTMLTableRowElement>(null);
|
||||||
|
const [row1Height, setRow1Height] = React.useState(70);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const el = headerRow1Ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const update = (): void => setRow1Height(Math.round(el.getBoundingClientRect().height));
|
||||||
|
update();
|
||||||
|
const observer = new ResizeObserver(update);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.tableScrollWrapper}>
|
<div className={styles.tableScrollWrapper}>
|
||||||
<table className={styles.ctTable}>
|
<table
|
||||||
|
className={styles.ctTable}
|
||||||
|
style={{ '--header-row1-h': `${row1Height}px` } as React.CSSProperties}
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
{/* Row 1: fixed headers (rowSpan=2) + supplier group headers (colSpan varies by currency) */}
|
{/* Row 1: fixed headers (rowSpan=2) + supplier group headers (colSpan varies by currency) */}
|
||||||
<tr className={styles.headerRow1}>
|
<tr className={styles.headerRow1} ref={headerRow1Ref}>
|
||||||
<th rowSpan={2} className={styles.colPosition}>Наименование</th>
|
<th rowSpan={2} className={styles.colPosition}>Наименование</th>
|
||||||
<th rowSpan={2} className={styles.colUnit}>Ед. изм.</th>
|
<th rowSpan={2} className={styles.colUnit}>Ед. изм.</th>
|
||||||
<th rowSpan={2} className={styles.colQty}>Кол-во</th>
|
<th rowSpan={2} className={styles.colQty}>Кол-во</th>
|
||||||
|
|||||||
+3
-1
@@ -48,7 +48,9 @@
|
|||||||
border-bottom: 2px solid $accent-border;
|
border-bottom: 2px solid $accent-border;
|
||||||
border-right: 1px solid $border;
|
border-right: 1px solid $border;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 70px; // approximate height of row1 — adjust if needed
|
// Высота row1 (шапка поставщика) переменная — измеряется в ComparisonMatrix и прокидывается
|
||||||
|
// сюда через CSS-переменную. Хардкод приводил к наезду подзаголовков на первую строку.
|
||||||
|
top: var(--header-row1-h, 70px);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
min-width: 90px;
|
min-width: 90px;
|
||||||
|
|||||||
+14
@@ -265,3 +265,17 @@
|
|||||||
.dialogError {
|
.dialogError {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.incoreProgress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.incoreHint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Button, Alert, Spinner, IconButton, Dialog, TextInput, TextArea, Icon,
|
|||||||
import { ApprovalMemberStatus, FormStep } from "../types/domain";
|
import { ApprovalMemberStatus, FormStep } from "../types/domain";
|
||||||
import { CtService } from "../../services/CtService";
|
import { CtService } from "../../services/CtService";
|
||||||
import { CTFormData } from "../hooks/useCtForm";
|
import { CTFormData } from "../hooks/useCtForm";
|
||||||
import { useStep4Approval } from "./useStep4Approval";
|
import { useStep4Approval, IncoreStep } from "./useStep4Approval";
|
||||||
import styles from "./Step4Approval.module.scss";
|
import styles from "./Step4Approval.module.scss";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -23,6 +23,15 @@ const STATUS_LABELS: Record<ApprovalMemberStatus, string> = {
|
|||||||
skipped: "Пропущено",
|
skipped: "Пропущено",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const INCORE_STEP_LABELS: Record<IncoreStep, string> = {
|
||||||
|
report: "Формирование отчёта СТ…",
|
||||||
|
connecting: "Подключение к NCALayer…",
|
||||||
|
signing: "Ожидание подписи в NCALayer…",
|
||||||
|
registering: "Регистрация документа в SIGEX…",
|
||||||
|
finalizing: "Фиксация документа…",
|
||||||
|
building: "Формирование карточки с ЭЦП…",
|
||||||
|
};
|
||||||
|
|
||||||
function getStatusClass(status: ApprovalMemberStatus): string {
|
function getStatusClass(status: ApprovalMemberStatus): string {
|
||||||
const map: Record<ApprovalMemberStatus, string> = {
|
const map: Record<ApprovalMemberStatus, string> = {
|
||||||
pending: styles.statusPending,
|
pending: styles.statusPending,
|
||||||
@@ -379,6 +388,42 @@ export const Step4Approval: React.FC<Props> = (props) => {
|
|||||||
)}
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* INCORE: прогресс подписания и сборки карточки */}
|
||||||
|
{a.incoreStep && (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
title="Подписание и выгрузка в INCORE"
|
||||||
|
onDismiss={() => undefined}
|
||||||
|
>
|
||||||
|
<div className={styles.incoreProgress}>
|
||||||
|
<Spinner size="small" />
|
||||||
|
<span>{INCORE_STEP_LABELS[a.incoreStep]}</span>
|
||||||
|
</div>
|
||||||
|
{a.incoreStep === "signing" && (
|
||||||
|
<p className={styles.incoreHint}>
|
||||||
|
Выберите сертификат ЭЦП и введите PIN-код в открывшемся окне
|
||||||
|
NCALayer.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* INCORE: ошибка подписания */}
|
||||||
|
{a.incoreError && (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
title="Ошибка выгрузки в INCORE"
|
||||||
|
onDismiss={a.dismissIncoreError}
|
||||||
|
footer={
|
||||||
|
<Button variant="secondary" onClick={a.dismissIncoreError}>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Alert variant="err">{a.incoreError}</Alert>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import { ApprovalMember, ApprovalMemberStatus, FormStep } from '../types/domain';
|
import { ApprovalMember, ApprovalMemberStatus, FormStep } from '../types/domain';
|
||||||
import { CtService } from '../../services/CtService';
|
import { CtService } from '../../services/CtService';
|
||||||
|
import { SigexSignService, SigexSignStep } from '../../services/SigexSignService';
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
|
|
||||||
|
// Этапы выгрузки пакета в INCORE: формирование отчёта + этапы подписания SIGEX.
|
||||||
|
export type IncoreStep = 'report' | SigexSignStep;
|
||||||
|
|
||||||
|
// Подпись через SIGEX без состояния — один экземпляр на модуль.
|
||||||
|
const sigexSign = new SigexSignService();
|
||||||
|
|
||||||
export type ApprovalAction = 'approve' | 'reject';
|
export type ApprovalAction = 'approve' | 'reject';
|
||||||
|
|
||||||
export interface ActionDialogState {
|
export interface ActionDialogState {
|
||||||
@@ -44,6 +51,8 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) {
|
|||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [notifyWarning, setNotifyWarning] = useState<string | null>(null);
|
const [notifyWarning, setNotifyWarning] = useState<string | null>(null);
|
||||||
const [isSendingToIncore, setIsSendingToIncore] = useState(false);
|
const [isSendingToIncore, setIsSendingToIncore] = useState(false);
|
||||||
|
const [incoreStep, setIncoreStep] = useState<IncoreStep | null>(null);
|
||||||
|
const [incoreError, setIncoreError] = useState<string | null>(null);
|
||||||
|
|
||||||
// ─── Add member ───
|
// ─── Add member ───
|
||||||
const [showAddRow, setShowAddRow] = useState(false);
|
const [showAddRow, setShowAddRow] = useState(false);
|
||||||
@@ -60,16 +69,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 next = [...prev];
|
|
||||||
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||||
if (swapIdx < 0 || swapIdx >= next.length) return prev;
|
if (swapIdx < 0 || swapIdx >= chain.length) return;
|
||||||
|
const next = [...chain];
|
||||||
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
|
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
|
||||||
return next.map((m, i) => ({ ...m, order: i + 1 }));
|
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 => {
|
const openActionDialog = useCallback((member: ApprovalMember, action: ApprovalAction): void => {
|
||||||
setActionDialog({
|
setActionDialog({
|
||||||
@@ -321,9 +333,22 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) {
|
|||||||
const handleSendToIncore = async (): Promise<void> => {
|
const handleSendToIncore = async (): Promise<void> => {
|
||||||
if (!ct) return;
|
if (!ct) return;
|
||||||
setIsSendingToIncore(true);
|
setIsSendingToIncore(true);
|
||||||
setSubmitError(null);
|
setIncoreError(null);
|
||||||
try {
|
try {
|
||||||
await service.generateCtReport(ct.id);
|
// 1. Отчёт СТ в PDF — карточка SIGEX визуализирует именно PDF.
|
||||||
|
setIncoreStep('report');
|
||||||
|
const reportBlob = await service.getCtReportBlob(ct.id, 'pdf');
|
||||||
|
const title = `CT_${ct.id}.pdf`;
|
||||||
|
|
||||||
|
// 2. Подпись NCALayer → регистрация → фиксация → карточка (DDC).
|
||||||
|
const cardBlob = await sigexSign.signAndBuildCard(
|
||||||
|
reportBlob,
|
||||||
|
title,
|
||||||
|
setIncoreStep,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Пакет для INCORE: карточка с ЭЦП + файлы КП.
|
||||||
|
service.downloadBlob(cardBlob, `Карточка_CT_${ct.id}.pdf`);
|
||||||
|
|
||||||
const kpFiles = formData.uploadedKpFiles ?? [];
|
const kpFiles = formData.uploadedKpFiles ?? [];
|
||||||
for (const f of kpFiles) {
|
for (const f of kpFiles) {
|
||||||
@@ -331,8 +356,13 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) {
|
|||||||
await new Promise<void>((resolve) => setTimeout(resolve, 300));
|
await new Promise<void>((resolve) => setTimeout(resolve, 300));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSubmitError((err as Error).message ?? 'Ошибка скачивания файлов');
|
// Пользователь закрыл окно NCALayer, не подписав — это не ошибка.
|
||||||
|
if ((err as { canceledByUser?: boolean }).canceledByUser) return;
|
||||||
|
setIncoreError(
|
||||||
|
(err as Error).message ?? 'Ошибка подписания и выгрузки пакета',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
setIncoreStep(null);
|
||||||
setIsSendingToIncore(false);
|
setIsSendingToIncore(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -381,6 +411,9 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) {
|
|||||||
dismissNotifyWarning: () => setNotifyWarning(null),
|
dismissNotifyWarning: () => setNotifyWarning(null),
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
isSendingToIncore,
|
isSendingToIncore,
|
||||||
|
incoreStep,
|
||||||
|
incoreError,
|
||||||
|
dismissIncoreError: () => setIncoreError(null),
|
||||||
isOnApproval,
|
isOnApproval,
|
||||||
showStatuses,
|
showStatuses,
|
||||||
isInitiator,
|
isInitiator,
|
||||||
|
|||||||
@@ -1505,7 +1505,9 @@ export class CtService {
|
|||||||
throw new Error(`Ошибка сохранения комментария ИИ: ${res.status}`);
|
throw new Error(`Ошибка сохранения комментария ИИ: ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateCtReport(ctId: string, format: string = "xlsx"): Promise<void> {
|
// Сформировать отчёт СТ и вернуть его как blob (без скачивания на диск).
|
||||||
|
// Нужен для подписания через NCALayer и сборки карточки SIGEX.
|
||||||
|
async getCtReportBlob(ctId: string, format: string = "xlsx"): Promise<Blob> {
|
||||||
const token = await this._getApiToken();
|
const token = await this._getApiToken();
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${EXTERNAL_API}/Report/CT?ctid=${parseInt(ctId, 10)}&format=${format}`,
|
`${EXTERNAL_API}/Report/CT?ctid=${parseInt(ctId, 10)}&format=${format}`,
|
||||||
@@ -1514,11 +1516,20 @@ export class CtService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!res.ok) throw new Error(`Ошибка формирования отчёта: ${res.status}`);
|
if (!res.ok) throw new Error(`Ошибка формирования отчёта: ${res.status}`);
|
||||||
const blob = await res.blob();
|
return res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateCtReport(ctId: string, format: string = "xlsx"): Promise<void> {
|
||||||
|
const blob = await this.getCtReportBlob(ctId, format);
|
||||||
|
this.downloadBlob(blob, `CT_${ctId}.${format}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скачать blob как файл под указанным именем.
|
||||||
|
downloadBlob(blob: Blob, fileName: string): void {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `CT_${ctId}.${format}`;
|
a.download = fileName;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { NCALayerClient } from 'ncalayer-js-client';
|
||||||
|
|
||||||
|
// Этапы подписания — для отображения прогресса в UI.
|
||||||
|
export type SigexSignStep =
|
||||||
|
| 'connecting' // подключение к NCALayer
|
||||||
|
| 'signing' // ожидание выбора сертификата и PIN в окне NCALayer
|
||||||
|
| 'registering' // регистрация документа в SIGEX
|
||||||
|
| 'finalizing' // фиксация хэша документа
|
||||||
|
| 'building'; // сборка карточки электронного документа
|
||||||
|
|
||||||
|
// Официальный сервис проверки/регистрации ЭЦП (карточка = DDC).
|
||||||
|
// API: https://sigex.kz/en/support/developers/api-documents/
|
||||||
|
const SIGEX_BASE_URL = 'https://sigex.kz';
|
||||||
|
|
||||||
|
interface RegisterResponse {
|
||||||
|
documentId?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildDdcResponse {
|
||||||
|
ddc?: string; // карточка электронного документа в виде base64-строки
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подписание PDF через десктопный NCALayer и формирование карточки
|
||||||
|
// электронного документа (DDC) в SIGEX. Мобильный/QR-сценарий не используется —
|
||||||
|
// подпись только ключом ЭЦП на ПК подписанта.
|
||||||
|
export class SigexSignService {
|
||||||
|
// Подписать PDF (base64) — CMS detached без метки времени, ключ SIGN.
|
||||||
|
async signWithNcaLayer(pdfBase64: string): Promise<string> {
|
||||||
|
const client = new NCALayerClient();
|
||||||
|
await client.connect();
|
||||||
|
return client.basicsSignCMS(
|
||||||
|
NCALayerClient.basicsStorageAll,
|
||||||
|
pdfBase64,
|
||||||
|
NCALayerClient.basicsCMSParamsDetachedNoTSP,
|
||||||
|
NCALayerClient.basicsSignerSignAny,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Зарегистрировать документ в SIGEX вместе с первой подписью → documentId.
|
||||||
|
async registerDocument(title: string, cmsBase64: string): Promise<string> {
|
||||||
|
const res = await fetch(`${SIGEX_BASE_URL}/api`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
signType: 'cms',
|
||||||
|
signature: cmsBase64,
|
||||||
|
settings: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as RegisterResponse;
|
||||||
|
if (!res.ok || !data.documentId) {
|
||||||
|
throw new Error(`SIGEX: ${data.message ?? `HTTP ${res.status}`}`);
|
||||||
|
}
|
||||||
|
return data.documentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Зафиксировать хэш документа — обязательный шаг перед сборкой карточки.
|
||||||
|
async finalizeDocument(documentId: string, pdfBlob: Blob): Promise<void> {
|
||||||
|
const res = await fetch(`${SIGEX_BASE_URL}/api/${documentId}/data`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
body: pdfBlob,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`SIGEX (фиксация документа): ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Собрать карточку электронного документа (DDC) — PDF с оригиналом, подписью
|
||||||
|
// и визуализацией. qrWithIDLink=true — QR со ссылкой на страницу проверки.
|
||||||
|
async buildDdc(documentId: string, pdfBlob: Blob, fileName: string): Promise<Blob> {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
fileName,
|
||||||
|
qrWithIDLink: 'true',
|
||||||
|
language: 'ru',
|
||||||
|
});
|
||||||
|
const res = await fetch(
|
||||||
|
`${SIGEX_BASE_URL}/api/${documentId}/buildDDC?${query.toString()}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
body: pdfBlob,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = (await res.json()) as BuildDdcResponse;
|
||||||
|
if (!res.ok || !data.ddc) {
|
||||||
|
throw new Error(`SIGEX (сборка карточки): ${data.message ?? `HTTP ${res.status}`}`);
|
||||||
|
}
|
||||||
|
return this._base64ToPdfBlob(data.ddc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Полный сценарий: подпись PDF → регистрация → фиксация → карточка.
|
||||||
|
// onProgress уведомляет UI о текущем этапе.
|
||||||
|
async signAndBuildCard(
|
||||||
|
pdfBlob: Blob,
|
||||||
|
title: string,
|
||||||
|
onProgress: (step: SigexSignStep) => void,
|
||||||
|
): Promise<Blob> {
|
||||||
|
const pdfBase64 = await this._blobToBase64(pdfBlob);
|
||||||
|
|
||||||
|
onProgress('connecting');
|
||||||
|
const client = new NCALayerClient();
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
onProgress('signing');
|
||||||
|
const cms = await client.basicsSignCMS(
|
||||||
|
NCALayerClient.basicsStorageAll,
|
||||||
|
pdfBase64,
|
||||||
|
NCALayerClient.basicsCMSParamsDetachedNoTSP,
|
||||||
|
NCALayerClient.basicsSignerSignAny,
|
||||||
|
);
|
||||||
|
|
||||||
|
onProgress('registering');
|
||||||
|
const documentId = await this.registerDocument(title, cms);
|
||||||
|
|
||||||
|
onProgress('finalizing');
|
||||||
|
await this.finalizeDocument(documentId, pdfBlob);
|
||||||
|
|
||||||
|
onProgress('building');
|
||||||
|
return this.buildDdc(documentId, pdfBlob, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _blobToBase64(blob: Blob): Promise<string> {
|
||||||
|
const buffer = await blob.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _base64ToPdfBlob(base64: string): Blob {
|
||||||
|
const normalized = base64.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, '');
|
||||||
|
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return new Blob([bytes], { type: 'application/pdf' });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user