diff --git a/src/webparts/ctFormsPortal/components/CtFormsPortal.tsx b/src/webparts/ctFormsPortal/components/CtFormsPortal.tsx index f917213..6cee521 100644 --- a/src/webparts/ctFormsPortal/components/CtFormsPortal.tsx +++ b/src/webparts/ctFormsPortal/components/CtFormsPortal.tsx @@ -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 = (props) => { const navigate = (form: string | undefined, params: Record = {}): 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); - Object.keys(params).forEach((k) => sp.set(k, params[k])); - } + 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 = (props) => { return (
+ {route.form === 'kps' ? ( <>
@@ -89,6 +90,8 @@ const CtFormsPortal: React.FC = (props) => { isDarkTheme={props.isDarkTheme} /> + ) : route.form === 'contragents' ? ( + ) : ( )} diff --git a/src/webparts/ctFormsPortal/components/Dashboard.tsx b/src/webparts/ctFormsPortal/components/Dashboard.tsx index 8f63af0..c216133 100644 --- a/src/webparts/ctFormsPortal/components/Dashboard.tsx +++ b/src/webparts/ctFormsPortal/components/Dashboard.tsx @@ -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 = { ct: 'clipboard', oid: 'chart', kps: 'building' }; - // Цвет статуса DashboardRow → вариант нашего Badge (error → danger). const VARIANT_MAP: Record = { neutral: 'neutral', @@ -55,7 +52,10 @@ interface IDashboardProps { export const Dashboard: React.FC = ({ context, onNavigateForm }) => { const { myDocs, approvedDocs, pendingDocs, isLoading, error } = useDocumentDashboard(context, DOCUMENT_TYPES); - const [activeType, setActiveType] = useState(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(initialType); const [queue, setQueue] = useState('pending'); const [collapsed, setCollapsed] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); @@ -69,12 +69,11 @@ export const Dashboard: React.FC = ({ 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 = {}; + 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 = ({ context, onNavigateForm } setStatus(''); }; + const onSidebarChange = (id: string | number): void => { + if (String(id) === CONTRAGENTS_ID) { + onNavigateForm('contragents', {}); + return; + } + selectType(id); + }; + return ( setCollapsed((c) => !c)} diff --git a/src/webparts/ctFormsPortal/components/sidebarNav.ts b/src/webparts/ctFormsPortal/components/sidebarNav.ts new file mode 100644 index 0000000..bc20a74 --- /dev/null +++ b/src/webparts/ctFormsPortal/components/sidebarNav.ts @@ -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 = { ct: 'clipboard', oid: 'chart', kps: 'building' }; + +/** + * Единый список пунктов сайдбара для всего портала: типы документов + контрагенты. + * pendingCountByType — счётчики очереди «на согласовании» (только на дашборде). + */ +export function buildSidebarItems(pendingCountByType?: Record): 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' }]; +} diff --git a/src/webparts/ctFormsPortal/forms/contragents/components/ContractorDialog.module.scss b/src/webparts/ctFormsPortal/forms/contragents/components/ContractorDialog.module.scss new file mode 100644 index 0000000..f6584d4 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/contragents/components/ContractorDialog.module.scss @@ -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; +} diff --git a/src/webparts/ctFormsPortal/forms/contragents/components/ContractorDialog.tsx b/src/webparts/ctFormsPortal/forms/contragents/components/ContractorDialog.tsx new file mode 100644 index 0000000..602e4cd --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/contragents/components/ContractorDialog.tsx @@ -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 = ({ + open, + mode, + contractor, + service, + onDismiss, + onChanged +}) => { + const [editing, setEditing] = useState(mode === 'create'); + const [form, setForm] = useState(() => toInput(contractor)); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + // Сброс состояния при каждом открытии/смене контрагента. + 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 => { + 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 => { + 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 => { + if (!isDirty) return true; + return confirmModal({ + title: 'Закрыть без сохранения?', + message: 'Введённые данные не сохранятся.', + confirmText: 'Закрыть', + cancelText: 'Продолжить', + danger: true + }); + }; + + // Закрытие модалки (крестик / Esc / фон / «Закрыть») — с проверкой грязного состояния. + const requestClose = async (): Promise => { + if (await confirmDiscard()) onDismiss(); + }; + + // «Отмена»: создание — закрыть модалку; редактирование — вернуться к просмотру. + const handleCancel = async (): Promise => { + if (!(await confirmDiscard())) return; + if (mode === 'create') { + onDismiss(); + } else { + setEditing(false); + setForm(toInput(contractor)); + } + }; + + const title = mode === 'create' ? 'Новый контрагент' : editing ? 'Редактирование контрагента' : 'Карточка контрагента'; + + const footer = + mode !== 'create' && !editing ? ( + <> + + + + + ) : ( + <> + + + + ); + + return ( + + {error && ( +
+ {error} +
+ )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {mode !== 'create' && contractor && (contractor.createdAt || contractor.updatedAt) && ( +
+ {contractor.createdAt && Создан: {formatDateTime(contractor.createdAt)}} + {contractor.updatedAt && Изменён: {formatDateTime(contractor.updatedAt)}} +
+ )} +
+ ); +}; diff --git a/src/webparts/ctFormsPortal/forms/contragents/components/ContragentsRegistry.module.scss b/src/webparts/ctFormsPortal/forms/contragents/components/ContragentsRegistry.module.scss new file mode 100644 index 0000000..ae44725 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/contragents/components/ContragentsRegistry.module.scss @@ -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%; + } +} diff --git a/src/webparts/ctFormsPortal/forms/contragents/components/ContragentsRegistry.tsx b/src/webparts/ctFormsPortal/forms/contragents/components/ContragentsRegistry.tsx new file mode 100644 index 0000000..2abc396 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/contragents/components/ContragentsRegistry.tsx @@ -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) => 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 = ({ + context, + navigate, +}) => { + const service = useMemo(() => new ContragentsService(context), [context]); + const suppliersService = useMemo( + () => new SuppliersService(context), + [context], + ); + + const [collapsed, setCollapsed] = useState(false); + const [mobileOpen, setMobileOpen] = useState(false); + + const [search, setSearch] = useState(""); + const [debounced, setDebounced] = useState(""); + const [page, setPage] = useState(1); + + const [items, setItems] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [totalPages, setTotalPages] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [reloadKey, setReloadKey] = useState(0); + + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogMode, setDialogMode] = useState<"view" | "create">("view"); + const [selected, setSelected] = useState(null); + const [missingOpen, setMissingOpen] = useState(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[] = [ + { + 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 ( + setCollapsed((c) => !c)} + mobileOpen={mobileOpen} + onMobileClose={() => setMobileOpen(false)} + /> + } + mobileToggle={ setMobileOpen(true)} />} + > +
+

База контрагентов

+
+ + +
+
+ +
+
+ +
+ {!loading && !error && ( + Всего: {totalCount} + )} +
+ + {error && ( +
+ Не удалось загрузить данные: {error} +
+ )} + {!error && loading &&
Загрузка…
} + {!error && !loading && ( + <> +
+ r.id} + onRowClick={openView} + emptyText="Контрагенты не найдены" + /> +
+ + {totalPages > 1 && ( +
+ + {pageNumbers(page, totalPages).map((n, i) => + n === "…" ? ( + + … + + ) : ( + + ), + )} + +
+ )} + + )} + + setDialogOpen(false)} + onChanged={reload} + /> + + setMissingOpen(false)} + onAdded={reload} + /> +
+ ); +}; diff --git a/src/webparts/ctFormsPortal/forms/contragents/components/MissingSuppliersDialog.module.scss b/src/webparts/ctFormsPortal/forms/contragents/components/MissingSuppliersDialog.module.scss new file mode 100644 index 0000000..eda4576 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/contragents/components/MissingSuppliersDialog.module.scss @@ -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; +} diff --git a/src/webparts/ctFormsPortal/forms/contragents/components/MissingSuppliersDialog.tsx b/src/webparts/ctFormsPortal/forms/contragents/components/MissingSuppliersDialog.tsx new file mode 100644 index 0000000..424cae3 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/contragents/components/MissingSuppliersDialog.tsx @@ -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 = ({ + open, + service, + suppliersService, + onDismiss, + onAdded, +}) => { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [rows, setRows] = useState([]); + const [skippedNoBin, setSkippedNoBin] = useState(0); + const [addingId, setAddingId] = useState(undefined); + + const load = useCallback(async (): Promise => { + setLoading(true); + setError(""); + try { + const [suppliers, contractors] = await Promise.all([ + suppliersService.listSuppliers(), + service.listAll(), + ]); + // Множество БИН из базы контрагентов (только цифры, непустые). + const knownBin = new Set(); + contractors.forEach((c) => { + const b = String(c.bin ?? "").replace(/\D/g, ""); + if (b) knownBin.add(b); + }); + // Сверка строго по БИН. Без БИН — сопоставить нельзя, пропускаем (со счётчиком). + const seen = new Set(); + 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 => { + 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[] = [ + { 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) => ( + + ), + }, + ]; + + const footer = ( + + ); + + return ( + + {error && ( +
+ {error} +
+ )} + {loading &&
Загрузка…
} + {!loading && !error && ( + <> +
+ Не в базе: {rows.length} + {skippedNoBin > 0 && <> · без БИН пропущено: {skippedNoBin}} +
+
+ r.id} + emptyText="Все поставщики из SharePoint уже есть в базе контрагентов" + /> +
+ + )} +
+ ); +}; diff --git a/src/webparts/ctFormsPortal/forms/contragents/services/ContragentsService.ts b/src/webparts/ctFormsPortal/forms/contragents/services/ContragentsService.ts new file mode 100644 index 0000000..2f651dc --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/contragents/services/ContragentsService.ts @@ -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 { + const tokenProvider = await this.context.aadTokenProviderFactory.getTokenProvider(); + return tokenProvider.getToken(EXT_API_RESOURCE); + } + + private async authHeaders(withBody: boolean): Promise> { + const token = await this.getApiToken(); + const headers: Record = { Authorization: `Bearer ${token}` }; + if (withBody) headers['Content-Type'] = 'application/json'; + return headers; + } + + /** Список контрагентов с серверной пагинацией и поиском по имени. */ + public async list(page: number, pageSize: number, name?: string): Promise { + 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 & { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const text = await res.text(); + if (!text) return undefined; + try { + return JSON.parse(text) as IContractor; + } catch { + return undefined; + } + } +} diff --git a/src/webparts/ctFormsPortal/forms/contragents/services/SuppliersService.ts b/src/webparts/ctFormsPortal/forms/contragents/services/SuppliersService.ts new file mode 100644 index 0000000..e1f70d4 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/contragents/services/SuppliersService.ts @@ -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 { + 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); + } +} diff --git a/src/webparts/ctFormsPortal/forms/contragents/types/domain.ts b/src/webparts/ctFormsPortal/forms/contragents/types/domain.ts new file mode 100644 index 0000000..3bb0406 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/contragents/types/domain.ts @@ -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; + +export function emptyContractorInput(): ContractorInput { + return { + fullName: '', + name: '', + contractorType: null, + bin: null, + kbe: null, + oked: null, + country: null, + address: null, + actualAddress: null, + phone: null + }; +}