feat(ct): выбор поставщика из базы данных на шаге СТ
This commit is contained in:
+2
-2
@@ -781,13 +781,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: wrap;
|
||||||
padding: 4px 0 8px;
|
padding: 4px 0 8px;
|
||||||
max-width: 30%;
|
max-width: 30%;
|
||||||
|
|
||||||
> :first-child {
|
> :first-child {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 160px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -1,7 +1,9 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
import { Button, Alert, TextInput, Toggle, Spinner, Dialog, Icon } from '@tnsu/ui-kit';
|
import { Button, Alert, TextInput, Toggle, Spinner, Dialog, Icon } from '@tnsu/ui-kit';
|
||||||
import { FormStep } from '../types/domain';
|
import { FormStep } from '../types/domain';
|
||||||
import { CtService } from '../../services/CtService';
|
import { CtService } from '../../services/CtService';
|
||||||
|
import { VendorDatabaseService, IVendorRecord } from '../../services/VendorDatabaseService';
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
import styles from './Step3ComparisonTable.module.scss';
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
import { InlineApprovalChain } from './InlineApprovalChain';
|
import { InlineApprovalChain } from './InlineApprovalChain';
|
||||||
@@ -12,6 +14,7 @@ import { ComparisonMatrix } from './ComparisonMatrix';
|
|||||||
import { CriteriaTable } from './CriteriaTable';
|
import { CriteriaTable } from './CriteriaTable';
|
||||||
import { useAdataCheck } from './useAdataCheck';
|
import { useAdataCheck } from './useAdataCheck';
|
||||||
import { useRecommendedPick } from './useRecommendedPick';
|
import { useRecommendedPick } from './useRecommendedPick';
|
||||||
|
import { VendorPickerDialog } from './VendorPickerDialog';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
service: CtService;
|
service: CtService;
|
||||||
@@ -42,13 +45,22 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
const matrix = useStep3Matrix({ service, formData, onSave, onNext, onStepComplete });
|
const matrix = useStep3Matrix({ service, formData, onSave, onNext, onStepComplete });
|
||||||
const {
|
const {
|
||||||
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
|
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
|
||||||
handleAddSupplier, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave,
|
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave,
|
||||||
extraPositions, suppliersWithOffer, allPositions, computedOffers, selectedSupplierId,
|
extraPositions, suppliersWithOffer, allPositions, computedOffers, selectedSupplierId,
|
||||||
} = matrix;
|
} = matrix;
|
||||||
|
|
||||||
// Проверка контрагентов в Adata вынесена в хук.
|
// Проверка контрагентов в Adata вынесена в хук.
|
||||||
const adata = useAdataCheck(service, suppliersWithOffer);
|
const adata = useAdataCheck(service, suppliersWithOffer);
|
||||||
|
|
||||||
|
// Модалка выбора поставщика из «База данных поставщиков» (см. VendorPickerDialog).
|
||||||
|
const [showVendorPicker, setShowVendorPicker] = useState(false);
|
||||||
|
const vendorDbService = useMemo(() => new VendorDatabaseService(service.webPartContext), [service]);
|
||||||
|
const handleAddVendorFromDatabase = (vendor: IVendorRecord): void => {
|
||||||
|
const id = handleAddSupplierFromDatabase(vendor);
|
||||||
|
if (vendor.bin) adata.seedBin(id, vendor.bin);
|
||||||
|
setShowVendorPicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
// Выбор рекомендованного поставщика по формуле 80% цена / 20% условия оплаты вынесен в хук.
|
// Выбор рекомендованного поставщика по формуле 80% цена / 20% условия оплаты вынесен в хук.
|
||||||
const recPick = useRecommendedPick(service, matrix);
|
const recPick = useRecommendedPick(service, matrix);
|
||||||
|
|
||||||
@@ -112,6 +124,10 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
<Icon name="plus" size="sm" />
|
<Icon name="plus" size="sm" />
|
||||||
Добавить потенциального поставщика
|
Добавить потенциального поставщика
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setShowVendorPicker(true)}>
|
||||||
|
<Icon name="building" size="sm" />
|
||||||
|
Добавить из базы поставщиков
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -210,6 +226,14 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showVendorPicker && (
|
||||||
|
<VendorPickerDialog
|
||||||
|
vendorDbService={vendorDbService}
|
||||||
|
onAdd={handleAddVendorFromDatabase}
|
||||||
|
onClose={() => setShowVendorPicker(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{ai.showAiPromptDialog && (
|
{ai.showAiPromptDialog && (
|
||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
@import '../common/theme';
|
||||||
|
|
||||||
|
.state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 0;
|
||||||
|
color: $muted;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchBox {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryBox {
|
||||||
|
flex: 0 0 200px;
|
||||||
|
max-width: 200px;
|
||||||
|
|
||||||
|
// Select из ui-kit не принимает className — форсируем ширину нативного <select> под обёртку.
|
||||||
|
:global(select) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid $border-strong;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: $surface;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemMain {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemName {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemBin {
|
||||||
|
color: $muted;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemMeta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Карточка поставщика (read-only значения полей) ─────────────────────────
|
||||||
|
.viewValue {
|
||||||
|
min-height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 0;
|
||||||
|
color: $text;
|
||||||
|
font-size: 14px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Dialog, Button, TextInput, Select, Spinner, Alert, FormGrid, FormField, Icon } from '@tnsu/ui-kit';
|
||||||
|
import { VendorDatabaseService, IVendorRecord } from '../../services/VendorDatabaseService';
|
||||||
|
import styles from './VendorPickerDialog.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
vendorDbService: VendorDatabaseService;
|
||||||
|
onAdd: (vendor: IVendorRecord) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Список большой (сотни поставщиков) — без поиска и без выбранного раздела показываем только
|
||||||
|
// первые N по алфавиту (сортировка уже приходит из VendorDatabaseService.listVendors), чтобы окно
|
||||||
|
// не было пустым при открытии, но и не рендерило все 500+ строк разом.
|
||||||
|
const INITIAL_LIMIT = 20;
|
||||||
|
|
||||||
|
function ViewField({ label, value, full }: { label: string; value: string; full?: boolean }): JSX.Element {
|
||||||
|
return (
|
||||||
|
<FormField label={label} full={full}>
|
||||||
|
<div className={styles.viewValue}>{value || '—'}</div>
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диалог выбора поставщика из «База данных поставщиков» (Шаг 3 СТ): поиск + список → карточка
|
||||||
|
// поставщика с кнопкой добавления в таблицу сравнения (см. handleAddSupplierFromDatabase).
|
||||||
|
export const VendorPickerDialog: React.FC<Props> = ({ vendorDbService, onAdd, onClose }) => {
|
||||||
|
const [vendors, setVendors] = useState<IVendorRecord[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [category, setCategory] = useState('');
|
||||||
|
const [selected, setSelected] = useState<IVendorRecord | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setIsLoading(true);
|
||||||
|
vendorDbService
|
||||||
|
.listVendors()
|
||||||
|
.then((list) => { if (!cancelled) setVendors(list); })
|
||||||
|
.catch((e) => { if (!cancelled) setError((e as Error).message || 'Ошибка загрузки базы поставщиков'); })
|
||||||
|
.finally(() => { if (!cancelled) setIsLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [vendorDbService]);
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(() => {
|
||||||
|
const unique = Array.from(new Set(vendors.map((v) => v.category).filter(Boolean)));
|
||||||
|
unique.sort((a, b) => a.localeCompare(b, 'ru'));
|
||||||
|
return unique.map((c) => ({ value: c, label: c }));
|
||||||
|
}, [vendors]);
|
||||||
|
|
||||||
|
const isBrowsing = !query.trim() && !category;
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const byCategory = category ? vendors.filter((v) => v.category === category) : vendors;
|
||||||
|
if (!q) return byCategory;
|
||||||
|
return byCategory.filter(
|
||||||
|
(v) =>
|
||||||
|
v.name.toLowerCase().includes(q) ||
|
||||||
|
v.bin.includes(q) ||
|
||||||
|
v.category.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [vendors, query, category]);
|
||||||
|
|
||||||
|
// Без поиска и без раздела — режем список, чтобы не рендерить всё сразу; при любом сужении
|
||||||
|
// (текст и/или раздел) пользователь уже сам ограничил выборку — показываем всё, что нашлось.
|
||||||
|
const displayed = isBrowsing ? filtered.slice(0, INITIAL_LIMIT) : filtered;
|
||||||
|
|
||||||
|
if (selected) {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
title="Карточка поставщика"
|
||||||
|
subText={selected.category ? `Раздел: ${selected.category}` : undefined}
|
||||||
|
onDismiss={onClose}
|
||||||
|
width="lg"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setSelected(null)}>
|
||||||
|
<Icon name="arrow-left" size="sm" />
|
||||||
|
Назад к списку
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => onAdd(selected)}>
|
||||||
|
<Icon name="plus" size="sm" />
|
||||||
|
Добавить поставщика в СТ
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormGrid>
|
||||||
|
<ViewField label="Наименование" value={selected.name} full />
|
||||||
|
<ViewField label="БИН" value={selected.bin} />
|
||||||
|
<ViewField label="Статус" value={selected.status} />
|
||||||
|
<ViewField label="Юридический адрес" value={selected.legalAddress} full />
|
||||||
|
<ViewField label="Факт. адрес / склад" value={selected.actualAddress} full />
|
||||||
|
<ViewField label="Контактное лицо" value={selected.contactPerson} />
|
||||||
|
<ViewField label="Контактная информация" value={selected.contactInfo} />
|
||||||
|
<ViewField label="Email" value={selected.email} />
|
||||||
|
<ViewField label="Веб-сайт" value={selected.website} />
|
||||||
|
{selected.productName && <ViewField label="Наименование ТМЦ" value={selected.productName} full />}
|
||||||
|
{selected.catalogLink && <ViewField label="Ссылка на каталог/прайс" value={selected.catalogLink} full />}
|
||||||
|
</FormGrid>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
title="Выбор поставщика из базы"
|
||||||
|
subText="Найдите поставщика по названию, БИН или разделу — выбор откроет его карточку."
|
||||||
|
onDismiss={onClose}
|
||||||
|
width="lg"
|
||||||
|
>
|
||||||
|
{isLoading && (
|
||||||
|
<div className={styles.state}>
|
||||||
|
<Spinner size="small" /> Загрузка базы поставщиков…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && error && <Alert variant="err">{error}</Alert>}
|
||||||
|
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<>
|
||||||
|
<div className={styles.filterRow}>
|
||||||
|
<div className={styles.searchBox}>
|
||||||
|
<TextInput
|
||||||
|
value={query}
|
||||||
|
onChange={setQuery}
|
||||||
|
placeholder="Поиск по названию или БИН…"
|
||||||
|
ariaLabel="Поиск поставщика"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.categoryBox}>
|
||||||
|
<Select
|
||||||
|
value={category}
|
||||||
|
onChange={setCategory}
|
||||||
|
options={categoryOptions}
|
||||||
|
placeholder="Все разделы"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className={styles.state}>Ничего не найдено</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isBrowsing && vendors.length > INITIAL_LIMIT && (
|
||||||
|
<div className={styles.hint}>
|
||||||
|
Показаны первые {INITIAL_LIMIT} из {vendors.length} — используйте поиск или выберите раздел
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{displayed.map((v) => (
|
||||||
|
<li key={v.id} className={styles.item} onClick={() => setSelected(v)}>
|
||||||
|
<div className={styles.itemMain}>
|
||||||
|
<span className={styles.itemName}>{v.name}</span>
|
||||||
|
{v.bin && <span className={styles.itemBin}>БИН {v.bin}</span>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.itemMeta}>
|
||||||
|
<span>{v.category || '—'}</span>
|
||||||
|
{v.contactInfo && <span>{v.contactInfo}</span>}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,6 +6,14 @@ export interface ExtraSupplier {
|
|||||||
currency: string;
|
currency: string;
|
||||||
spId?: number;
|
spId?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
// Заполняются только когда поставщик добавлен из VendorPickerDialog («База данных поставщиков»).
|
||||||
|
offerId?: number;
|
||||||
|
bin?: string;
|
||||||
|
address?: string;
|
||||||
|
contactPerson?: string;
|
||||||
|
contactInfo?: string;
|
||||||
|
email?: string;
|
||||||
|
website?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtraPosition {
|
export interface ExtraPosition {
|
||||||
|
|||||||
+9
-1
@@ -15,6 +15,7 @@ export interface UseAdataCheckResult {
|
|||||||
checkSupplier: (supplierId: string) => Promise<void>;
|
checkSupplier: (supplierId: string) => Promise<void>;
|
||||||
checkAllSuppliers: () => Promise<void>;
|
checkAllSuppliers: () => Promise<void>;
|
||||||
isCheckingAll: boolean;
|
isCheckingAll: boolean;
|
||||||
|
seedBin: (supplierId: string, bin: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверка контрагентов в Adata (Шаг 3): подтягивает БИН + кэш прошлой проверки из списка
|
// Проверка контрагентов в Adata (Шаг 3): подтягивает БИН + кэш прошлой проверки из списка
|
||||||
@@ -82,5 +83,12 @@ export function useAdataCheck(service: CtService, suppliers: Supplier[]): UseAda
|
|||||||
setIsCheckingAll(false);
|
setIsCheckingAll(false);
|
||||||
}, [checkSupplier]);
|
}, [checkSupplier]);
|
||||||
|
|
||||||
return { adataMap, checkSupplier, checkAllSuppliers, isCheckingAll };
|
// Проставляет БИН сразу (без ожидания эффекта монтирования выше, у которого deps=[] и который
|
||||||
|
// не подхватит поставщика, добавленного уже после первого рендера) — нужен для поставщика,
|
||||||
|
// выбранного из VendorPickerDialog, чтобы «Проверить всех»/иконка Adata стали доступны сразу.
|
||||||
|
const seedBin = useCallback((supplierId: string, bin: string): void => {
|
||||||
|
setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], bin, isLoading: false } }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { adataMap, checkSupplier, checkAllSuppliers, isCheckingAll, seedBin };
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-2
@@ -5,6 +5,7 @@ import { CtService } from '../../services/CtService';
|
|||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
|
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
|
||||||
import { buildOfferKey, computeOffer } from './matrixHelpers';
|
import { buildOfferKey, computeOffer } from './matrixHelpers';
|
||||||
|
import { IVendorRecord } from '../../services/VendorDatabaseService';
|
||||||
|
|
||||||
let extraIdCounter = 0;
|
let extraIdCounter = 0;
|
||||||
|
|
||||||
@@ -290,7 +291,12 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
|
|
||||||
const suppliersWithOffer = useMemo(() => [
|
const suppliersWithOffer = useMemo(() => [
|
||||||
...suppliers.map(s => ({ ...s, isExtraSupplier: false as const, isNoOffer: !s.hasOffer })),
|
...suppliers.map(s => ({ ...s, isExtraSupplier: false as const, isNoOffer: !s.hasOffer })),
|
||||||
...extraSuppliers.map(es => ({ id: es.id, name: es.name, hasOffer: true as const, isExtraSupplier: true as const, isNoOffer: false as const })),
|
...extraSuppliers.map(es => ({
|
||||||
|
id: es.id, name: es.name, hasOffer: true as const, isExtraSupplier: true as const, isNoOffer: false as const,
|
||||||
|
// spId/bin известны сразу для поставщиков, добавленных из «База данных поставщиков»
|
||||||
|
// (см. handleAddSupplierFromDatabase) — без этого useAdataCheck не увидит их БИН.
|
||||||
|
spId: es.spId, bin: es.bin,
|
||||||
|
})),
|
||||||
], [suppliers, extraSuppliers]);
|
], [suppliers, extraSuppliers]);
|
||||||
|
|
||||||
const allPositions = useMemo((): PositionRow[] => {
|
const allPositions = useMemo((): PositionRow[] => {
|
||||||
@@ -564,6 +570,58 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
onSave({ step3ExtraSuppliers: updated });
|
onSave({ step3ExtraSuppliers: updated });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Добавление поставщика из VendorPickerDialog («База данных поставщиков»): та же механика
|
||||||
|
// колонки, что и handleAddSupplier, но с известными БИН/контактами. В отличие от ручного ввода,
|
||||||
|
// сразу (не дожидаясь «Сохранить и продолжить») пишем запись в SP «Поставщики» — по аналогии
|
||||||
|
// с потоком «поставщик с КП», который тоже сохраняет eagerly. Это даёт непустой БИН сразу, и
|
||||||
|
// кнопка «Проверить всех» (Adata) становится доступна без ожидания сохранения формы.
|
||||||
|
const handleAddSupplierFromDatabase = (vendor: IVendorRecord): string => {
|
||||||
|
const id = `extra_sup_${++extraIdCounter}`;
|
||||||
|
const address = vendor.legalAddress || vendor.actualAddress || undefined;
|
||||||
|
const newEntry: ExtraSupplier = {
|
||||||
|
id,
|
||||||
|
name: vendor.name,
|
||||||
|
currency: 'KZT',
|
||||||
|
bin: vendor.bin || undefined,
|
||||||
|
address,
|
||||||
|
contactPerson: vendor.contactPerson || undefined,
|
||||||
|
contactInfo: vendor.contactInfo || undefined,
|
||||||
|
email: vendor.email || undefined,
|
||||||
|
website: vendor.website || undefined,
|
||||||
|
};
|
||||||
|
const updated = [...extraSuppliers, newEntry];
|
||||||
|
setExtraSuppliers(updated);
|
||||||
|
setSupplierStatuses(prev => ({ ...prev, [id]: '' }));
|
||||||
|
onSave({ step3ExtraSuppliers: updated });
|
||||||
|
|
||||||
|
void (async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { spId, offerId } = await service.saveManualSupplierWithKp(vendor.name);
|
||||||
|
await service.saveSupplierContacts(spId, {
|
||||||
|
bin: vendor.bin || undefined,
|
||||||
|
address,
|
||||||
|
contactPerson: vendor.contactPerson || undefined,
|
||||||
|
contactInfo: vendor.contactInfo || undefined,
|
||||||
|
email: vendor.email || undefined,
|
||||||
|
website: vendor.website || undefined,
|
||||||
|
});
|
||||||
|
setExtraSuppliers(prev => {
|
||||||
|
const next = prev.map(es => (es.id === id ? { ...es, spId, offerId } : es));
|
||||||
|
onSave({ step3ExtraSuppliers: next });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Колонку не блокируем — при неудаче запись в SP создастся позже как обычно при handleSave
|
||||||
|
// (уже без предзаполненных БИН/контактов, как для вручную введённого поставщика).
|
||||||
|
setSaveError(
|
||||||
|
`Не удалось сразу сохранить «${vendor.name}» в SharePoint — данные досохранятся при нажатии «Сохранить и продолжить». (${(err as Error).message})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddPosition = (): void => {
|
const handleAddPosition = (): void => {
|
||||||
if (!newPosName.trim()) return;
|
if (!newPosName.trim()) return;
|
||||||
const id = `extra_pos_${++extraIdCounter}`;
|
const id = `extra_pos_${++extraIdCounter}`;
|
||||||
@@ -625,8 +683,10 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
const extraOfferIds: Record<string, number> = {};
|
const extraOfferIds: Record<string, number> = {};
|
||||||
for (const es of extraSuppliers) {
|
for (const es of extraSuppliers) {
|
||||||
if (es.spId) {
|
if (es.spId) {
|
||||||
// Already created in SP on a previous save — reuse to avoid duplicates.
|
// Already created in SP — either on a previous save, or eagerly by
|
||||||
|
// handleAddSupplierFromDatabase (which also sets es.offerId). Reuse to avoid duplicates.
|
||||||
extraSupSpIds[es.id] = es.spId;
|
extraSupSpIds[es.id] = es.spId;
|
||||||
|
if (es.offerId) extraOfferIds[es.id] = es.offerId;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const { spId, offerId } = await service.saveManualSupplierWithKp(es.name);
|
const { spId, offerId } = await service.saveManualSupplierWithKp(es.name);
|
||||||
@@ -825,6 +885,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
handleNameColResizeMouseDown,
|
handleNameColResizeMouseDown,
|
||||||
handleStatusChange,
|
handleStatusChange,
|
||||||
handleAddSupplier,
|
handleAddSupplier,
|
||||||
|
handleAddSupplierFromDatabase,
|
||||||
handleAddPosition,
|
handleAddPosition,
|
||||||
handleAddDelivery,
|
handleAddDelivery,
|
||||||
handleDeliveryKmChange,
|
handleDeliveryKmChange,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { CT, FormStep, Position, Supplier, CurrencyRate, SupplierOffer } from '../types/domain';
|
import { CT, FormStep, Position, Supplier, CurrencyRate, SupplierOffer } from '../types/domain';
|
||||||
import { IMatrixRow } from '../../../../shared/approval';
|
import { IMatrixRow } from '../../../../shared/approval';
|
||||||
|
import { ExtraSupplier } from '../Step3ComparisonTable/matrixTypes';
|
||||||
|
|
||||||
export interface CTFormData {
|
export interface CTFormData {
|
||||||
ct: CT | undefined;
|
ct: CT | undefined;
|
||||||
@@ -15,7 +16,7 @@ export interface CTFormData {
|
|||||||
approvalMatrix: IMatrixRow[];
|
approvalMatrix: IMatrixRow[];
|
||||||
initiatorDepartment: string;
|
initiatorDepartment: string;
|
||||||
initiatorCompany: string;
|
initiatorCompany: string;
|
||||||
step3ExtraSuppliers: Array<{ id: string; name: string; currency: string; spId?: number; status?: string }>;
|
step3ExtraSuppliers: ExtraSupplier[];
|
||||||
step3ExtraPositions: Array<{ id: string; materialName: string; unit: string; spId?: number; isDelivery?: boolean }>;
|
step3ExtraPositions: Array<{ id: string; materialName: string; unit: string; spId?: number; isDelivery?: boolean }>;
|
||||||
step3DeliveryKm?: Record<string, number>; // key = `${posId}::${supId}`
|
step3DeliveryKm?: Record<string, number>; // key = `${posId}::${supId}`
|
||||||
uploadedKpFiles: Array<{ name: string; spId: number; supplierName?: string }>;
|
uploadedKpFiles: Array<{ name: string; spId: number; supplierName?: string }>;
|
||||||
|
|||||||
@@ -94,6 +94,16 @@ const CHOICE_TO_CT_STATUS: Record<string, CTStatus> = {
|
|||||||
Отклонено: "rejected",
|
Отклонено: "rejected",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Контактные поля поставщика (из «База данных поставщиков») для saveSupplierContacts.
|
||||||
|
export interface SupplierContactInput {
|
||||||
|
bin?: string;
|
||||||
|
address?: string;
|
||||||
|
contactPerson?: string;
|
||||||
|
contactInfo?: string;
|
||||||
|
email?: string;
|
||||||
|
website?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class CtService {
|
export class CtService {
|
||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
private readonly http: SPHttpClient;
|
private readonly http: SPHttpClient;
|
||||||
@@ -119,6 +129,12 @@ export class CtService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Прокидывает WebPartContext наружу — нужен VendorDatabaseService, который читает список
|
||||||
|
// на другом сайте (sed) независимо от baseUrl этого сервиса.
|
||||||
|
get webPartContext(): WebPartContext {
|
||||||
|
return this.context;
|
||||||
|
}
|
||||||
|
|
||||||
private _li(listName: string): string {
|
private _li(listName: string): string {
|
||||||
return `${this.baseUrl}/_api/web/lists/getByTitle('${encodeURIComponent(listName)}')/items`;
|
return `${this.baseUrl}/_api/web/lists/getByTitle('${encodeURIComponent(listName)}')/items`;
|
||||||
}
|
}
|
||||||
@@ -1036,6 +1052,29 @@ export class CtService {
|
|||||||
throw new Error(`Ошибка сохранения БИН поставщика: ${res.status}`);
|
throw new Error(`Ошибка сохранения БИН поставщика: ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Записывает БИН + контактные данные поставщика (пришедшие из «База данных поставщиков») в
|
||||||
|
// запись списка Поставщики — единый MERGE, аналог saveSupplierBIN, но для всех полей сразу.
|
||||||
|
async saveSupplierContacts(
|
||||||
|
supplierSpId: number,
|
||||||
|
contacts: SupplierContactInput,
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await this.http.post(
|
||||||
|
this._liById(LISTS.SUPPLIERS, supplierSpId),
|
||||||
|
SPHttpClient.configurations.v1,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json;odata=nometadata",
|
||||||
|
"IF-MATCH": "*",
|
||||||
|
"X-HTTP-Method": "MERGE",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(contacts),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error(`Ошибка сохранения контактов поставщика: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Проверка контрагента в Adata через внутренний прокси (long polling: Get → token → Check → data/wait).
|
// Проверка контрагента в Adata через внутренний прокси (long polling: Get → token → Check → data/wait).
|
||||||
async fetchAdataInfo(bin: string): Promise<AdataCompanyInfo> {
|
async fetchAdataInfo(bin: string): Promise<AdataCompanyInfo> {
|
||||||
const apiToken = await this._getApiToken();
|
const apiToken = await this._getApiToken();
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
|
||||||
|
|
||||||
|
// Список «База данных поставщиков» живёт на сайте sed (общий справочник, собранный из Excel).
|
||||||
|
const SED_SITE_URL = 'https://tnsukz.sharepoint.com/sites/sed';
|
||||||
|
const VENDOR_DB_LIST = 'База данных поставщиков';
|
||||||
|
|
||||||
|
export interface IVendorRecord {
|
||||||
|
id: number;
|
||||||
|
category: string; // Title
|
||||||
|
name: string; // SupplierName
|
||||||
|
bin: string; // SupplierBIN — только цифры
|
||||||
|
status: string; // SupplierStatus
|
||||||
|
legalAddress: string;
|
||||||
|
actualAddress: string;
|
||||||
|
productName: string;
|
||||||
|
contactPerson: string;
|
||||||
|
contactInfo: string;
|
||||||
|
remark: string;
|
||||||
|
catalogLink: string;
|
||||||
|
email: string;
|
||||||
|
website: string;
|
||||||
|
sourceSheet: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IVendorRecordRaw {
|
||||||
|
Id: number;
|
||||||
|
Title?: string;
|
||||||
|
SupplierName?: string;
|
||||||
|
SupplierBIN?: string;
|
||||||
|
SupplierStatus?: string;
|
||||||
|
LegalAddress?: string;
|
||||||
|
ActualAddress?: string;
|
||||||
|
ProductName?: string;
|
||||||
|
ContactPerson?: string;
|
||||||
|
ContactInfo?: string;
|
||||||
|
Remark?: string;
|
||||||
|
CatalogLink?: string;
|
||||||
|
Email?: string;
|
||||||
|
Website?: string;
|
||||||
|
SourceSheet?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Чтение SP-списка «База данных поставщиков» (сайт /sites/sed) — источник для VendorPickerDialog. */
|
||||||
|
export class VendorDatabaseService {
|
||||||
|
constructor(private context: WebPartContext) {}
|
||||||
|
|
||||||
|
public async listVendors(): Promise<IVendorRecord[]> {
|
||||||
|
const url =
|
||||||
|
`${SED_SITE_URL}/_api/web/lists/getbytitle('${encodeURIComponent(VENDOR_DB_LIST)}')/items` +
|
||||||
|
'?$select=Id,Title,SupplierName,SupplierBIN,SupplierStatus,LegalAddress,ActualAddress,' +
|
||||||
|
'ProductName,ContactPerson,ContactInfo,Remark,CatalogLink,Email,Website,SourceSheet' +
|
||||||
|
'&$orderby=SupplierName&$top=5000';
|
||||||
|
const res: SPHttpClientResponse = await this.context.spHttpClient.get(url, SPHttpClient.configurations.v1);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Ошибка загрузки «База данных поставщиков»: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
const json: { value: IVendorRecordRaw[] } = await res.json();
|
||||||
|
return json.value
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.Id,
|
||||||
|
category: (r.Title ?? '').trim(),
|
||||||
|
name: (r.SupplierName ?? '').trim(),
|
||||||
|
bin: String(r.SupplierBIN ?? '').replace(/\D/g, ''),
|
||||||
|
status: (r.SupplierStatus ?? '').trim(),
|
||||||
|
legalAddress: (r.LegalAddress ?? '').trim(),
|
||||||
|
actualAddress: (r.ActualAddress ?? '').trim(),
|
||||||
|
productName: (r.ProductName ?? '').trim(),
|
||||||
|
contactPerson: (r.ContactPerson ?? '').trim(),
|
||||||
|
contactInfo: (r.ContactInfo ?? '').trim(),
|
||||||
|
remark: (r.Remark ?? '').trim(),
|
||||||
|
catalogLink: (r.CatalogLink ?? '').trim(),
|
||||||
|
email: (r.Email ?? '').trim(),
|
||||||
|
website: (r.Website ?? '').trim(),
|
||||||
|
sourceSheet: (r.SourceSheet ?? '').trim(),
|
||||||
|
}))
|
||||||
|
.filter((v) => v.name.length > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user