feat: добавил базу контрагентов CRUD
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import '@tnsu/ui-kit/styles.css';
|
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 styles from './CtFormsPortal.module.scss';
|
||||||
import type { ICtFormsPortalProps } from './ICtFormsPortalProps';
|
import type { ICtFormsPortalProps } from './ICtFormsPortalProps';
|
||||||
import { Dashboard } from './Dashboard';
|
import { Dashboard } from './Dashboard';
|
||||||
@@ -9,6 +9,7 @@ import { DOCUMENT_TYPES } from './config';
|
|||||||
import KpsCtForm from '../forms/kps/components/KpsCtForm';
|
import KpsCtForm from '../forms/kps/components/KpsCtForm';
|
||||||
import CtCreateForm from '../forms/ct/components/CtCreateForm';
|
import CtCreateForm from '../forms/ct/components/CtCreateForm';
|
||||||
import OidCreateForm from '../forms/oid/components/OidCreateForm';
|
import OidCreateForm from '../forms/oid/components/OidCreateForm';
|
||||||
|
import { ContragentsRegistry } from '../forms/contragents/components/ContragentsRegistry';
|
||||||
|
|
||||||
interface Route {
|
interface Route {
|
||||||
form?: string;
|
form?: string;
|
||||||
@@ -35,11 +36,10 @@ const CtFormsPortal: React.FC<ICtFormsPortalProps> = (props) => {
|
|||||||
const navigate = (form: string | undefined, params: Record<string, string> = {}): void => {
|
const navigate = (form: string | undefined, params: Record<string, string> = {}): void => {
|
||||||
const sp = new URLSearchParams(window.location.search);
|
const sp = new URLSearchParams(window.location.search);
|
||||||
sp.delete('form');
|
sp.delete('form');
|
||||||
|
sp.delete('type');
|
||||||
FORM_ID_PARAMS.forEach((p) => sp.delete(p));
|
FORM_ID_PARAMS.forEach((p) => sp.delete(p));
|
||||||
if (form) {
|
if (form) sp.set('form', form);
|
||||||
sp.set('form', form);
|
Object.keys(params).forEach((k) => sp.set(k, params[k]));
|
||||||
Object.keys(params).forEach((k) => sp.set(k, params[k]));
|
|
||||||
}
|
|
||||||
const qs = sp.toString();
|
const qs = sp.toString();
|
||||||
window.history.pushState(undefined, '', qs ? `?${qs}` : window.location.pathname);
|
window.history.pushState(undefined, '', qs ? `?${qs}` : window.location.pathname);
|
||||||
setRoute({ form });
|
setRoute({ form });
|
||||||
@@ -48,6 +48,7 @@ const CtFormsPortal: React.FC<ICtFormsPortalProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<IconSprite />
|
<IconSprite />
|
||||||
|
<ConfirmHost />
|
||||||
{route.form === 'kps' ? (
|
{route.form === 'kps' ? (
|
||||||
<>
|
<>
|
||||||
<div className={styles.formBack}>
|
<div className={styles.formBack}>
|
||||||
@@ -89,6 +90,8 @@ const CtFormsPortal: React.FC<ICtFormsPortalProps> = (props) => {
|
|||||||
isDarkTheme={props.isDarkTheme}
|
isDarkTheme={props.isDarkTheme}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
) : route.form === 'contragents' ? (
|
||||||
|
<ContragentsRegistry context={props.context} navigate={navigate} />
|
||||||
) : (
|
) : (
|
||||||
<Dashboard context={props.context} onNavigateForm={navigate} />
|
<Dashboard context={props.context} onNavigateForm={navigate} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,18 +17,15 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Select,
|
Select,
|
||||||
ISelectOption,
|
ISelectOption,
|
||||||
Alert,
|
Alert
|
||||||
IconName
|
|
||||||
} from '@tnsu/ui-kit';
|
} from '@tnsu/ui-kit';
|
||||||
import { DOCUMENT_TYPES } from './config';
|
import { DOCUMENT_TYPES } from './config';
|
||||||
|
import { buildSidebarItems, CONTRAGENTS_ID } from './sidebarNav';
|
||||||
import { useDocumentDashboard } from './registry/useDocumentDashboard';
|
import { useDocumentDashboard } from './registry/useDocumentDashboard';
|
||||||
import { DashboardRow } from './registry/documentRegistry';
|
import { DashboardRow } from './registry/documentRegistry';
|
||||||
|
|
||||||
type QueueKey = 'pending' | 'mine' | 'approved';
|
type QueueKey = 'pending' | 'mine' | 'approved';
|
||||||
|
|
||||||
// Иконка сайдбара по типу (kit-иконки; Fluent-иконки из конфигов тут не используем).
|
|
||||||
const TYPE_ICON: Record<string, IconName> = { ct: 'clipboard', oid: 'chart', kps: 'building' };
|
|
||||||
|
|
||||||
// Цвет статуса DashboardRow → вариант нашего Badge (error → danger).
|
// Цвет статуса DashboardRow → вариант нашего Badge (error → danger).
|
||||||
const VARIANT_MAP: Record<DashboardRow['statusVariant'], BadgeVariant> = {
|
const VARIANT_MAP: Record<DashboardRow['statusVariant'], BadgeVariant> = {
|
||||||
neutral: 'neutral',
|
neutral: 'neutral',
|
||||||
@@ -55,7 +52,10 @@ interface IDashboardProps {
|
|||||||
export const Dashboard: React.FC<IDashboardProps> = ({ context, onNavigateForm }) => {
|
export const Dashboard: React.FC<IDashboardProps> = ({ context, onNavigateForm }) => {
|
||||||
const { myDocs, approvedDocs, pendingDocs, isLoading, error } = useDocumentDashboard(context, DOCUMENT_TYPES);
|
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 [queue, setQueue] = useState<QueueKey>('pending');
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(false);
|
const [collapsed, setCollapsed] = useState<boolean>(false);
|
||||||
const [mobileOpen, setMobileOpen] = 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 mineForType = byType(myDocs);
|
||||||
const approvedForType = byType(approvedDocs);
|
const approvedForType = byType(approvedDocs);
|
||||||
|
|
||||||
const sidebarItems: ISidebarItem[] = DOCUMENT_TYPES.map((t) => ({
|
const pendingCountByType: Record<string, number> = {};
|
||||||
id: t.typeKey,
|
DOCUMENT_TYPES.forEach((t) => {
|
||||||
title: t.typeLabel,
|
pendingCountByType[t.typeKey] = pendingDocs.filter((r) => r.typeKey === t.typeKey).length;
|
||||||
icon: TYPE_ICON[t.typeKey] ?? 'document',
|
});
|
||||||
count: pendingDocs.filter((r) => r.typeKey === t.typeKey).length
|
const sidebarItems: ISidebarItem[] = buildSidebarItems(pendingCountByType);
|
||||||
}));
|
|
||||||
|
|
||||||
const tabs: ITab[] = [
|
const tabs: ITab[] = [
|
||||||
{ id: 'pending', label: 'На согласовании у меня', count: pendingForType.length },
|
{ id: 'pending', label: 'На согласовании у меня', count: pendingForType.length },
|
||||||
@@ -119,13 +118,21 @@ export const Dashboard: React.FC<IDashboardProps> = ({ context, onNavigateForm }
|
|||||||
setStatus('');
|
setStatus('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSidebarChange = (id: string | number): void => {
|
||||||
|
if (String(id) === CONTRAGENTS_ID) {
|
||||||
|
onNavigateForm('contragents', {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectType(id);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout
|
<AppLayout
|
||||||
sidebar={
|
sidebar={
|
||||||
<Sidebar
|
<Sidebar
|
||||||
items={sidebarItems}
|
items={sidebarItems}
|
||||||
activeId={activeType}
|
activeId={activeType}
|
||||||
onChange={selectType}
|
onChange={onSidebarChange}
|
||||||
brand="Договора"
|
brand="Договора"
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapsed={() => setCollapsed((c) => !c)}
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+76
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+22
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user