feat: добавил базу контрагентов CRUD

This commit is contained in:
Тимур Городецкий
2026-09-01 11:25:23 +05:00
parent 00a0eb6ce7
commit 5c9392fe49
12 changed files with 1042 additions and 18 deletions
@@ -1,7 +1,7 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import '@tnsu/ui-kit/styles.css';
import { IconSprite, Button } from '@tnsu/ui-kit';
import { IconSprite, ConfirmHost, Button } from '@tnsu/ui-kit';
import styles from './CtFormsPortal.module.scss';
import type { ICtFormsPortalProps } from './ICtFormsPortalProps';
import { Dashboard } from './Dashboard';
@@ -9,6 +9,7 @@ import { DOCUMENT_TYPES } from './config';
import KpsCtForm from '../forms/kps/components/KpsCtForm';
import CtCreateForm from '../forms/ct/components/CtCreateForm';
import OidCreateForm from '../forms/oid/components/OidCreateForm';
import { ContragentsRegistry } from '../forms/contragents/components/ContragentsRegistry';
interface Route {
form?: string;
@@ -35,11 +36,10 @@ const CtFormsPortal: React.FC<ICtFormsPortalProps> = (props) => {
const navigate = (form: string | undefined, params: Record<string, string> = {}): void => {
const sp = new URLSearchParams(window.location.search);
sp.delete('form');
sp.delete('type');
FORM_ID_PARAMS.forEach((p) => sp.delete(p));
if (form) {
sp.set('form', form);
if (form) sp.set('form', form);
Object.keys(params).forEach((k) => sp.set(k, params[k]));
}
const qs = sp.toString();
window.history.pushState(undefined, '', qs ? `?${qs}` : window.location.pathname);
setRoute({ form });
@@ -48,6 +48,7 @@ const CtFormsPortal: React.FC<ICtFormsPortalProps> = (props) => {
return (
<div className={styles.root}>
<IconSprite />
<ConfirmHost />
{route.form === 'kps' ? (
<>
<div className={styles.formBack}>
@@ -89,6 +90,8 @@ const CtFormsPortal: React.FC<ICtFormsPortalProps> = (props) => {
isDarkTheme={props.isDarkTheme}
/>
</>
) : route.form === 'contragents' ? (
<ContragentsRegistry context={props.context} navigate={navigate} />
) : (
<Dashboard context={props.context} onNavigateForm={navigate} />
)}
@@ -17,18 +17,15 @@ import {
TextInput,
Select,
ISelectOption,
Alert,
IconName
Alert
} from '@tnsu/ui-kit';
import { DOCUMENT_TYPES } from './config';
import { buildSidebarItems, CONTRAGENTS_ID } from './sidebarNav';
import { useDocumentDashboard } from './registry/useDocumentDashboard';
import { DashboardRow } from './registry/documentRegistry';
type QueueKey = 'pending' | 'mine' | 'approved';
// Иконка сайдбара по типу (kit-иконки; Fluent-иконки из конфигов тут не используем).
const TYPE_ICON: Record<string, IconName> = { ct: 'clipboard', oid: 'chart', kps: 'building' };
// Цвет статуса DashboardRow → вариант нашего Badge (error → danger).
const VARIANT_MAP: Record<DashboardRow['statusVariant'], BadgeVariant> = {
neutral: 'neutral',
@@ -55,7 +52,10 @@ interface IDashboardProps {
export const Dashboard: React.FC<IDashboardProps> = ({ context, onNavigateForm }) => {
const { myDocs, approvedDocs, pendingDocs, isLoading, error } = useDocumentDashboard(context, DOCUMENT_TYPES);
const [activeType, setActiveType] = useState<string>(DOCUMENT_TYPES[0].typeKey);
// Тип по умолчанию — из ?type= (возврат из раздела контрагентов), иначе первый.
const urlType = new URLSearchParams(window.location.search).get('type');
const initialType = DOCUMENT_TYPES.some((t) => t.typeKey === urlType) ? (urlType as string) : DOCUMENT_TYPES[0].typeKey;
const [activeType, setActiveType] = useState<string>(initialType);
const [queue, setQueue] = useState<QueueKey>('pending');
const [collapsed, setCollapsed] = useState<boolean>(false);
const [mobileOpen, setMobileOpen] = useState<boolean>(false);
@@ -69,12 +69,11 @@ export const Dashboard: React.FC<IDashboardProps> = ({ context, onNavigateForm }
const mineForType = byType(myDocs);
const approvedForType = byType(approvedDocs);
const sidebarItems: ISidebarItem[] = DOCUMENT_TYPES.map((t) => ({
id: t.typeKey,
title: t.typeLabel,
icon: TYPE_ICON[t.typeKey] ?? 'document',
count: pendingDocs.filter((r) => r.typeKey === t.typeKey).length
}));
const pendingCountByType: Record<string, number> = {};
DOCUMENT_TYPES.forEach((t) => {
pendingCountByType[t.typeKey] = pendingDocs.filter((r) => r.typeKey === t.typeKey).length;
});
const sidebarItems: ISidebarItem[] = buildSidebarItems(pendingCountByType);
const tabs: ITab[] = [
{ id: 'pending', label: 'На согласовании у меня', count: pendingForType.length },
@@ -119,13 +118,21 @@ export const Dashboard: React.FC<IDashboardProps> = ({ context, onNavigateForm }
setStatus('');
};
const onSidebarChange = (id: string | number): void => {
if (String(id) === CONTRAGENTS_ID) {
onNavigateForm('contragents', {});
return;
}
selectType(id);
};
return (
<AppLayout
sidebar={
<Sidebar
items={sidebarItems}
activeId={activeType}
onChange={selectType}
onChange={onSidebarChange}
brand="Договора"
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((c) => !c)}
@@ -0,0 +1,23 @@
import { ISidebarItem, IconName } from '@tnsu/ui-kit';
import { DOCUMENT_TYPES } from './config';
// Общий раздел «База контрагентов» (отдельный маршрут ?form=contragents).
export const CONTRAGENTS_ID = 'contragents';
export const CONTRAGENTS_TITLE = 'База контрагентов';
// Иконка сайдбара по типу документа (kit-иконки).
export const TYPE_ICON: Record<string, IconName> = { ct: 'clipboard', oid: 'chart', kps: 'building' };
/**
* Единый список пунктов сайдбара для всего портала: типы документов + контрагенты.
* pendingCountByType — счётчики очереди «на согласовании» (только на дашборде).
*/
export function buildSidebarItems(pendingCountByType?: Record<string, number>): ISidebarItem[] {
const docItems: ISidebarItem[] = DOCUMENT_TYPES.map((t) => ({
id: t.typeKey,
title: t.typeLabel,
icon: TYPE_ICON[t.typeKey] ?? 'document',
count: pendingCountByType ? pendingCountByType[t.typeKey] : undefined
}));
return [...docItems, { id: CONTRAGENTS_ID, title: CONTRAGENTS_TITLE, icon: 'people' }];
}
@@ -0,0 +1,14 @@
.alert {
margin-bottom: 12px;
}
.meta {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 20px;
flex-wrap: wrap;
font-size: 12px;
color: #6b7280;
}
@@ -0,0 +1,249 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { Dialog, Button, TextInput, PhoneInput, FormGrid, FormField, Alert, confirmModal } from '@tnsu/ui-kit';
import { ContragentsService } from '../services/ContragentsService';
import { IContractor, ContractorInput, emptyContractorInput } from '../types/domain';
import styles from './ContractorDialog.module.scss';
type DialogMode = 'view' | 'create';
interface IContractorDialogProps {
open: boolean;
mode: DialogMode;
/** Открытый контрагент (для view). Для create — null. */
contractor: IContractor | null;
service: ContragentsService;
onDismiss: () => void;
/** Список нужно перечитать (после create/update/delete). */
onChanged: () => void;
}
function toInput(c: IContractor | null): ContractorInput {
if (!c) return emptyContractorInput();
return {
fullName: c.fullName ?? '',
name: c.name ?? '',
contractorType: c.contractorType,
bin: c.bin,
kbe: c.kbe,
oked: c.oked,
country: c.country,
address: c.address,
actualAddress: c.actualAddress,
phone: c.phone
};
}
function formatDateTime(iso?: string | null): string {
if (!iso) return '';
const d = new Date(iso);
const p = (n: number): string => (n < 10 ? `0${n}` : `${n}`);
return `${p(d.getDate())}.${p(d.getMonth() + 1)}.${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** Карточка контрагента: просмотр / редактирование / создание в модалке Dialog. */
export const ContractorDialog: React.FC<IContractorDialogProps> = ({
open,
mode,
contractor,
service,
onDismiss,
onChanged
}) => {
const [editing, setEditing] = useState<boolean>(mode === 'create');
const [form, setForm] = useState<ContractorInput>(() => toInput(contractor));
const [saving, setSaving] = useState<boolean>(false);
const [error, setError] = useState<string>('');
// Сброс состояния при каждом открытии/смене контрагента.
useEffect(() => {
if (open) {
setForm(toInput(contractor));
setEditing(mode === 'create');
setError('');
setSaving(false);
}
}, [open, contractor, mode]);
const set =
(field: keyof ContractorInput): ((value: string) => void) =>
(value: string): void =>
setForm((f) => ({ ...f, [field]: value }));
const readOnly = !editing;
// БИН/ИИН — только цифры, не длиннее 12.
const setBin = (value: string): void => setForm((f) => ({ ...f, bin: value.replace(/\D/g, '').slice(0, 12) }));
const handleSave = async (): Promise<void> => {
if (!form.name || !form.name.trim()) {
setError('Укажите наименование контрагента');
return;
}
if (form.bin && form.bin.length !== 12) {
setError('БИН / ИИН должен содержать ровно 12 цифр');
return;
}
setSaving(true);
setError('');
try {
// Пустые строки необязательных полей отправляем как null.
const payload: ContractorInput = {
...form,
fullName: form.fullName || form.name,
contractorType: form.contractorType || null,
bin: form.bin || null,
kbe: form.kbe || null,
oked: form.oked || null,
country: form.country || null,
address: form.address || null,
actualAddress: form.actualAddress || null,
phone: form.phone || null
};
if (mode === 'create' || !contractor) {
await service.create(payload);
} else {
await service.update(contractor.id, payload);
}
onChanged();
onDismiss();
} catch (e) {
setError(e instanceof Error ? e.message : 'Не удалось сохранить контрагента');
} finally {
setSaving(false);
}
};
const handleDelete = async (): Promise<void> => {
if (!contractor) return;
const ok = await confirmModal({
title: 'Удалить контрагента',
message: `Удалить «${contractor.name ?? contractor.fullName ?? ''}»? Действие необратимо.`,
confirmText: 'Удалить',
danger: true
});
if (!ok) return;
setSaving(true);
setError('');
try {
await service.remove(contractor.id);
onChanged();
onDismiss();
} catch (e) {
setError(e instanceof Error ? e.message : 'Не удалось удалить контрагента');
setSaving(false);
}
};
// Грязное состояние: в режиме создания/редактирования форма отличается от исходной.
const baseline = mode === 'create' ? emptyContractorInput() : toInput(contractor);
const isDirty = editing && JSON.stringify(form) !== JSON.stringify(baseline);
const confirmDiscard = async (): Promise<boolean> => {
if (!isDirty) return true;
return confirmModal({
title: 'Закрыть без сохранения?',
message: 'Введённые данные не сохранятся.',
confirmText: 'Закрыть',
cancelText: 'Продолжить',
danger: true
});
};
// Закрытие модалки (крестик / Esc / фон / «Закрыть») — с проверкой грязного состояния.
const requestClose = async (): Promise<void> => {
if (await confirmDiscard()) onDismiss();
};
// «Отмена»: создание — закрыть модалку; редактирование — вернуться к просмотру.
const handleCancel = async (): Promise<void> => {
if (!(await confirmDiscard())) return;
if (mode === 'create') {
onDismiss();
} else {
setEditing(false);
setForm(toInput(contractor));
}
};
const title = mode === 'create' ? 'Новый контрагент' : editing ? 'Редактирование контрагента' : 'Карточка контрагента';
const footer =
mode !== 'create' && !editing ? (
<>
<Button variant="danger" onClick={handleDelete} disabled={saving}>
Удалить
</Button>
<Button variant="accent" onClick={() => setEditing(true)} disabled={saving}>
Редактировать
</Button>
<Button variant="ghost" onClick={requestClose} disabled={saving}>
Закрыть
</Button>
</>
) : (
<>
<Button variant="ghost" onClick={handleCancel} disabled={saving}>
Отмена
</Button>
<Button variant="accent" onClick={handleSave} disabled={saving}>
{saving ? 'Сохранение…' : mode === 'create' ? 'Создать' : 'Сохранить'}
</Button>
</>
);
return (
<Dialog open={open} title={title} onDismiss={requestClose} footer={footer} width="lg">
{error && (
<div className={styles.alert}>
<Alert variant="err">{error}</Alert>
</div>
)}
<FormGrid>
<FormField label="Наименование" required full>
<TextInput value={form.name ?? ''} onChange={set('name')} readOnly={readOnly} placeholder="Название" />
</FormField>
<FormField label="Полное наименование" full>
<TextInput value={form.fullName ?? ''} onChange={set('fullName')} readOnly={readOnly} />
</FormField>
<FormField label="БИН / ИИН">
<TextInput
value={form.bin ?? ''}
onChange={setBin}
readOnly={readOnly}
placeholder="12 цифр"
ariaLabel="БИН / ИИН"
/>
</FormField>
<FormField label="Тип контрагента">
<TextInput value={form.contractorType ?? ''} onChange={set('contractorType')} readOnly={readOnly} />
</FormField>
<FormField label="КБЕ">
<TextInput value={form.kbe ?? ''} onChange={set('kbe')} readOnly={readOnly} />
</FormField>
<FormField label="ОКЭД">
<TextInput value={form.oked ?? ''} onChange={set('oked')} readOnly={readOnly} />
</FormField>
<FormField label="Страна">
<TextInput value={form.country ?? ''} onChange={set('country')} readOnly={readOnly} />
</FormField>
<FormField label="Телефон">
<PhoneInput value={form.phone ?? ''} onChange={set('phone')} readOnly={readOnly} />
</FormField>
<FormField label="Юридический адрес" full>
<TextInput value={form.address ?? ''} onChange={set('address')} readOnly={readOnly} />
</FormField>
<FormField label="Фактический адрес" full>
<TextInput value={form.actualAddress ?? ''} onChange={set('actualAddress')} readOnly={readOnly} />
</FormField>
</FormGrid>
{mode !== 'create' && contractor && (contractor.createdAt || contractor.updatedAt) && (
<div className={styles.meta}>
{contractor.createdAt && <span>Создан: {formatDateTime(contractor.createdAt)}</span>}
{contractor.updatedAt && <span>Изменён: {formatDateTime(contractor.updatedAt)}</span>}
</div>
)}
</Dialog>
);
};
@@ -0,0 +1,76 @@
// Раскладку (сайдбар+контент) даёт AppLayout. Здесь — оформление реестра контрагентов.
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.title {
margin: 0;
font-family: 'Lora', Georgia, serif;
font-size: 22px;
font-weight: 600;
color: #0f2440;
}
.headActions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search {
width: 320px;
max-width: 100%;
}
.count {
font-size: 13px;
color: #6b7280;
}
.tableWrap {
margin-top: 16px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
box-shadow: 0 1px 2px rgba(15, 36, 64, 0.04), 0 1px 3px rgba(15, 36, 64, 0.06);
overflow: hidden;
}
.state {
margin-top: 16px;
color: #6b7280;
font-size: 13px;
}
.pager {
margin-top: 16px;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.gap {
padding: 0 4px;
color: #9ca3af;
}
@media (max-width: 640px) {
.search {
width: 100%;
}
}
@@ -0,0 +1,276 @@
import * as React from "react";
import { useState, useEffect, useMemo, useCallback } from "react";
import { WebPartContext } from "@microsoft/sp-webpart-base";
import {
AppLayout,
Sidebar,
SidebarToggle,
DataTable,
IDataTableColumn,
Button,
TextInput,
Alert,
} from "@tnsu/ui-kit";
import {
buildSidebarItems,
CONTRAGENTS_ID,
} from "../../../components/sidebarNav";
import { ContragentsService } from "../services/ContragentsService";
import { SuppliersService } from "../services/SuppliersService";
import { IContractor } from "../types/domain";
import { ContractorDialog } from "./ContractorDialog";
import { MissingSuppliersDialog } from "./MissingSuppliersDialog";
import styles from "./ContragentsRegistry.module.scss";
const PAGE_SIZE = 20;
interface IContragentsRegistryProps {
context: WebPartContext;
/** Навигация портала (та же, что onNavigateForm у дашборда). */
navigate: (form: string | undefined, params?: Record<string, string>) => void;
}
// Компактный набор номеров страниц с многоточиями (как в projects-list).
function pageNumbers(current: number, total: number): (number | "…")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const out: (number | "…")[] = [1];
const from = Math.max(2, current - 1);
const to = Math.min(total - 1, current + 1);
if (from > 2) out.push("…");
for (let i = from; i <= to; i++) out.push(i);
if (to < total - 1) out.push("…");
out.push(total);
return out;
}
const dash = (v: string | null | undefined): string =>
v && v.trim() ? v : "—";
/** Реестр базы контрагентов: таблица + серверные поиск/пагинация + CRUD в модалке. */
export const ContragentsRegistry: React.FC<IContragentsRegistryProps> = ({
context,
navigate,
}) => {
const service = useMemo(() => new ContragentsService(context), [context]);
const suppliersService = useMemo(
() => new SuppliersService(context),
[context],
);
const [collapsed, setCollapsed] = useState<boolean>(false);
const [mobileOpen, setMobileOpen] = useState<boolean>(false);
const [search, setSearch] = useState<string>("");
const [debounced, setDebounced] = useState<string>("");
const [page, setPage] = useState<number>(1);
const [items, setItems] = useState<IContractor[]>([]);
const [totalCount, setTotalCount] = useState<number>(0);
const [totalPages, setTotalPages] = useState<number>(1);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [reloadKey, setReloadKey] = useState<number>(0);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const [dialogMode, setDialogMode] = useState<"view" | "create">("view");
const [selected, setSelected] = useState<IContractor | null>(null);
const [missingOpen, setMissingOpen] = useState<boolean>(false);
// Debounce ввода поиска: 400 мс, сброс на первую страницу.
useEffect(() => {
const t = setTimeout(() => {
setDebounced(search);
setPage(1);
}, 400);
return () => clearTimeout(t);
}, [search]);
// Загрузка страницы.
useEffect(() => {
let cancelled = false;
setLoading(true);
setError("");
service
.list(page, PAGE_SIZE, debounced)
.then((res) => {
if (cancelled) return;
setItems(res.items);
setTotalCount(res.totalCount);
setTotalPages(res.totalPages);
})
.catch((e: unknown) => {
if (cancelled) return;
setError(
e instanceof Error ? e.message : "Не удалось загрузить контрагентов",
);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [service, page, debounced, reloadKey]);
const reload = useCallback(() => setReloadKey((k) => k + 1), []);
const openView = (c: IContractor): void => {
setSelected(c);
setDialogMode("view");
setDialogOpen(true);
};
const openCreate = (): void => {
setSelected(null);
setDialogMode("create");
setDialogOpen(true);
};
const onSidebarChange = (id: string): void => {
if (id === CONTRAGENTS_ID) return;
// Переход к реестру документов с выбором нужного типа.
navigate(undefined, { type: id });
};
const columns: IDataTableColumn<IContractor>[] = [
{
key: "name",
title: "Наименование",
render: (r) => dash(r.name ?? r.fullName),
},
{
key: "bin",
title: "БИН / ИИН",
width: "160px",
render: (r) => dash(r.bin),
},
{
key: "contractorType",
title: "Тип",
width: "160px",
render: (r) => dash(r.contractorType),
},
{
key: "phone",
title: "Телефон",
width: "160px",
render: (r) => dash(r.phone),
},
];
return (
<AppLayout
sidebar={
<Sidebar
items={buildSidebarItems()}
activeId={CONTRAGENTS_ID}
onChange={onSidebarChange}
brand="Договора"
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((c) => !c)}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
}
mobileToggle={<SidebarToggle onClick={() => setMobileOpen(true)} />}
>
<div className={styles.head}>
<h2 className={styles.title}>База контрагентов</h2>
<div className={styles.headActions}>
<Button variant="secondary" onClick={() => setMissingOpen(true)}>
Добавить
</Button>
<Button variant="accent" onClick={openCreate}>
Создать
</Button>
</div>
</div>
<div className={styles.toolbar}>
<div className={styles.search}>
<TextInput
value={search}
onChange={setSearch}
placeholder="Поиск по наименованию"
/>
</div>
{!loading && !error && (
<span className={styles.count}>Всего: {totalCount}</span>
)}
</div>
{error && (
<div className={styles.state}>
<Alert variant="err">Не удалось загрузить данные: {error}</Alert>
</div>
)}
{!error && loading && <div className={styles.state}>Загрузка…</div>}
{!error && !loading && (
<>
<div className={styles.tableWrap}>
<DataTable
columns={columns}
rows={items}
rowKey={(r) => r.id}
onRowClick={openView}
emptyText="Контрагенты не найдены"
/>
</div>
{totalPages > 1 && (
<div className={styles.pager}>
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
← Назад
</Button>
{pageNumbers(page, totalPages).map((n, i) =>
n === "…" ? (
<span key={`gap-${i}`} className={styles.gap}>
…
</span>
) : (
<Button
key={n}
variant={n === page ? "accent" : "ghost"}
size="sm"
onClick={() => setPage(n)}
>
{n}
</Button>
),
)}
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
Вперёд →
</Button>
</div>
)}
</>
)}
<ContractorDialog
open={dialogOpen}
mode={dialogMode}
contractor={selected}
service={service}
onDismiss={() => setDialogOpen(false)}
onChanged={reload}
/>
<MissingSuppliersDialog
open={missingOpen}
service={service}
suppliersService={suppliersService}
onDismiss={() => setMissingOpen(false)}
onAdded={reload}
/>
</AppLayout>
);
};
@@ -0,0 +1,22 @@
.alert {
margin-bottom: 12px;
}
.state {
color: #6b7280;
font-size: 13px;
padding: 8px 0;
}
.count {
font-size: 13px;
color: #6b7280;
margin-bottom: 8px;
}
.tableWrap {
max-height: 60vh;
overflow-y: auto;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
@@ -0,0 +1,161 @@
import * as React from "react";
import { useState, useEffect, useCallback } from "react";
import {
Dialog,
Button,
DataTable,
IDataTableColumn,
Alert,
} from "@tnsu/ui-kit";
import { ContragentsService } from "../services/ContragentsService";
import { SuppliersService, ISpSupplier } from "../services/SuppliersService";
import { emptyContractorInput } from "../types/domain";
import styles from "./MissingSuppliersDialog.module.scss";
interface IMissingSuppliersDialogProps {
open: boolean;
service: ContragentsService;
suppliersService: SuppliersService;
onDismiss: () => void;
/** Контрагент добавлен — реестр нужно перечитать. */
onAdded: () => void;
}
/**
* Поставщики из SP-списка «Поставщики», которых НЕТ в базе контрагентов
* (сверка по БИН/ИИН). У каждого — «Добавить»: создаёт контрагента (имя + БИН).
*/
export const MissingSuppliersDialog: React.FC<IMissingSuppliersDialogProps> = ({
open,
service,
suppliersService,
onDismiss,
onAdded,
}) => {
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [rows, setRows] = useState<ISpSupplier[]>([]);
const [skippedNoBin, setSkippedNoBin] = useState<number>(0);
const [addingId, setAddingId] = useState<number | undefined>(undefined);
const load = useCallback(async (): Promise<void> => {
setLoading(true);
setError("");
try {
const [suppliers, contractors] = await Promise.all([
suppliersService.listSuppliers(),
service.listAll(),
]);
// Множество БИН из базы контрагентов (только цифры, непустые).
const knownBin = new Set<string>();
contractors.forEach((c) => {
const b = String(c.bin ?? "").replace(/\D/g, "");
if (b) knownBin.add(b);
});
// Сверка строго по БИН. Без БИН — сопоставить нельзя, пропускаем (со счётчиком).
const seen = new Set<string>();
const missing: ISpSupplier[] = [];
let noBin = 0;
suppliers.forEach((s) => {
if (!s.bin) {
noBin++;
return;
}
if (knownBin.has(s.bin) || seen.has(s.bin)) return;
seen.add(s.bin);
missing.push(s);
});
setRows(missing);
setSkippedNoBin(noBin);
} catch (e) {
setError(e instanceof Error ? e.message : "Не удалось загрузить данные");
} finally {
setLoading(false);
}
}, [service, suppliersService]);
useEffect(() => {
if (open) void load();
}, [open, load]);
const handleAdd = async (supplier: ISpSupplier): Promise<void> => {
setAddingId(supplier.id);
setError("");
try {
await service.create({
...emptyContractorInput(),
name: supplier.name,
fullName: supplier.name,
bin: supplier.bin || null,
});
setRows((rs) => rs.filter((r) => r.id !== supplier.id));
onAdded();
} catch (e) {
setError(
e instanceof Error ? e.message : "Не удалось добавить контрагента",
);
} finally {
setAddingId(undefined);
}
};
const columns: IDataTableColumn<ISpSupplier>[] = [
{ key: "name", title: "Наименование (SharePoint)", render: (r) => r.name },
{ key: "bin", title: "БИН / ИИН", width: "160px", render: (r) => r.bin },
{
key: "actions",
title: "",
width: "140px",
align: "right",
render: (r) => (
<Button
variant="accent"
size="sm"
onClick={() => void handleAdd(r)}
disabled={addingId !== undefined}
>
{addingId === r.id ? "Добавление…" : "Добавить"}
</Button>
),
},
];
const footer = (
<Button variant="ghost" onClick={onDismiss}>
Закрыть
</Button>
);
return (
<Dialog
open={open}
title="Контрагенты отсутствующие в базе"
onDismiss={onDismiss}
footer={footer}
width="lg"
>
{error && (
<div className={styles.alert}>
<Alert variant="err">{error}</Alert>
</div>
)}
{loading && <div className={styles.state}>Загрузка…</div>}
{!loading && !error && (
<>
<div className={styles.count}>
Не в базе: {rows.length}
{skippedNoBin > 0 && <> · без БИН пропущено: {skippedNoBin}</>}
</div>
<div className={styles.tableWrap}>
<DataTable
columns={columns}
rows={rows}
rowKey={(r) => r.id}
emptyText="Все поставщики из SharePoint уже есть в базе контрагентов"
/>
</div>
</>
)}
</Dialog>
);
};
@@ -0,0 +1,112 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { IContractor, IContractorsResponse, ContractorInput } from '../types/domain';
// AAD-ресурс и базовый URL бизнес-API — те же, что использует OidService для api.tnsu.kz.
const EXT_API_RESOURCE = '9232d9e2-9807-4753-808f-07250e5474b0';
const EXTERNAL_API = 'https://api.tnsu.kz';
/**
* CRUD-клиент базы контрагентов (эндпоинт /Contractors на api.tnsu.kz).
* Токен — через aadTokenProviderFactory (тот же паттерн, что в OidService).
*/
export class ContragentsService {
constructor(private context: WebPartContext) {}
private async getApiToken(): Promise<string> {
const tokenProvider = await this.context.aadTokenProviderFactory.getTokenProvider();
return tokenProvider.getToken(EXT_API_RESOURCE);
}
private async authHeaders(withBody: boolean): Promise<Record<string, string>> {
const token = await this.getApiToken();
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
if (withBody) headers['Content-Type'] = 'application/json';
return headers;
}
/** Список контрагентов с серверной пагинацией и поиском по имени. */
public async list(page: number, pageSize: number, name?: string): Promise<IContractorsResponse> {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
if (name && name.trim()) params.set('name', name.trim());
const res = await fetch(`${EXTERNAL_API}/Contractors?${params.toString()}`, {
headers: await this.authHeaders(false)
});
if (!res.ok) throw new Error(`Ошибка загрузки контрагентов: ${res.status}`);
const data = (await res.json()) as Partial<IContractorsResponse> & { items?: IContractor[] };
const items = data.items ?? [];
const totalCount = data.totalCount ?? items.length;
return {
items,
totalCount,
page: data.page ?? page,
pageSize: data.pageSize ?? pageSize,
totalPages: data.totalPages ?? Math.max(1, Math.ceil(totalCount / pageSize))
};
}
/** Все контрагенты (постранично, для сверки со справочником «Поставщики»). */
public async listAll(): Promise<IContractor[]> {
const pageSize = 200;
const first = await this.list(1, pageSize);
const all = [...first.items];
for (let page = 2; page <= first.totalPages; page++) {
const res = await this.list(page, pageSize);
all.push(...res.items);
if (res.items.length === 0) break;
}
return all;
}
/** Контрагент по id. */
public async getById(id: string): Promise<IContractor> {
const res = await fetch(`${EXTERNAL_API}/Contractors/${encodeURIComponent(id)}`, {
headers: await this.authHeaders(false)
});
if (!res.ok) throw new Error(`Ошибка загрузки контрагента: ${res.status}`);
return (await res.json()) as IContractor;
}
/** Создать контрагента. Возвращает созданную запись (если API её отдаёт). */
public async create(input: ContractorInput): Promise<IContractor | undefined> {
const res = await fetch(`${EXTERNAL_API}/Contractors`, {
method: 'POST',
headers: await this.authHeaders(true),
body: JSON.stringify(input)
});
if (!res.ok) throw new Error(`Ошибка создания контрагента: ${res.status}`);
return this.tryParse(res);
}
/** Обновить контрагента. */
public async update(id: string, input: ContractorInput): Promise<IContractor | undefined> {
const res = await fetch(`${EXTERNAL_API}/Contractors/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: await this.authHeaders(true),
body: JSON.stringify(input)
});
if (!res.ok) throw new Error(`Ошибка сохранения контрагента: ${res.status}`);
return this.tryParse(res);
}
/** Удалить контрагента. */
public async remove(id: string): Promise<void> {
const res = await fetch(`${EXTERNAL_API}/Contractors/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: await this.authHeaders(false)
});
if (!res.ok) throw new Error(`Ошибка удаления контрагента: ${res.status}`);
}
// Тело ответа мутаций может быть пустым (204) — не роняем на этом.
private async tryParse(res: Response): Promise<IContractor | undefined> {
const text = await res.text();
if (!text) return undefined;
try {
return JSON.parse(text) as IContractor;
} catch {
return undefined;
}
}
}
@@ -0,0 +1,37 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
// Глобальный справочник «Поставщики» живёт на сайте sed (общий для CT/KPS).
const SED_SITE_URL = 'https://tnsukz.sharepoint.com/sites/sed';
const SUPPLIERS_LIST = 'Поставщики';
export interface ISpSupplier {
id: number;
name: string;
/** БИН/ИИН из SP (поле bin). Только цифры; '' если не заполнен. */
bin: string;
}
/** Чтение SP-списка «Поставщики» (имя + БИН). */
export class SuppliersService {
constructor(private context: WebPartContext) {}
/** Все поставщики из SharePoint (Id + имя + БИН). Пустые имена отбрасываем. */
public async listSuppliers(): Promise<ISpSupplier[]> {
const url =
`${SED_SITE_URL}/_api/web/lists/getbytitle('${encodeURIComponent(SUPPLIERS_LIST)}')/items` +
`?$select=Id,Title,bin&$orderby=Title&$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: Array<{ Id: number; Title?: string; bin?: string }> } = await res.json();
return json.value
.map((r) => ({
id: r.Id,
name: (r.Title ?? '').trim(),
bin: String(r.bin ?? '').replace(/\D/g, '')
}))
.filter((s) => s.name.length > 0);
}
}
@@ -0,0 +1,44 @@
// Доменные типы фичи «База контрагентов». Форма ответа /Contractors —
// та же, что у остальных списочных эндпоинтов api.tnsu.kz (items + totalCount).
export interface IContractor {
id: string;
fullName: string | null;
name: string | null;
contractorType: string | null;
bin: string | null;
kbe: string | null;
oked: string | null;
country: string | null;
address: string | null;
actualAddress: string | null;
phone: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface IContractorsResponse {
items: IContractor[];
totalCount: number;
page: number;
pageSize: number;
totalPages: number;
}
// Тело для create/update (без серверных полей).
export type ContractorInput = Omit<IContractor, 'id' | 'createdAt' | 'updatedAt'>;
export function emptyContractorInput(): ContractorInput {
return {
fullName: '',
name: '',
contractorType: null,
bin: null,
kbe: null,
oked: null,
country: null,
address: null,
actualAddress: null,
phone: null
};
}