объединение CT, KPS, OID и дашборда в один портал

Перенос, декомпозиция и перекраска форм CT/KPS/OID на @tnsu/ui-kit,
config-driven дашборд-реестр по трём типам документов. Из отслеживания
исключены артефакты сборки (lib-commonjs/jest-output/release) — забыты
в .gitignore ранее.
This commit is contained in:
Тимур Городецкий
2026-08-28 16:21:34 +05:00
parent baf8fa357d
commit 109053e3af
210 changed files with 27016 additions and 11730 deletions
+162
View File
@@ -0,0 +1,162 @@
// ============================================
// Tansu Construction — Design Tokens (V5: Executive Premium)
// ЗЕРКАЛО токенов пакета @tnsu/ui-kit (src/tokens/_tokens.scss).
// Источник правды — ui-kit; здесь локальная копия для SPFx-сборки (heft не
// резолвит scss из node_modules через ~). При изменении токенов в ui-kit —
// синхронизировать этот файл. Импортируется в *.module.scss:
// @import '<путь>/styles/design-tokens';
// Без блока :root — чтобы импорт в десятки модулей не дублировал CSS.
// ============================================
// ---- Navy (Primary) ----
$navy-950: #0A1B30;
$navy-900: #0F2440;
$navy-800: #1B3A5C;
$navy-700: #244B73;
$navy-600: #2D5C8A;
$navy-100: #E8EEF4;
$navy-50: #F4F7FA;
// ---- Gold (Accent) ----
$gold-600: #B8881A;
$gold-500: #D4A01E;
$gold-400: #E5B635;
$gold-300: #F0CC5E;
$gold-100: #FDF5E0;
$gold-50: #FEFBF0;
// ---- Gray (Neutral) ----
$gray-900: #111827;
$gray-700: #374151;
$gray-500: #6B7280;
$gray-400: #9CA3AF;
$gray-300: #D1D5DB;
$gray-200: #E5E7EB;
$gray-100: #F3F4F6;
$gray-50: #F9FAFB;
// ---- Semantic ----
$red-500: #DC2626;
$red-50: #FEF2F2;
$green-600: #15803D;
$green-50: #F0FDF4;
$blue-600: #2563EB;
$blue-50: #EFF6FF;
$amber-700: #854D0E;
$amber-50: #FBF1D9; // светлый янтарный фон предупреждений (пара к $amber-700)
$white: #fff;
// ---- Typography ----
$font-serif: 'Lora', Georgia, serif;
$font-sans: 'Inter', -apple-system, 'Segoe UI', sans-serif;
$fs-page-title: 24px;
$fs-form-title: 22px;
$fs-section-title: 15px;
$fs-body: 14px;
$fs-sm: 13px;
$fs-label: 12px;
$fs-caption: 11px;
$fs-overline: 10.5px;
// ---- Radius ----
$r-sm: 6px;
$r-md: 10px;
$r-lg: 14px;
$r-full: 100px;
// ---- Shadows ----
$sh-1: 0 1px 2px rgba(15,36,64,.04), 0 1px 3px rgba(15,36,64,.06);
$sh-2: 0 4px 16px rgba(15,36,64,.07);
$sh-3: 0 12px 32px rgba(15,36,64,.10);
$sh-gold: 0 4px 20px rgba(212,160,30,.18);
$sh-modal: 0 24px 70px rgba(10,27,48,.25);
// ---- Gradients (фирменные) ----
$grad-header: linear-gradient(135deg, $navy-950 0%, $navy-800 85%);
$grad-gold: linear-gradient(135deg, $gold-400, $gold-500);
$grad-gold-v: linear-gradient(180deg, $gold-400, $gold-600);
$grad-hairline: linear-gradient(90deg, $gold-400, transparent 70%);
// ---- Spacing ----
$sp-1: 4px; $sp-2: 8px; $sp-3: 12px;
$sp-4: 16px; $sp-5: 20px; $sp-6: 24px;
$sp-7: 28px; $sp-8: 32px;
// ---- Готовые миксины ----
@mixin page-header {
background: $grad-header;
border-radius: $r-lg;
padding: 26px 30px;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
left: 0; right: 0; bottom: 0;
height: 2px;
background: linear-gradient(90deg, $gold-500, transparent 60%);
}
}
@mixin card-hover-gold {
transition: .22s;
&:hover {
box-shadow: $sh-3;
transform: translateY(-2px);
border-color: $gold-300;
}
}
@mixin btn-accent {
background: $grad-gold;
color: $navy-950;
font-weight: 600;
border: 1px solid $gold-500;
&:hover { box-shadow: $sh-gold; transform: translateY(-1px); }
}
@mixin input-focus-gold {
&:focus {
outline: 0;
border-color: $gold-500;
box-shadow: 0 0 0 3px rgba(212,160,30,.12);
}
}
@mixin readonly-field($sel) {
&#{$sel},
&#{$sel}:focus {
background: $gray-50;
color: $gray-500;
border-color: $gray-200;
box-shadow: none;
cursor: default;
}
}
@mixin hairline {
height: 1px;
background: $grad-hairline;
border: 0;
}
// ---- Брейкпоинты (адаптивность) ----
$bp-xs: 350px;
$bp-sm: 640px;
$bp-md: 768px;
$bp-lg: 1024px;
$bp-xl: 1280px;
@mixin below($bp) {
@media (max-width: $bp) {
@content;
}
}
@mixin above($bp) {
@media (min-width: ($bp + 1)) {
@content;
}
}
@@ -15,3 +15,8 @@
.root {
width: 100%;
}
// Панель «назад к реестру» над встроенной формой.
.formBack {
padding: 12px 16px 0;
}
@@ -1,17 +1,99 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import '@tnsu/ui-kit/styles.css';
import { IconSprite } from '@tnsu/ui-kit';
import { IconSprite, Button } from '@tnsu/ui-kit';
import styles from './CtFormsPortal.module.scss';
import type { ICtFormsPortalProps } from './ICtFormsPortalProps';
import { Dashboard } from './Dashboard';
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';
// Корень веб-парта: спрайт иконок (один раз) + дашборд-реестр.
// Импорт CtFormsPortal.module.scss обязателен — в нём :global full-width правила канвы.
const CtFormsPortal: React.FC<ICtFormsPortalProps> = (props) => (
<div className={styles.root}>
<IconSprite />
<Dashboard context={props.context} />
</div>
);
interface Route {
form?: string;
}
function readRoute(): Route {
return { form: new URLSearchParams(window.location.search).get('form') ?? undefined };
}
// URL-параметры идентификатора формы (чистим при навигации туда-обратно).
const FORM_ID_PARAMS = ['ctId', 'ctid', 'oidId', 'oidid'];
// Корень веб-парта: простой роутер. Без form-параметра — реестр (Dashboard);
// form=kps/ct — форма встроена в портал (сама читает ctId/ctid из URL).
const CtFormsPortal: React.FC<ICtFormsPortalProps> = (props) => {
const [route, setRoute] = useState<Route>(readRoute);
useEffect(() => {
const onPop = (): void => setRoute(readRoute());
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
const navigate = (form: string | undefined, params: Record<string, string> = {}): void => {
const sp = new URLSearchParams(window.location.search);
sp.delete('form');
FORM_ID_PARAMS.forEach((p) => sp.delete(p));
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 });
};
return (
<div className={styles.root}>
<IconSprite />
{route.form === 'kps' ? (
<>
<div className={styles.formBack}>
<Button variant="ghost" onClick={() => navigate(undefined)}>
← К реестру
</Button>
</div>
<KpsCtForm
context={props.context}
userDisplayName={props.userDisplayName}
isDarkTheme={props.isDarkTheme}
siteUrl={DOCUMENT_TYPES.filter((t) => t.typeKey === 'kps')[0]?.siteUrl}
/>
</>
) : route.form === 'ct' ? (
<>
<div className={styles.formBack}>
<Button variant="ghost" onClick={() => navigate(undefined)}>
← К реестру
</Button>
</div>
<CtCreateForm
context={props.context}
userDisplayName={props.userDisplayName}
isDarkTheme={props.isDarkTheme}
siteUrl={DOCUMENT_TYPES.filter((t) => t.typeKey === 'ct')[0]?.siteUrl}
/>
</>
) : route.form === 'oid' ? (
<>
<div className={styles.formBack}>
<Button variant="ghost" onClick={() => navigate(undefined)}>
← К реестру
</Button>
</div>
<OidCreateForm
context={props.context}
userDisplayName={props.userDisplayName}
isDarkTheme={props.isDarkTheme}
/>
</>
) : (
<Dashboard context={props.context} onNavigateForm={navigate} />
)}
</div>
);
};
export default CtFormsPortal;
@@ -45,8 +45,14 @@ function formatDate(iso?: string): string {
return `${dd}.${mm}.${d.getFullYear()}`;
}
interface IDashboardProps {
context: WebPartContext;
/** Открыть форму внутри портала (для типов с inPortal). Маршрут: form=<typeKey> + params. */
onNavigateForm: (form: string, params: Record<string, string>) => void;
}
// Дашборд-реестр (config-driven, данные из SharePoint). Оболочка и элементы — из @tnsu/ui-kit.
export const Dashboard: React.FC<{ context: WebPartContext }> = ({ context }) => {
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);
@@ -131,8 +137,8 @@ export const Dashboard: React.FC<{ context: WebPartContext }> = ({ context }) =>
>
<div className={styles.head}>
<h2 className={styles.title}>{cfg.typeLabel}</h2>
{cfg.createNewUrl && (
<Button variant="accent" onClick={() => window.open(cfg.createNewUrl, '_blank')}>
{cfg.inPortal && (
<Button variant="accent" onClick={() => onNavigateForm(cfg.typeKey, {})}>
{cfg.createNewLabel ?? 'Создать'}
</Button>
)}
@@ -161,7 +167,9 @@ export const Dashboard: React.FC<{ context: WebPartContext }> = ({ context }) =>
columns={columns}
rows={rows}
rowKey={(r) => `${r.typeKey}-${r.id}`}
onRowClick={(r) => window.open(cfg.buildLinkUrl(r), '_blank')}
onRowClick={(r) =>
cfg.inPortal && onNavigateForm(cfg.typeKey, cfg.buildInPortalParams ? cfg.buildInPortalParams(r) : {})
}
emptyText="Нет документов"
/>
</div>
@@ -48,13 +48,11 @@ export function createCtDocumentConfig(): DocumentTypeConfig {
select: 'Id,Title,number,project,status,Created'
})
},
buildLinkUrl: (row) => {
const base = 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица.aspx';
const param = row.status === 'Черновик' ? 'ctId' : 'ctid';
return `${base}?${param}=${row.id}`;
},
createNewUrl: 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица.aspx',
createNewLabel: 'Создать новую форму СТ',
// Форма CT встроена в портал (маршрут). Открытие существующей: ctId (черновик) / ctid (иначе)
// — как читает сама форма (см. forms/ct/.../hooks/useCtForm.ts).
inPortal: true,
buildInPortalParams: (row) => ({ [row.status === 'Черновик' ? 'ctId' : 'ctid']: row.id }),
mapItem: (raw): DashboardRow => {
const spStatus = String(raw['status'] ?? '');
return {
@@ -1,7 +1,8 @@
import { DocumentTypeConfig, DashboardRow } from '../registry/documentRegistry';
const KPS_SITE_URL = 'https://tnsukz.sharepoint.com/sites/kps';
const KPS_FORM_PAGE = `${KPS_SITE_URL}/SitePages/Сравнительная-таблица.aspx`;
// Миграция на /sites/sed (2026-08-24): списки KPS переехали на общий сайт портала
// вместе с CT/OID. Data-списки получили суффикс « КПС» (см. LISTS в KpsCtFormService).
const KPS_SITE_URL = 'https://tnsukz.sharepoint.com/sites/sed';
const STATUS_LABEL: Record<string, string> = {
'Черновик': 'Сформирована',
@@ -22,41 +23,40 @@ export function createKpsDocumentConfig(): DocumentTypeConfig {
typeKey: 'kps',
typeLabel: 'СТ КПС',
icon: 'Processing',
// Списки КПС — на отдельном сайт-коллекшене /sites/kps.
// Списки КПС — на общем сайте портала /sites/sed (data-списки с суффиксом « КПС»).
siteUrl: KPS_SITE_URL,
myDocsListName: 'Сравнительная таблица',
myDocsListName: 'Сравнительная таблица КПС',
buildMyDocsFilter: (userId) => `AuthorId eq ${userId}`,
myDocsSelect: 'Id,Title,number,project,status,Created',
pendingApproval: {
approvalListName: 'Согласование СТ',
approvalListName: 'Согласование СТ КПС',
buildPendingFilter: (userId) => `aproover/Id eq ${userId} and status eq 'На согласовании'`,
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: (parentIds) => ({
listName: 'Сравнительная таблица',
listName: 'Сравнительная таблица КПС',
filter: parentIds.map((id) => `Id eq ${id}`).join(' or '),
select: 'Id,Title,number,project,status,Created'
})
},
approvedByMe: {
approvalListName: 'Согласование СТ',
approvalListName: 'Согласование СТ КПС',
buildPendingFilter: (userId) => `aproover/Id eq ${userId} and status eq 'Согласовано'`,
approvalSelect: 'Id,CTId,status,aproover/Id',
approvalExpand: 'aproover',
parentIdField: 'CTId',
buildParentQuery: (parentIds) => ({
listName: 'Сравнительная таблица',
listName: 'Сравнительная таблица КПС',
filter: parentIds.map((id) => `Id eq ${id}`).join(' or '),
select: 'Id,Title,number,project,status,Created'
})
},
buildLinkUrl: (row) => {
const param = row.status === 'Черновик' ? 'ctId' : 'ctid';
return `${KPS_FORM_PAGE}?${param}=${row.id}`;
},
createNewUrl: KPS_FORM_PAGE,
createNewLabel: 'Создать новую форму КПС',
// Форма KPS встроена в портал (маршрут). Открытие существующей: ctId (черновик) / ctid (иначе)
// — как читает сама форма (см. forms/kps/.../utils/session.ts).
inPortal: true,
buildInPortalParams: (row) => ({ [row.status === 'Черновик' ? 'ctId' : 'ctid']: row.id }),
mapItem: (raw): DashboardRow => {
const spStatus = String(raw['status'] ?? '');
return {
@@ -54,13 +54,11 @@ export function createOidDocumentConfig(): DocumentTypeConfig {
select: 'Id,Title,number,project,status,Created'
})
},
buildLinkUrl: (row) => {
const base = 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица-ОИД.aspx';
const param = row.status === 'Черновик' || row.status === 'formed' ? 'oidId' : 'oidid';
return `${base}?${param}=${row.id}`;
},
createNewUrl: 'https://tnsukz.sharepoint.com/sites/sed/SitePages/Сравнительная-таблица-ОИД.aspx',
createNewLabel: 'Создать новую форму ОИД',
// Форма ОИД встроена в портал (маршрут). Открытие существующей: oidId (черновик/сформирована) /
// oidid (иначе) — как читает сама форма (см. forms/oid/.../hooks/useOidForm.ts).
inPortal: true,
buildInPortalParams: (row) => ({ [row.status === 'Черновик' || row.status === 'formed' ? 'oidId' : 'oidid']: row.id }),
mapItem: (raw): DashboardRow => {
const spStatus = String(raw['status'] ?? '');
return {
@@ -46,8 +46,10 @@ export interface DocumentTypeConfig {
/** null = нет вкладки "Согласованные мной" */
approvedByMe: ApprovalSectionConfig | null;
mapItem: (raw: Record<string, unknown>, context: WebPartContext) => DashboardRow;
buildLinkUrl: (row: DashboardRow) => string;
createNewUrl?: string;
createNewLabel?: string;
columns: ColumnDef[];
/** Форма этого типа встроена в портал (открывается маршрутом), а не по внешней ссылке. */
inPortal?: boolean;
/** URL-параметры для открытия существующей записи внутри портала (напр. {ctId:'53'}). */
buildInPortalParams?: (row: DashboardRow) => Record<string, string>;
}
@@ -0,0 +1,27 @@
@import '../common/theme';
.root {
background: $surface;
border-bottom: 1px solid $border;
padding: 12px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.projectName {
font-size: 13px;
font-weight: 400;
opacity: 0.85;
margin-left: 8px;
}
.stepperWrap {
width: 30vw;
min-width: 320px;
margin: 0 auto 0 0;
@include below($bp-md) {
width: 100%;
min-width: 0;
}
}
@@ -0,0 +1,72 @@
import * as React from 'react';
import { Stepper, FormHeader, Badge } from '@tnsu/ui-kit';
import { FORM_STEPS, FormStep, CTStatus } from '../types/domain';
import styles from './Breadcrumbs.module.scss';
interface Props {
currentStep: FormStep;
ctNumber?: string;
projectName?: string;
ctStatus?: CTStatus;
completedSteps: FormStep[];
highestStep: FormStep;
onStepClick: (step: FormStep) => void;
}
const STATUS_LABELS: Record<CTStatus, string> = {
draft: 'Черновик',
kp_loading: 'Загрузка КП',
kp_loaded: 'КП загружены',
formed: 'Сформирована',
on_approval: 'На согласовании',
approved: 'Согласована',
rejected: 'Отклонена',
};
const STATUS_VARIANT: Record<CTStatus, 'neutral' | 'info' | 'success' | 'danger'> = {
draft: 'neutral',
kp_loading: 'neutral',
kp_loaded: 'neutral',
formed: 'neutral',
on_approval: 'info',
approved: 'success',
rejected: 'danger',
};
// Адаптер над Stepper/FormHeader из @tnsu/ui-kit (по образцу KPS Breadcrumbs): контракт props формы
// сохранён, внутри — шапка (номер СТ/проект/статус) + степпер шагов.
export const Breadcrumbs: React.FC<Props> = ({
currentStep,
ctNumber,
projectName,
ctStatus,
completedSteps,
highestStep,
onStepClick,
}) => {
return (
<div className={styles.root}>
{(ctNumber || projectName) && (
<FormHeader
title={
<>
{ctNumber && <>Сравнительная таблица №{ctNumber}</>}
{projectName && <span className={styles.projectName}>{projectName}</span>}
</>
}
right={ctStatus && <Badge variant={STATUS_VARIANT[ctStatus]}>{STATUS_LABELS[ctStatus]}</Badge>}
/>
)}
<div className={styles.stepperWrap}>
<Stepper
steps={FORM_STEPS.map(s => ({ id: s.step, label: s.label }))}
currentId={currentStep}
completedIds={completedSteps}
highestId={highestStep}
onStepClick={id => onStepClick(id as FormStep)}
/>
</div>
</div>
);
};
@@ -0,0 +1,44 @@
@import './common/theme';
@import '~@fluentui/react/dist/sass/References.scss';
:global {
#workbenchPageContent,
.CanvasComponent.LCS .CanvasZone {
max-width: 100% !important;
}
}
.root {
display: flex;
flex-direction: column;
min-height: 100vh;
background: $head-bg;
color: "[theme:bodyText, default: #111827]";
color: var(--bodyText);
font-family: $ms-font-family-fallbacks;
font-size: 14px;
line-height: 1.5;
*,
*::before,
*::after {
box-sizing: border-box;
}
}
.content {
flex: 1;
background: $surface;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
min-height: 0;
}
// Dark theme override — inherit from SharePoint's theme tokens
.dark {
background: #1b1a19;
color: $hover;
.content {
background: #252423;
}
}
@@ -0,0 +1,267 @@
import * as React from 'react';
import { useMemo, useEffect, useState } from 'react';
import type { ICtCreateFormProps } from './ICtCreateFormProps';
import { CtService } from '../services/CtService';
import { useCtForm } from './hooks/useCtForm';
import { Breadcrumbs } from './Breadcrumbs/Breadcrumbs';
import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSelection';
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
import { Step4Approval } from './Step4Approval/Step4Approval';
import { ApprovalMember, CTStatus } from './types/domain';
import styles from './CtCreateForm.module.scss';
import '@tnsu/ui-kit/styles.css';
function deriveCtStatus(chain: ApprovalMember[]): CTStatus {
if (chain.length === 0) return 'formed';
if (chain.some(m => m.status === 'rejected')) return 'rejected';
if (chain.every(m => m.status === 'approved' || m.status === 'recommends' || m.status === 'skipped')) return 'approved';
// Chain is activated only when the initiator has 'recommends' (Рекомендую).
// All-pending means chain was saved as draft but not yet activated via Step 4.
if (chain.some(m => m.status === 'recommends')) return 'on_approval';
return 'formed';
}
const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName, isDarkTheme, siteUrl }) => {
const service = useMemo(() => new CtService(context, siteUrl), [context, siteUrl]);
const [isRestrictedView, setIsRestrictedView] = useState(false);
const {
currentStep,
formData,
initialCtId,
isNotificationLink,
goToStep,
goNext,
goPrev,
updateFormData,
completedSteps,
markStepComplete,
highestStep,
ensureHighestStep,
} = useCtForm();
// Цены показываем только участникам согласования: рекомендующему (Инициатор) и согласующим.
// Автор/инициатор при создании и редактировании СТ (до отправки на согласование) видит цены всегда.
// astel-k (isRestrictedView) — независимое ограничение, складывается с этим: если его уберут,
// правило по цепочке согласования останется. Сопоставление участника — по отображаемому имени
// (пока тестируем; при необходимости переключим на ID).
const userInApprovalChain = (formData.approvalChain ?? []).some(m => m.userName === userDisplayName);
const hidePrices = isRestrictedView || (formData.approvalSent === true && !userInApprovalChain);
useEffect(() => {
service.getCurrentUserProfile()
.then(profile => updateFormData({ initiatorDepartment: profile.department }))
.catch(() => undefined);
service.getCurrentUserCompany()
.then(company => setIsRestrictedView(company.toLowerCase() === 'astel-k'))
.catch(() => undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load existing CT from SP when opened via notification link (?ctid=N)
// Always reloads from SP to get fresh approval statuses
useEffect(() => {
if (!initialCtId) return;
Promise.all([
service.loadCTById(initialCtId),
service.loadApprovalChain(initialCtId),
service.loadPositionsForCT(initialCtId),
]).then(async ([ct, approvalChain, positions]) => {
const positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined);
const rawOffers = await service.loadFullOffersForPositions(positionSpIds);
// Build supplierSpId → offerId map from session uploadedKpFiles.
// offerId cannot be read back via SP REST (500), so we restore it from session.
// formData here is the initial mount value (session state before this update).
const offerIdBySupplierSpId = new Map<number, number>();
for (const kpFile of (formData.uploadedKpFiles ?? [])) {
if (!kpFile.spId || !kpFile.supplierName) continue;
const sup = (formData.suppliers ?? []).find(s => s.name === kpFile.supplierName);
if (sup && sup.spId) offerIdBySupplierSpId.set(sup.spId, kpFile.spId);
}
// Load currency rates for the valutaIds referenced by offers (separate query avoids
// SP REST 400 that occurs when selecting a Choice field via $expand on a lookup).
const valutaIds = rawOffers
.map(o => o.valutaSpId)
.filter((id): id is number => id !== undefined && id > 0)
.filter((id, idx, arr) => arr.indexOf(id) === idx);
const loadedRates = await service.loadCurrencyRatesByIds(valutaIds);
const rateById = new Map(loadedRates.map(r => [Number(r.id), r]));
const spCurrencyRates = loadedRates;
// Build unique suppliers from offers (used only as fallback when session has none)
const supplierCurrencyMap = new Map<number, string>();
rawOffers.forEach(o => {
supplierCurrencyMap.set(o.supplierSpId, o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT');
});
const supplierMap = new Map<number, string>();
rawOffers.forEach(o => { if (!supplierMap.has(o.supplierSpId)) supplierMap.set(o.supplierSpId, o.supplierName); });
const spSuppliers = Array.from(supplierMap.entries()).map(([spId, name]) => ({
id: String(spId), spId, name, hasOffer: true as const,
currency: supplierCurrencyMap.get(spId) ?? 'KZT',
}));
// Build step3DeliveryKm from SP offers. Step 3 reads km by React-id key
// (`${pos.id}::${s.id}`), so map SP ids → React ids; for delivery the position is an
// extra (step3ExtraPositions) whose React id never equals its SP id, hence the lookup.
// Fallback to String(spId) keeps the no-session / notification-link case working
// (there the table uses String(spId) ids for SP-loaded positions/suppliers).
const posReactIdBySpId = new Map<number, string>();
[...formData.selectedPositions, ...(formData.step3ExtraPositions ?? [])].forEach(p => {
if (p.spId !== undefined) posReactIdBySpId.set(p.spId, p.id);
});
const supReactIdBySpId = new Map<number, string>();
formData.suppliers.forEach(s => {
if (s.spId !== undefined) supReactIdBySpId.set(s.spId, s.id);
});
const step3DeliveryKm: Record<string, number> = {};
rawOffers.forEach(o => {
if (o.deliveryDistanceKm !== undefined) {
const posKey = posReactIdBySpId.get(o.positionSpId) ?? String(o.positionSpId);
const supKey = supReactIdBySpId.get(o.supplierSpId) ?? String(o.supplierSpId);
step3DeliveryKm[`${posKey}::${supKey}`] = o.deliveryDistanceKm;
}
});
// Build offers in domain format, restoring offerId from session
const offers = rawOffers.map(o => {
const offerIdNum = offerIdBySupplierSpId.get(o.supplierSpId);
const currencyCode = o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT';
return {
id: '',
supplierId: String(o.supplierSpId),
positionId: String(o.positionSpId),
unit: o.unit,
pricePerUnit: o.price,
quantity: o.quantity,
currencyCode,
pricePerUnitKzt: o.priceTenge || o.price,
totalPriceKzt: o.summaTenge,
totalWithExtraKzt: o.summaTenge,
offerId: offerIdNum !== undefined ? String(offerIdNum) : undefined,
};
});
// Reconstruct uploadedKpFiles for users without session (other users opening CT via link)
let uploadedKpFiles = formData.uploadedKpFiles;
if (isNotificationLink || uploadedKpFiles.length === 0) {
const kpFilesRaw = await service.loadKpFilesForOffers(positionSpIds);
if (kpFilesRaw.length > 0) {
uploadedKpFiles = kpFilesRaw.map(f => ({
spId: f.spId,
name: f.name,
supplierName: supplierMap.get(f.supplierSpId) ?? '',
}));
}
}
const derivedStatus = deriveCtStatus(approvalChain);
updateFormData({
ct: { ...ct, status: derivedStatus },
approvalChain,
// Preserve session selectedPositions — overwriting changes pos.id (Запросы ID → Позиции СТ ID),
// which breaks edits keys built from buildOfferKey(sup.id, pos.id) in Step 3.
...(isNotificationLink || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}),
// Preserve session suppliers and offers — SP data may be incomplete or stale vs. unsaved edits.
// Only use SP-derived data when session has none, or when opened via notification link (always fresh).
...(formData.suppliers.length === 0 ? { suppliers: spSuppliers } : {}),
...(isNotificationLink || formData.offers.length === 0 ? { offers } : {}),
...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}),
...(isNotificationLink ? { approvalSent: true } : {}),
...(uploadedKpFiles.length > 0 ? { uploadedKpFiles } : {}),
...(Object.keys(step3DeliveryKm).length > 0 ? { step3DeliveryKm } : {}),
});
if (isNotificationLink) {
// Teams notification link — always show Step 3 (comparison table + approval chain)
goToStep(3);
ensureHighestStep(4);
markStepComplete(3);
markStepComplete(4);
} else if (approvalChain.length > 0) {
// Regular reload — make Step 4 accessible; only mark complete if CT is fully resolved
ensureHighestStep(4);
if (derivedStatus === 'approved' || derivedStatus === 'rejected') {
markStepComplete(4);
}
}
}).catch(() => undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const renderStep = (): React.ReactElement => {
switch (currentStep) {
case 1:
return (
<Step1PositionSelection
service={service}
formData={formData}
onSave={updateFormData}
onNext={goNext}
onStepComplete={markStepComplete}
isReadOnly={formData.ct?.status === 'on_approval' || formData.ct?.status === 'approved' || formData.ct?.status === 'rejected'}
/>
);
case 2:
return (
<Step2CommercialOffers
service={service}
formData={formData}
userDisplayName={userDisplayName}
isNotificationLink={isNotificationLink}
isReadOnly={formData.ct?.status === 'on_approval' || formData.ct?.status === 'approved' || formData.ct?.status === 'rejected'}
isRestrictedView={hidePrices}
onSave={updateFormData}
onNext={goNext}
onPrev={goPrev}
onStepComplete={markStepComplete}
/>
);
case 3:
return (
<Step3ComparisonTable
service={service}
formData={formData}
userDisplayName={userDisplayName}
isNotificationLink={isNotificationLink}
isRestrictedView={hidePrices}
onSave={updateFormData}
onNext={goNext}
onPrev={goPrev}
onStepComplete={markStepComplete}
/>
);
case 4:
return (
<Step4Approval
service={service}
formData={formData}
userDisplayName={userDisplayName}
onSave={updateFormData}
onPrev={goPrev}
onStepComplete={markStepComplete}
/>
);
}
};
return (
<div className={[styles.root, isDarkTheme ? styles.dark : ''].filter(Boolean).join(' ')}>
<Breadcrumbs
currentStep={currentStep}
ctNumber={formData.ct?.number}
projectName={formData.selectedProject?.name}
ctStatus={formData.ct?.status}
completedSteps={completedSteps}
highestStep={highestStep}
onStepClick={goToStep}
/>
<main className={styles.content}>
{renderStep()}
</main>
</div>
);
};
export default CtCreateForm;
@@ -0,0 +1,9 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
export interface ICtCreateFormProps {
context: WebPartContext;
userDisplayName: string;
isDarkTheme: boolean;
/** URL сайта со списками CT, когда форма встроена в портал на другом сайте. Не задан — текущий сайт. */
siteUrl?: string;
}
@@ -0,0 +1,166 @@
@import '../common/theme';
.tableWrapper {
overflow-x: auto;
border: 1px solid $border;
border-radius: 4px;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
background: $head-bg;
color: $text;
font-weight: 600;
padding: 10px 12px;
text-align: left;
border-bottom: 2px solid $border;
white-space: nowrap;
position: sticky;
top: 0;
z-index: 1;
}
td {
padding: 8px 12px;
border-bottom: 1px solid $hover;
vertical-align: middle;
color: $text;
}
}
.requestRow {
background: $surface;
cursor: default;
&:hover td {
background: $hover;
}
&.rowExpanded td {
background: $head-bg;
}
}
.rowLocked {
opacity: 0.45;
pointer-events: none;
}
.positionRow {
background: #fefefe;
td {
padding-left: 20px;
font-size: 12px;
color: $muted;
}
&:hover td {
background: $hover;
}
&.positionSelected td {
background: $accent-bg;
color: $accent-dark;
}
}
.checkboxCell {
width: 40px;
text-align: center;
}
.expandCell {
width: 32px;
text-align: center;
}
.expandBtn {
width: 24px;
height: 24px;
border: none;
background: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2px;
color: $muted;
padding: 0;
&:hover {
background: $border;
color: $text;
}
svg {
width: 14px;
height: 14px;
}
}
.requestNumber {
font-weight: 600;
color: $accent;
}
.materialName {
font-weight: 500;
}
.qty {
white-space: nowrap;
}
.dateCell {
white-space: nowrap;
color: $muted;
}
.countCell {
text-align: center;
}
.positionCount {
display: inline-block;
background: $border;
color: $text;
border-radius: 10px;
padding: 1px 8px;
font-size: 11px;
font-weight: 600;
min-width: 20px;
text-align: center;
}
.selectionBadge {
display: inline-block;
background: $accent;
color: $surface;
border-radius: 10px;
padding: 1px 7px;
font-size: 11px;
font-weight: 600;
min-width: 20px;
text-align: center;
}
.lockedHint {
td {
padding: 4px 12px;
font-size: 11px;
color: $muted-2;
font-style: italic;
border: none;
}
}
.empty {
padding: 48px 24px;
text-align: center;
color: $muted;
font-size: 14px;
}
@@ -0,0 +1,179 @@
import * as React from 'react';
import { useState, useCallback } from 'react';
import { Checkbox } from '@tnsu/ui-kit';
import { Request, Position } from '../types/domain';
import styles from './RequestsTable.module.scss';
interface Props {
requests: Request[];
selectedPositionIds: Set<string>;
lockedProjectId: string | undefined;
onSelectionChange: (positionId: string, position: Position, request: Request, checked: boolean) => void;
onRequestSelectionChange: (request: Request, checked: boolean) => void;
isReadOnly?: boolean;
}
function formatDate(iso: string): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
export const RequestsTable: React.FC<Props> = ({
requests,
selectedPositionIds,
lockedProjectId,
onSelectionChange,
onRequestSelectionChange,
isReadOnly = false,
}) => {
const [expandedRequestIds, setExpandedRequestIds] = useState<Set<string>>(new Set());
const toggleExpand = useCallback((id: string) => {
setExpandedRequestIds(prev => {
const next = new Set(prev);
if (next.has(id)) { next.delete(id); } else { next.add(id); }
return next;
});
}, []);
const getRequestSelectionCount = (request: Request): number =>
request.positions.filter(p => selectedPositionIds.has(p.id)).length;
const isProjectLocked = (request: Request): boolean =>
lockedProjectId !== undefined && request.project.id !== lockedProjectId;
if (requests.length === 0) {
return (
<div className={styles.empty}>
<p>Потребности не найдены за указанный период</p>
</div>
);
}
return (
<div className={styles.tableWrapper}>
<table className={styles.table} role="treegrid">
<thead>
<tr>
<th style={{ width: 40 }} />
<th style={{ width: 32 }} />
<th>№ потребности</th>
<th>Проект</th>
<th>Раздел</th>
<th>Вид работ</th>
<th>Организация</th>
<th>Дата создания</th>
<th>Позиций</th>
</tr>
</thead>
<tbody>
{requests.map(request => {
const isExpanded = expandedRequestIds.has(request.id);
const selCount = getRequestSelectionCount(request);
const totalCount = request.positions.length;
const locked = isProjectLocked(request);
return (
<React.Fragment key={request.id}>
<tr
className={[
styles.requestRow,
locked ? styles.rowLocked : '',
isExpanded ? styles.rowExpanded : '',
]
.filter(Boolean)
.join(' ')}
aria-expanded={isExpanded}
>
<td className={styles.checkboxCell} onClick={e => e.stopPropagation()}>
<Checkbox
checked={selCount === totalCount && totalCount > 0}
indeterminate={selCount > 0 && selCount < totalCount}
disabled={locked || isReadOnly || totalCount === 0}
onChange={checked => onRequestSelectionChange(request, checked)}
ariaLabel={`Выбрать все позиции потребности ${request.requestNumber}`}
/>
</td>
<td className={styles.expandCell}>
{request.positions.length > 0 && (
<button
className={styles.expandBtn}
onClick={() => toggleExpand(request.id)}
aria-label={isExpanded ? 'Свернуть' : 'Развернуть'}
>
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d={isExpanded ? 'M4 6l4 4 4-4' : 'M6 4l4 4-4 4'}
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
</td>
<td className={styles.requestNumber}>{request.requestNumber}</td>
<td>{request.project.name}</td>
<td>{request.discipline || '—'}</td>
<td>{request.workType || '—'}</td>
<td>{request.organization || '—'}</td>
<td className={styles.dateCell}>{formatDate(request.createdAt)}</td>
<td className={styles.countCell}>
<span className={styles.positionCount}>{request.positions.length}</span>
</td>
</tr>
{isExpanded &&
request.positions.map(position => {
const isSelected = selectedPositionIds.has(position.id);
return (
<tr
key={position.id}
className={[
styles.positionRow,
isSelected ? styles.positionSelected : '',
locked ? styles.rowLocked : '',
]
.filter(Boolean)
.join(' ')}
role="row"
aria-selected={isSelected}
>
<td className={styles.checkboxCell} onClick={e => e.stopPropagation()}>
<Checkbox
checked={isSelected}
disabled={locked || isReadOnly}
onChange={checked => onSelectionChange(position.id, position, request, checked)}
ariaLabel={`Выбрать позицию: ${position.materialName}`}
/>
</td>
<td />
<td colSpan={2} className={styles.materialName}>
{position.materialName}
</td>
<td className={styles.qty}>
{position.quantity} {position.unit}
</td>
<td>{position.smrType || '—'}</td>
<td colSpan={2}>{formatDate(position.deadline)}</td>
<td />
</tr>
);
})}
{locked && (
<tr className={styles.lockedHint}>
<td colSpan={9}>
<span>Позиции из другого проекта недоступны для выбора</span>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
);
};
@@ -0,0 +1,76 @@
@import '../common/theme';
.root {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
min-height: 400px;
}
.toolbar {
display: flex;
flex-direction: column;
gap: 12px;
}
.filterRow {
display: flex;
align-items: flex-end;
gap: 12px;
}
.daysInput {
width: 220px;
}
.searchInput {
width: 280px;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 8px 0;
}
.pageInfo {
font-size: 13px;
color: $text;
min-width: 80px;
text-align: center;
}
.loaderWrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 64px 0;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 16px;
border-top: 1px solid $border;
gap: 16px;
flex-wrap: wrap;
}
.selectionSummary {
font-size: 13px;
color: $text;
}
.noSelection {
color: $muted-2;
font-style: italic;
}
.actions {
display: flex;
align-items: center;
gap: 12px;
}
@@ -0,0 +1,287 @@
import * as React from 'react';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { Button, Alert, Spinner, TextInput, FormField, IconButton } from '@tnsu/ui-kit';
import { Request, Position, FormStep } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { useRequests } from '../hooks/useRequests';
import { RequestsTable } from './RequestsTable';
import styles from './Step1PositionSelection.module.scss';
interface Props {
service: CtService;
formData: CTFormData;
onSave: (partial: Partial<CTFormData>) => void;
onNext: () => void;
onStepComplete: (step: FormStep) => void;
isReadOnly?: boolean;
}
const DEFAULT_DAYS = 5;
const PAGE_SIZE = 10;
export const Step1PositionSelection: React.FC<Props> = ({
service,
formData,
onSave,
onNext,
onStepComplete,
isReadOnly = false,
}) => {
const [days, setDays] = useState(DEFAULT_DAYS);
const [daysInput, setDaysInput] = useState(String(DEFAULT_DAYS));
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [selectedPositionIds, setSelectedPositionIds] = useState<Set<string>>(
new Set(formData.selectedPositions.map(p => p.id))
);
const [positionMap, setPositionMap] = useState<Map<string, { position: Position; request: Request }>>(new Map());
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined);
const { requests, isLoading, error: loadError, reload } = useRequests(service, days, isReadOnly);
const readOnlyRequests = useMemo(() => {
if (!isReadOnly) return [];
const byRequestId = new Map<string, Position[]>();
formData.selectedPositions.forEach(p => {
const group = byRequestId.get(p.requestId) ?? [];
group.push(p);
byRequestId.set(p.requestId, group);
});
return Array.from(byRequestId.entries()).map(([requestId, positions]) => ({
id: requestId,
requestNumber: positions[0].requestNumber,
project: formData.selectedProject ?? { id: '', name: '', code: '' },
discipline: positions[0].disciplineCode,
workType: positions[0].smrType,
organization: positions[0].organization ?? '',
createdAt: positions[0].requestCreatedAt ?? '',
positions,
}));
}, [isReadOnly, formData.selectedPositions, formData.selectedProject]);
const filteredRequests = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
const filtered = q ? requests.filter(r => r.requestNumber.toLowerCase().includes(q)) : requests;
return [...filtered].sort((a, b) => {
const aSelected = a.positions.some(p => selectedPositionIds.has(p.id)) ? 0 : 1;
const bSelected = b.positions.some(p => selectedPositionIds.has(p.id)) ? 0 : 1;
return aSelected - bSelected;
});
}, [requests, searchQuery, selectedPositionIds]);
const totalPages = Math.max(1, Math.ceil(filteredRequests.length / PAGE_SIZE));
const safePage = Math.min(currentPage, totalPages);
const pagedRequests = isReadOnly ? readOnlyRequests : filteredRequests.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
// Build position→request map for resolving selection metadata
useEffect(() => {
const map = new Map<string, { position: Position; request: Request }>();
requests.forEach(req => {
req.positions.forEach(pos => {
map.set(pos.id, { position: pos, request: req });
});
});
setPositionMap(map);
}, [requests]);
const lockedProjectId = formData.selectedProject?.id;
const handleSelectionChange = useCallback(
(positionId: string, _position: Position, request: Request, checked: boolean) => {
if (checked && lockedProjectId !== undefined && request.project.id !== lockedProjectId) return;
setSelectedPositionIds(prev => {
const next = new Set(prev);
if (checked) { next.add(positionId); } else { next.delete(positionId); }
return next;
});
if (checked && lockedProjectId === undefined) {
onSave({ selectedProject: request.project });
}
// Release project lock when last position is deselected
if (!checked && selectedPositionIds.size === 1 && selectedPositionIds.has(positionId)) {
onSave({ selectedProject: undefined });
}
},
[lockedProjectId, onSave, selectedPositionIds]
);
const handleRequestSelectionChange = useCallback(
(request: Request, checked: boolean) => {
if (request.positions.length === 0) return;
if (checked && lockedProjectId !== undefined && request.project.id !== lockedProjectId) return;
setSelectedPositionIds(prev => {
const next = new Set(prev);
request.positions.forEach(p => checked ? next.add(p.id) : next.delete(p.id));
return next;
});
if (checked && lockedProjectId === undefined) {
onSave({ selectedProject: request.project });
}
if (!checked) {
const remainingAfter = Array.from(selectedPositionIds).filter(
id => !request.positions.some(p => p.id === id)
);
if (remainingAfter.length === 0) onSave({ selectedProject: undefined });
}
},
[lockedProjectId, onSave, selectedPositionIds]
);
const handleDaysChange = (val: string): void => {
setDaysInput(val);
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0 && parsed <= 365) {
setDays(parsed);
setCurrentPage(1);
}
};
const handleSearchChange = (val: string): void => {
setSearchQuery(val);
setCurrentPage(1);
};
const selectedPositions: Position[] = Array.from(selectedPositionIds)
.map(id => positionMap.get(id)?.position)
.filter((p): p is Position => p !== undefined);
const selectedProject = selectedPositions.length > 0
? positionMap.get(selectedPositions[0].id)?.request.project ?? null
: null;
const canSave = selectedPositions.length > 0;
const handleSave = async (): Promise<void> => {
if (!selectedProject || selectedPositions.length === 0) return;
setIsSaving(true);
setSaveError(undefined);
try {
const ctNumber = await service.generateCtNumber(selectedProject.name);
const ct = await service.createCT(selectedProject.name, ctNumber);
const enrichedPositions = selectedPositions.map(p => ({
...p,
disciplineCode: p.disciplineCode || (positionMap.get(p.id)?.request.discipline ?? ''),
}));
const savedPositions = await service.createCTPositions(ct.id, enrichedPositions);
const initiatorCompany = positionMap.get(selectedPositions[0].id)?.request.organization ?? '';
onSave({ ct, selectedProject, selectedPositions: savedPositions, initiatorCompany });
onStepComplete(1);
onNext();
} catch (err) {
setSaveError((err as Error).message ?? 'Ошибка сохранения');
} finally {
setIsSaving(false);
}
};
return (
<div className={styles.root}>
{!isReadOnly && (
<div className={styles.toolbar}>
<div className={styles.filterRow}>
<div className={styles.daysInput}>
<FormField label="Потребности за последние (дней)">
<TextInput value={daysInput} onChange={handleDaysChange} type="number" min={1} max={365} />
</FormField>
</div>
<div className={styles.searchInput}>
<FormField label="Поиск по номеру потребности">
<TextInput value={searchQuery} onChange={handleSearchChange} placeholder="Например: 081/26-04/0017" />
</FormField>
</div>
<Button variant="secondary" onClick={reload} disabled={isLoading}>
Обновить
</Button>
<Button variant="secondary" onClick={() => window.open('https://tnsukz.sharepoint.com/sites/sed', '_blank')}>
Мои СТ
</Button>
</div>
{formData.selectedProject && (
<Alert variant="info">
Выбран проект: <strong>{formData.selectedProject.name}</strong> — позиции из других проектов недоступны
</Alert>
)}
</div>
)}
{loadError && <Alert variant="err">{loadError}</Alert>}
{isLoading ? (
<div className={styles.loaderWrapper}>
<Spinner size="large" label="Загрузка потребностей..." />
</div>
) : (
<>
<RequestsTable
requests={pagedRequests}
selectedPositionIds={selectedPositionIds}
lockedProjectId={lockedProjectId}
onSelectionChange={handleSelectionChange}
onRequestSelectionChange={handleRequestSelectionChange}
isReadOnly={isReadOnly}
/>
{!isReadOnly && totalPages > 1 && (
<div className={styles.pagination}>
<IconButton
icon="chevron-left"
disabled={safePage === 1}
onClick={() => setCurrentPage(1)}
title="Первая страница"
/>
<IconButton
icon="chevron-left"
disabled={safePage === 1}
onClick={() => setCurrentPage(p => p - 1)}
title="Предыдущая страница"
/>
<span className={styles.pageInfo}>{safePage} / {totalPages}</span>
<IconButton
icon="chevron-right"
disabled={safePage === totalPages}
onClick={() => setCurrentPage(p => p + 1)}
title="Следующая страница"
/>
<IconButton
icon="chevron-right"
disabled={safePage === totalPages}
onClick={() => setCurrentPage(totalPages)}
title="Последняя страница"
/>
</div>
)}
</>
)}
<div className={styles.footer}>
<div className={styles.selectionSummary}>
{selectedPositions.length > 0 ? (
<span>
Выбрано позиций: <strong>{selectedPositions.length}</strong>
{selectedProject && <> · Проект: <strong>{selectedProject.name}</strong></>}
</span>
) : (
<span className={styles.noSelection}>Выберите позиции для формирования СТ</span>
)}
</div>
{!isReadOnly && (
<div className={styles.actions}>
{saveError && <Alert variant="err">{saveError}</Alert>}
<Button variant="accent" onClick={handleSave} disabled={!canSave || isSaving}>
{isSaving ? 'Сохранение…' : 'Сохранить и продолжить'}
</Button>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,102 @@
@import '../common/theme';
.root {
display: flex;
flex-direction: column;
gap: 14px;
}
.hint {
font-size: 13px;
color: $muted;
margin: 0;
}
.presets {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.presetsLabel {
font-size: 13px;
color: $text;
font-weight: 500;
}
.presetBtn {
font-size: 12px;
height: 28px;
}
.ratesTable {
border-collapse: collapse;
font-size: 13px;
width: auto;
align-self: flex-start;
th {
text-align: left;
padding: 6px 12px;
color: $muted;
font-weight: 600;
border-bottom: 1px solid $border;
}
td {
padding: 6px 12px;
vertical-align: middle;
}
tr:hover td {
background: $head-bg;
}
}
.currencyCode {
display: inline-block;
background: $border;
color: $text;
border-radius: 3px;
padding: 2px 6px;
font-family: monospace;
font-size: 12px;
font-weight: 600;
}
.rateInput {
width: 160px;
display: flex;
align-items: center;
gap: 6px;
}
.rateInputField {
flex: 1;
min-width: 0;
}
.rateInputSuffix {
font-size: 12px;
color: $muted;
flex-shrink: 0;
}
.empty {
font-size: 13px;
color: $muted-2;
font-style: italic;
margin: 0;
}
.nbkRow {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.nbkError {
flex: 1;
min-width: 0;
}
@@ -0,0 +1,175 @@
import * as React from 'react';
import { useCallback, useState } from 'react';
import { Button, Alert, TextInput, IconButton, Spinner } from '@tnsu/ui-kit';
import { CurrencyRate } from '../types/domain';
import styles from './CurrencyRatesForm.module.scss';
interface Props {
rates: CurrencyRate[];
onChange: (rates: CurrencyRate[]) => void;
readOnly?: boolean;
}
const PRESET_CURRENCIES: Omit<CurrencyRate, 'rateToKzt'>[] = [
{ code: 'USD', name: 'Доллар США' },
{ code: 'EUR', name: 'Евро' },
{ code: 'RUB', name: 'Российский рубль' },
{ code: 'CNY', name: 'Китайский юань' },
];
export const CurrencyRatesForm: React.FC<Props> = ({ rates, onChange, readOnly = false }) => {
const [isNbkLoading, setIsNbkLoading] = useState(false);
const [nbkError, setNbkError] = useState<string | undefined>(undefined);
const fetchNbkRates = useCallback(async () => {
if (rates.length === 0) return;
setIsNbkLoading(true);
setNbkError(undefined);
try {
const res = await fetch('https://open.er-api.com/v6/latest/USD');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.result !== 'success') throw new Error('Ошибка API');
const usdToKzt: number = data.rates['KZT'];
if (!usdToKzt) throw new Error('Курс KZT не найден');
const updated = rates.map(r => {
const rateToUsd: number = data.rates[r.code];
if (!rateToUsd) return r;
const rateToKzt = r.code === 'USD'
? Math.round(usdToKzt * 100) / 100
: Math.round((usdToKzt / rateToUsd) * 100) / 100;
return { ...r, rateToKzt };
});
onChange(updated);
} catch {
setNbkError('Не удалось получить курсы. Проверьте подключение к интернету.');
} finally {
setIsNbkLoading(false);
}
}, [rates, onChange]);
const addCurrency = useCallback(
(preset: Omit<CurrencyRate, 'rateToKzt'>) => {
if (rates.some(r => r.code === preset.code)) return;
onChange([...rates, { ...preset, rateToKzt: 0 }]);
},
[rates, onChange]
);
const updateRate = useCallback(
(code: string, value: string) => {
const parsed = parseFloat(value.replace(',', '.'));
onChange(
rates.map(r => (r.code === code ? { ...r, rateToKzt: isNaN(parsed) ? 0 : parsed } : r))
);
},
[rates, onChange]
);
const removeRate = useCallback(
(code: string) => {
onChange(rates.filter(r => r.code !== code));
},
[rates, onChange]
);
const presetsToShow = PRESET_CURRENCIES.filter(p => !rates.some(r => r.code === p.code));
return (
<div className={styles.root}>
{!readOnly && (
<p className={styles.hint}>
Укажите курсы валют к тенге на дату формирования СТ. Если все КП в тенге, оставьте пустым.
</p>
)}
{!readOnly && presetsToShow.length > 0 && (
<div className={styles.presets}>
<span className={styles.presetsLabel}>Добавить валюту:</span>
{presetsToShow.map(p => (
<Button
key={p.code}
variant="secondary"
onClick={() => addCurrency(p)}
className={styles.presetBtn}
>
{p.code} — {p.name}
</Button>
))}
</div>
)}
{!readOnly && rates.length > 0 && (
<div className={styles.nbkRow}>
<Button
variant="secondary"
onClick={fetchNbkRates}
disabled={isNbkLoading}
>
{isNbkLoading
? <><Spinner size="small" />&nbsp;Загрузка…</>
: 'Получить актуальный курс'}
</Button>
{nbkError && (
<div className={styles.nbkError}>
<Alert variant="err">{nbkError}</Alert>
</div>
)}
</div>
)}
{rates.length > 0 && (
<table className={styles.ratesTable}>
<thead>
<tr>
<th>Валюта</th>
<th>Код</th>
<th>Курс к KZT</th>
{!readOnly && <th />}
</tr>
</thead>
<tbody>
{rates.map(rate => (
<tr key={rate.code}>
<td>{rate.name}</td>
<td>
<span className={styles.currencyCode}>{rate.code}</span>
</td>
<td>
{readOnly ? (
<span>{rate.rateToKzt === 0 ? '—' : `${rate.rateToKzt} KZT`}</span>
) : (
<div className={styles.rateInput}>
<div className={styles.rateInputField}>
<TextInput
value={rate.rateToKzt === 0 ? '' : String(rate.rateToKzt)}
onChange={val => updateRate(rate.code, val)}
placeholder="0.00"
ariaLabel={`Курс ${rate.code} к KZT`}
/>
</div>
<span className={styles.rateInputSuffix}>KZT</span>
</div>
)}
</td>
{!readOnly && (
<td>
<IconButton
icon="trash"
onClick={() => removeRate(rate.code)}
title={`Удалить валюту ${rate.code}`}
/>
</td>
)}
</tr>
))}
</tbody>
</table>
)}
{rates.length === 0 && (
<p className={styles.empty}>Все предложения в тенге (KZT)</p>
)}
</div>
);
};
@@ -0,0 +1,186 @@
@import '../common/theme';
.root {
display: flex;
flex-direction: column;
gap: 16px;
}
.dropZone {
border: 2px dashed $border-strong;
border-radius: 8px;
padding: 40px 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
background: $head-bg;
&:hover,
&:focus {
border-color: $accent;
background: $accent-bg;
outline: none;
}
&.dropZoneDragging {
border-color: $accent;
background: $accent-bg;
}
&.dropZoneDisabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
}
.uploadIcon {
width: 40px;
height: 40px;
color: $muted-2;
margin: 0 auto 12px;
display: block;
}
.dropText {
font-size: 14px;
color: $text;
margin: 0 0 6px;
}
.browseLink {
color: $accent;
text-decoration: underline;
}
.acceptedTypes {
font-size: 12px;
color: $muted-2;
margin: 0;
}
.hiddenInput {
display: none;
}
.fileList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.fileItem {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: $surface;
border: 1px solid $border;
border-radius: 4px;
font-size: 13px;
}
.fileIcon {
flex-shrink: 0;
font-size: 18px;
}
.fileName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $text;
}
.statusBadge {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
&.status_pending {
background: $hover;
color: $muted;
}
&.status_uploading,
&.status_processing {
background: $accent-bg;
color: $accent-dark;
}
&.status_done {
background: $ok-bg;
color: $ok;
}
&.status_error {
background: $danger-bg;
color: $danger;
}
}
.spinner {
display: inline-block;
width: 10px;
height: 10px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.supplierTag {
font-size: 11px;
color: $ok;
background: $ok-bg;
border-radius: 10px;
padding: 1px 8px;
white-space: nowrap;
flex-shrink: 0;
}
.flowProgress {
font-size: 11px;
color: $accent-dark;
flex-shrink: 0;
}
.errorMsg {
font-size: 11px;
color: $danger;
}
.currencySelect {
font-size: 11px;
padding: 2px 6px;
border: 1px solid $border;
border-radius: 3px;
background: $surface;
color: $text;
font-family: inherit;
cursor: pointer;
outline: none;
flex-shrink: 0;
height: 26px;
&:focus { border-color: $accent; }
}
.removeBtn {
flex-shrink: 0;
color: $muted-2;
&:hover {
color: $danger;
}
}
@@ -0,0 +1,143 @@
import * as React from 'react';
import { useState, useCallback, useRef } from 'react';
import { IconButton } from '@tnsu/ui-kit';
import styles from './FileUploadArea.module.scss';
export interface UploadedFile {
id: string;
file: File;
status: 'pending' | 'uploading' | 'processing' | 'done' | 'error';
errorMessage?: string;
supplier?: string; // extracted supplier name from Syntex
flowProgress?: string; // e.g. "3/6 позиций"
currencyCode?: string; // currency selected for this KP file
}
interface Props {
files: UploadedFile[];
onFilesAdded: (files: File[]) => void;
onFileRemove: (id: string) => void;
disabled?: boolean;
currencyOptions?: string[];
onCurrencyChange?: (id: string, currency: string) => void;
}
const ACCEPTED_TYPES = ['.pdf', '.docx', '.doc'];
function getStatusLabel(status: UploadedFile['status']): string {
switch (status) {
case 'pending': return 'Ожидание';
case 'uploading': return 'Загрузка...';
case 'processing': return 'Обработка...';
case 'done': return 'Готово';
case 'error': return 'Ошибка';
}
}
export const FileUploadArea: React.FC<Props> = ({ files, onFilesAdded, onFileRemove, disabled, currencyOptions, onCurrencyChange }) => {
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const addFiles = useCallback(
(incoming: FileList | null) => {
if (!incoming || disabled) return;
onFilesAdded(Array.from(incoming));
},
[onFilesAdded, disabled]
);
const handleDragOver = (e: React.DragEvent): void => {
e.preventDefault();
if (!disabled) setIsDragging(true);
};
const handleDragLeave = (): void => setIsDragging(false);
const handleDrop = (e: React.DragEvent): void => {
e.preventDefault();
setIsDragging(false);
addFiles(e.dataTransfer.files);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
addFiles(e.target.files);
// Reset input so same file can be re-added after removal
if (inputRef.current) inputRef.current.value = '';
};
return (
<div className={styles.root}>
<div
className={[styles.dropZone, isDragging ? styles.dropZoneDragging : '', disabled ? styles.dropZoneDisabled : '']
.filter(Boolean)
.join(' ')}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !disabled && inputRef.current?.click()}
role="button"
tabIndex={disabled ? -1 : 0}
onKeyDown={e => e.key === 'Enter' && !disabled && inputRef.current?.click()}
aria-label="Загрузить файлы КП"
>
<svg className={styles.uploadIcon} viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path d="M16 22V10M10 16l6-6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<rect x="4" y="24" width="24" height="4" rx="2" fill="currentColor" opacity=".2" />
</svg>
<p className={styles.dropText}>
Перетащите файлы или <span className={styles.browseLink}>выберите на диске</span>
</p>
<p className={styles.acceptedTypes}>Поддерживаемые форматы: {ACCEPTED_TYPES.join(', ')}</p>
<input
ref={inputRef}
type="file"
multiple
accept={ACCEPTED_TYPES.join(',')}
className={styles.hiddenInput}
onChange={handleInputChange}
disabled={disabled}
aria-hidden="true"
tabIndex={-1}
/>
</div>
{files.length > 0 && (
<ul className={styles.fileList} role="list">
{files.map(f => (
<li key={f.id} className={styles.fileItem}>
<span className={styles.fileIcon} aria-hidden="true">📄</span>
<span className={styles.fileName}>{f.file.name}</span>
<span className={[styles.statusBadge, styles[`status_${f.status}`]].join(' ')}>
{f.status === 'processing' || f.status === 'uploading' ? (
<span className={styles.spinner} aria-hidden="true" />
) : null}
{getStatusLabel(f.status)}
</span>
{f.supplier && <span className={styles.supplierTag}>{f.supplier}</span>}
{f.flowProgress && <span className={styles.flowProgress}>{f.flowProgress}</span>}
{f.status === 'done' && currencyOptions && currencyOptions.length > 0 && onCurrencyChange && (
<select
className={styles.currencySelect}
value={f.currencyCode ?? 'KZT'}
onChange={e => onCurrencyChange(f.id, e.target.value)}
aria-label={`Валюта КП ${f.file.name}`}
>
{currencyOptions.map(c => <option key={c} value={c}>{c}</option>)}
</select>
)}
{f.errorMessage && <span className={styles.errorMsg}>{f.errorMessage}</span>}
{(f.status === 'pending' || f.status === 'done' || f.status === 'error') && (
<IconButton
icon="close"
onClick={() => onFileRemove(f.id)}
title="Удалить файл"
className={styles.removeBtn}
/>
)}
</li>
))}
</ul>
)}
</div>
);
};
@@ -0,0 +1,184 @@
@import '../common/theme';
.root {
display: flex;
flex-direction: column;
gap: 32px;
padding: 24px;
}
.topRow {
display: flex;
gap: 32px;
align-items: flex-start;
> .section:first-child {
flex: 1 1 0;
min-width: 0;
}
}
.currencySection {
flex: 1 1 0;
min-width: 0;
}
.section {
display: flex;
flex-direction: column;
gap: 12px;
}
.sectionTitle {
font-size: 15px;
font-weight: 600;
color: $text;
margin: 0;
padding-bottom: 8px;
border-bottom: 1px solid $border;
}
.sectionHint {
font-size: 13px;
color: $muted;
margin: 0;
}
.addSupplierRow {
display: flex;
align-items: flex-end;
gap: 10px;
flex-wrap: wrap;
max-width: 860px;
}
.supplierNameInput {
flex: 2;
min-width: 200px;
}
.supplierInnInput {
flex: 1;
min-width: 140px;
}
.noOfferList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
max-width: 860px;
}
.noOfferItem {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: $head-bg;
border: 1px solid $border;
border-radius: 4px;
font-size: 13px;
}
.noOfferName {
flex: 1;
font-weight: 500;
color: $text;
}
.noOfferInn {
font-size: 12px;
color: $muted;
font-family: monospace;
}
.kpFileLink {
font-size: 13px;
color: $accent;
text-decoration: none;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
text-decoration: underline;
color: $accent-dark;
}
}
.noOfferBadge {
background: $danger-bg;
color: $danger;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
}
// ─── Initiator card ───────────────────────────────────────────────────────
.initiatorCard {
display: flex;
align-items: flex-start;
gap: 16px;
background: $accent-bg;
border: 1px solid $accent-border;
border-radius: 4px;
padding: 12px 16px;
}
.initiatorTitle {
font-size: 12px;
font-weight: 600;
color: $accent-dark;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
padding-top: 2px;
min-width: 70px;
}
.initiatorFields {
display: flex;
flex-wrap: wrap;
gap: 6px 24px;
}
.initiatorRow {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.initiatorLabel {
color: $muted;
font-weight: 500;
}
.initiatorValue {
color: $text;
font-weight: 600;
}
// ─── Footer ───────────────────────────────────────────────────────────────
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16px;
border-top: 1px solid $border;
gap: 12px;
flex-wrap: wrap;
}
.footerRight {
display: flex;
align-items: center;
gap: 12px;
}
@@ -0,0 +1,131 @@
import * as React from 'react';
import { useState } from 'react';
import { Button, Alert, Spinner } from '@tnsu/ui-kit';
import { CurrencyRate, FormStep } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { FileUploadArea, UploadedFile } from './FileUploadArea';
import { CurrencyRatesForm } from './CurrencyRatesForm';
import { NoOfferEntry } from './kpTypes';
import { useKpProcessing } from './useKpProcessing';
import { persistStep2 } from './step2Persist';
import { SuppliersWithoutKp } from './SuppliersWithoutKp';
import { UploadedKpList } from './UploadedKpList';
import styles from './Step2CommercialOffers.module.scss';
interface Props {
service: CtService;
formData: CTFormData;
userDisplayName: string;
isNotificationLink?: boolean;
isReadOnly?: boolean;
isRestrictedView?: boolean;
onSave: (partial: Partial<CTFormData>) => void;
onNext: () => void;
onPrev: () => void;
onStepComplete: (step: FormStep) => void;
}
// Шаг 2 «Коммерческие предложения»: загрузка КП (конвейер — useKpProcessing), курсы валют, поставщики
// без КП. Сохранение — step2Persist. Контейнер компонует блоки и колбэки (§стайл-гайд).
export const Step2CommercialOffers: React.FC<Props> = ({
service,
formData,
userDisplayName,
isNotificationLink,
onSave,
onNext,
onPrev,
onStepComplete,
isReadOnly = false,
isRestrictedView = false,
}) => {
const [currencyRates, setCurrencyRates] = useState<CurrencyRate[]>(formData.currencyRates);
const [noOfferSuppliers, setNoOfferSuppliers] = useState<NoOfferEntry[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined);
const kp = useKpProcessing(service, formData, onSave);
const handleSave = async (): Promise<void> => {
setIsSaving(true);
setSaveError(undefined);
try {
const patch = await persistStep2({ service, formData, kpFiles: kp.kpFiles, currencyRates, noOfferSuppliers, userDisplayName });
onSave(patch);
onStepComplete(2);
onNext();
} catch (err) {
setSaveError((err as Error).message ?? 'Ошибка сохранения');
} finally {
setIsSaving(false);
}
};
const canProceed = !isSaving && currencyRates.every(r => r.rateToKzt > 0);
const currencyOptions = ['KZT', ...currencyRates.filter(r => r.code !== 'KZT').map(r => r.code)];
const uploadedFiles: UploadedFile[] = kp.kpFiles.map(f => ({
id: f.localId,
file: f.file,
status: (f.status === 'extracting' || f.status === 'flowing') ? 'processing' : f.status,
errorMessage: f.error,
supplier: f.supplierName,
flowProgress: f.status === 'flowing' && f.flowTotal > 0
? `${f.flowDone}/${f.flowTotal} позиций`
: undefined,
currencyCode: f.currencyCode,
}));
return (
<div className={styles.root}>
<div className={styles.topRow}>
{!isRestrictedView && <section className={styles.section}>
<h3 className={styles.sectionTitle}>Загрузка коммерческих предложений</h3>
<FileUploadArea
files={uploadedFiles}
onFilesAdded={files => { kp.handleFilesAdded(files).catch(() => undefined); }}
onFileRemove={kp.handleFileRemove}
currencyOptions={currencyOptions}
onCurrencyChange={kp.handleCurrencyChange}
disabled={isNotificationLink || isReadOnly}
/>
{kp.duplicateWarning && <Alert variant="warn">{kp.duplicateWarning}</Alert>}
{kp.isProcessing && (
<Alert variant="info">Файлы обрабатываются системой Syntex. Можно продолжить заполнение формы.</Alert>
)}
</section>}
<section className={`${styles.section} ${styles.currencySection}`}>
<h3 className={styles.sectionTitle}>Курсы валют</h3>
<CurrencyRatesForm rates={currencyRates} onChange={setCurrencyRates} readOnly={isReadOnly} />
</section>
</div>
<SuppliersWithoutKp items={noOfferSuppliers} onChange={setNoOfferSuppliers} disabled={isReadOnly} />
{!isRestrictedView && (formData.uploadedKpFiles?.length ?? 0) > 0 && (
<UploadedKpList
files={formData.uploadedKpFiles}
service={service}
onRemove={(spId) => onSave({ uploadedKpFiles: formData.uploadedKpFiles.filter(u => u.spId !== spId) })}
disabled={isReadOnly}
/>
)}
<div className={styles.footer}>
<Button variant="secondary" onClick={onPrev}>
Назад
</Button>
<div className={styles.footerRight}>
{saveError && <Alert variant="err">{saveError}</Alert>}
{!isNotificationLink && !isReadOnly && (
<Button variant="accent" onClick={handleSave} disabled={!canProceed}>
{isSaving ? <Spinner size="small" /> : 'Сохранить и продолжить'}
</Button>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,72 @@
import * as React from 'react';
import { useState } from 'react';
import { Button, TextInput, IconButton } from '@tnsu/ui-kit';
import { NoOfferEntry } from './kpTypes';
import styles from './Step2CommercialOffers.module.scss';
interface Props {
items: NoOfferEntry[];
onChange: (items: NoOfferEntry[]) => void;
disabled?: boolean;
}
// Секция «Потенциальные поставщики без КП»: держит своё UI-состояние формы добавления, список — снаружи.
export const SuppliersWithoutKp: React.FC<Props> = ({ items, onChange, disabled }) => {
const [name, setName] = useState('');
const [comment, setComment] = useState('');
const add = (): void => {
if (!name.trim()) return;
onChange([...items, { localId: `no_${Date.now()}`, name: name.trim(), comment: comment.trim() }]);
setName('');
setComment('');
};
return (
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Потенциальные поставщики без КП</h3>
<p className={styles.sectionHint}>Укажите потенциальных поставщиков, которые не предоставили коммерческое предложение.</p>
<div className={styles.addSupplierRow}>
<div className={styles.supplierNameInput}>
<TextInput
placeholder="Наименование потенциального поставщика"
value={name}
onChange={setName}
ariaLabel="Наименование потенциального поставщика без КП"
readOnly={disabled}
/>
</div>
<div className={styles.supplierInnInput}>
<TextInput
placeholder="Комментарий (необязательно)"
value={comment}
onChange={setComment}
ariaLabel="Комментарий"
readOnly={disabled}
/>
</div>
<Button variant="secondary" onClick={add} disabled={!name.trim() || disabled}>
Добавить
</Button>
</div>
{items.length > 0 && (
<ul className={styles.noOfferList}>
{items.map(s => (
<li key={s.localId} className={styles.noOfferItem}>
<span className={styles.noOfferName}>{s.name}</span>
{s.comment && <span className={styles.noOfferInn}>{s.comment}</span>}
<span className={styles.noOfferBadge}>Без КП</span>
<IconButton
icon="trash"
onClick={() => onChange(items.filter(x => x.localId !== s.localId))}
title={`Удалить потенциального поставщика ${s.name}`}
disabled={disabled}
/>
</li>
))}
</ul>
)}
</section>
);
};
@@ -0,0 +1,40 @@
import * as React from 'react';
import { IconButton } from '@tnsu/ui-kit';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import styles from './Step2CommercialOffers.module.scss';
interface Props {
files: CTFormData['uploadedKpFiles'];
service: CtService;
onRemove: (spId: number) => void;
disabled?: boolean;
}
// Секция «Загруженные КП» (постоянный список из formData): ссылка на файл + удаление из списка.
export const UploadedKpList: React.FC<Props> = ({ files, service, onRemove, disabled }) => (
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Загруженные КП</h3>
<ul className={styles.noOfferList}>
{files.map(f => (
<li key={f.spId} className={styles.noOfferItem}>
{f.supplierName && <span className={styles.noOfferName}>{f.supplierName}</span>}
<a
href={service.getKpFileUrl(f.name)}
target="_blank"
rel="noopener noreferrer"
className={styles.kpFileLink}
>
{f.name}
</a>
<IconButton
icon="trash"
title="Удалить КП из списка"
onClick={() => onRemove(f.spId)}
disabled={disabled}
/>
</li>
))}
</ul>
</section>
);
@@ -0,0 +1,33 @@
import { FlowResult } from '../types/domain';
// Результат разбора КП потоком по одной позиции.
export interface FlowPositionResult {
positionId: string;
positionSpId?: number;
flowResult: FlowResult;
}
// Файл КП в процессе загрузки/обработки (Шаг 2). Статус-конечный автомат: upload → extract → flow → done/error.
export interface KpFile {
localId: string;
file: File;
status: 'uploading' | 'extracting' | 'flowing' | 'done' | 'error';
spId?: number;
supplierName?: string;
supplierSpId?: number;
flowDone: number;
flowTotal: number;
error?: string;
currencyCode: string;
flowResults: FlowPositionResult[];
}
// Потенциальный поставщик без КП (ручной ввод).
export interface NoOfferEntry {
localId: string;
name: string;
comment: string;
}
export const POLL_INTERVAL_MS = 5000;
export const MAX_POLL_RETRIES = 60; // 5 min
@@ -0,0 +1,148 @@
import { ApprovalMember, ApprovalMemberStatus, CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { KpFile, NoOfferEntry } from './kpTypes';
export interface PersistStep2Args {
service: CtService;
formData: CTFormData;
kpFiles: KpFile[];
currencyRates: CurrencyRate[];
noOfferSuppliers: NoOfferEntry[];
userDisplayName: string;
}
// Сохранение Шага 2: курсы валют, поставщики без КП, поставщики с КП + офферы из результатов потока,
// пересборка цепочки согласования из матрицы. Возвращает patch для onSave. Логика 1:1 из исходного handleSave.
export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFormData>> {
const { service, formData, kpFiles, currencyRates, noOfferSuppliers, userDisplayName } = args;
let savedRates = currencyRates;
if (currencyRates.length > 0) {
savedRates = await service.saveCurrencyRates(currencyRates);
}
for (const s of noOfferSuppliers) {
await service.saveNoOfferSupplierItem(s.name, s.comment);
}
const newSuppliersWithOffer: Supplier[] = kpFiles
.filter(f => f.supplierName)
.map(f => ({
id: f.supplierSpId ? String(f.supplierSpId) : f.localId,
spId: f.supplierSpId,
name: f.supplierName as string,
hasOffer: true,
currency: f.currencyCode,
}));
const suppliersWithoutOffer: Supplier[] = noOfferSuppliers.map(s => ({
id: s.localId,
name: s.name,
hasOffer: false,
}));
// Build offers from flow results so Step 3 can pre-fill the table
const newOffers: SupplierOffer[] = [];
for (const f of kpFiles) {
if (!f.supplierSpId || f.flowResults.length === 0) continue;
const currency = f.currencyCode;
const rate = currency !== 'KZT'
? (savedRates.find(r => r.code === currency)?.rateToKzt ?? 1)
: 1;
for (const fr of f.flowResults) {
const pricePerUnit = fr.flowResult.price ?? 0;
const quantity = fr.flowResult.quantity ?? 0;
const pricePerUnitKzt = pricePerUnit * rate;
const totalPriceKzt = pricePerUnitKzt * quantity;
newOffers.push({
id: '',
supplierId: String(f.supplierSpId),
positionId: fr.positionId,
supplierPosition: fr.flowResult.position,
unit: fr.flowResult.unit,
pricePerUnit,
quantity,
currencyCode: currency,
pricePerUnitKzt,
totalPriceKzt,
totalWithExtraKzt: totalPriceKzt,
offerId: f.spId ? String(f.spId) : undefined,
});
}
}
// Persist offers with currency data to SP so shared links show correct currency columns.
// saveKpOffer (called during flow) omits valutaId and stores price as priceTenge — fix here
// before proceeding to Step 3.
if (newOffers.length > 0) {
const offersForSP = newOffers
.map(o => {
const pos = formData.selectedPositions.find(p => p.id === o.positionId);
return pos?.spId !== undefined ? { ...o, positionId: String(pos.spId) } : null;
})
.filter((o): o is SupplierOffer => o !== null && !isNaN(parseInt(o.supplierId, 10)));
if (offersForSP.length > 0) {
await service.saveOffers(offersForSP, savedRates);
}
}
// Мержим с поставщиками из предыдущих сессий на шаге 2
// (повторная загрузка КП того же поставщика — заменяем по имени)
const newSupplierNames = new Set(newSuppliersWithOffer.map(s => s.name));
const newSupplierIds = new Set(newSuppliersWithOffer.map(s => s.id));
const mergedSuppliersWithOffer: Supplier[] = [
...(formData.suppliers ?? []).filter(s => s.hasOffer && !newSupplierNames.has(s.name)),
...newSuppliersWithOffer,
];
const mergedOffers: SupplierOffer[] = [
...(formData.offers ?? []).filter(o => !newSupplierIds.has(o.supplierId)),
...newOffers,
];
// Preserve existing hasOffer:false suppliers from formData when user re-visits Step 2
// (noOfferSuppliers state starts empty on remount, so without this they'd be lost on re-save)
const newNoOfferNames = new Set(noOfferSuppliers.map(s => s.name.trim()));
const preservedNoOfferSuppliers = (formData.suppliers ?? [])
.filter(s => !s.hasOffer && !newNoOfferNames.has(s.name.trim()));
let approvalChain: ApprovalMember[] = [];
if (formData.ct?.id) {
await service.deleteApprovalChainByCt(formData.ct.id);
let matrix = formData.initiatorDepartment
? await service.loadApprovalMatrix(formData.initiatorCompany, formData.initiatorDepartment)
: [];
if (matrix.length === 0) {
matrix = await service.loadApprovalMatrix(formData.initiatorCompany, '');
}
if (matrix.length > 0) {
approvalChain = [
{
id: 'chain_1',
order: 1,
userId: String(service.currentUserSpId),
userName: userDisplayName,
role: 'Инициатор',
status: 'pending',
},
...matrix.map((m, idx) => ({
id: `chain_${idx + 2}`,
order: idx + 2,
userId: String(m.approverSpId),
userName: m.approverName,
role: m.post,
status: 'pending' as ApprovalMemberStatus,
canAddApprover: m.canAddApprover,
})),
];
approvalChain = await service.saveApprovalChain(approvalChain, formData.ct.id);
}
}
return {
currencyRates: savedRates,
suppliers: [...mergedSuppliersWithOffer, ...preservedNoOfferSuppliers, ...suppliersWithoutOffer],
offers: mergedOffers,
approvalChain,
};
}
@@ -0,0 +1,252 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from './kpTypes';
let localIdCounter = 0;
export interface UseKpProcessingResult {
kpFiles: KpFile[];
duplicateWarning: string | null;
setDuplicateWarning: (v: string | null) => void;
handleFilesAdded: (files: File[]) => Promise<void>;
handleFileRemove: (localId: string) => void;
handleCurrencyChange: (localId: string, currency: string) => void;
isProcessing: boolean;
}
// Конвейер обработки КП (Шаг 2): загрузка в библиотеку → извлечение текста (Word) → поллинг Syntex →
// запуск потока по позициям → сбор офферов/критериев/поставщика. Логика 1:1 из исходного
// Step2CommercialOffers (§стайл-гайд: логика вне вёрстки). Завершённые файлы пишутся в formData через onSave.
export function useKpProcessing(
service: CtService,
formData: CTFormData,
onSave: (partial: Partial<CTFormData>) => void
): UseKpProcessingResult {
const [kpFiles, setKpFiles] = useState<KpFile[]>([]);
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null);
const mountedRef = useRef(true);
const formDataRef = useRef(formData);
const kpFilesRef = useRef<KpFile[]>([]);
useEffect(() => { formDataRef.current = formData; }, [formData]);
useEffect(() => { kpFilesRef.current = kpFiles; }, [kpFiles]);
useEffect(() => () => { mountedRef.current = false; }, []);
// Сохраняем завершённые файлы в formData при изменении статуса
useEffect(() => {
const doneFiles = kpFiles.filter(f => f.status === 'done' && f.spId !== undefined);
if (doneFiles.length === 0) return;
const currentUploaded = formDataRef.current.uploadedKpFiles ?? [];
const newEntries = doneFiles
.filter(f => !currentUploaded.some(u => u.spId === f.spId))
.map(f => ({ name: f.file.name, spId: f.spId as number, supplierName: f.supplierName }));
if (newEntries.length > 0) {
onSave({ uploadedKpFiles: [...currentUploaded, ...newEntries] });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [kpFiles]);
const patchFile = (localId: string, patch: Partial<KpFile>): void => {
if (!mountedRef.current) return;
setKpFiles(prev => prev.map(f => f.localId === localId ? { ...f, ...patch } : f));
};
const triggerFlowsForFile = async (
localId: string,
fileSpId: number,
fileName: string,
supplierSpId?: number
): Promise<void> => {
const positions = formDataRef.current.selectedPositions;
if (!mountedRef.current) return;
setKpFiles(prev => prev.map(f =>
f.localId === localId ? { ...f, status: 'flowing', flowTotal: positions.length, flowDone: 0, flowResults: [] } : f
));
const collectedResults: FlowPositionResult[] = [];
await Promise.all(
positions.map(async pos => {
try {
const result = await service.triggerFlow(fileName, fileSpId, pos.materialName);
collectedResults.push({ positionId: pos.id, positionSpId: pos.spId, flowResult: result });
if (pos.spId && supplierSpId && result) {
await service.saveKpOffer({
positionSpId: pos.spId,
supplierSpId,
offerSpId: fileSpId,
supplierPosition: result.position ?? pos.materialName,
unit: result.unit ?? pos.unit,
quantity: result.quantity ?? pos.quantity,
price: result.price ?? 0,
summa: result.cost ?? 0,
});
}
} catch {
// individual position failure doesn't stop others
}
if (mountedRef.current) {
setKpFiles(prev => prev.map(f =>
f.localId === localId ? { ...f, flowDone: f.flowDone + 1 } : f
));
}
})
);
// If supplier wasn't set by Syntex, use supplier name from flow result
let resolvedSupplierSpId = supplierSpId;
if (!resolvedSupplierSpId) {
const supplierName = collectedResults.find(r => r.flowResult.supplier)?.flowResult.supplier;
if (supplierName) {
try {
resolvedSupplierSpId = await service.saveSupplierWithOffer(supplierName);
patchFile(localId, { supplierName, supplierSpId: resolvedSupplierSpId });
} catch {
// supplier save failed — continue without supplier
}
}
}
// Auto-save criteria (one record per supplier — take first result that has any criteria field)
const criteriaEntry = collectedResults.find(
r => r.flowResult.conditions || r.flowResult.deliveryType || r.flowResult.deadline || r.flowResult.sertificates
);
if (criteriaEntry && resolvedSupplierSpId) {
const ctSpId = formDataRef.current.ct?.id ? parseInt(formDataRef.current.ct.id, 10) : undefined;
service.saveFlowCriteria(resolvedSupplierSpId, criteriaEntry.flowResult, ctSpId).catch(() => undefined);
}
patchFile(localId, { status: 'done', flowResults: collectedResults });
};
const pollSyntex = async (
localId: string,
spId: number,
fileName: string,
retries: number,
savedSupplierSpId?: number
): Promise<void> => {
if (!mountedRef.current) return;
if (retries >= MAX_POLL_RETRIES) {
patchFile(localId, { status: 'error', error: 'Syntex не завершил обработку за 5 минут' });
return;
}
try {
const fields = await service.getKpItemFields(spId);
let currentSupplierSpId = savedSupplierSpId;
if (fields.customer && !currentSupplierSpId) {
patchFile(localId, { supplierName: fields.customer });
try {
currentSupplierSpId = await service.saveSupplierWithOffer(fields.customer);
patchFile(localId, { supplierSpId: currentSupplierSpId });
} catch {
// supplier save failed — continue polling
}
}
if (fields.fileContent) {
await triggerFlowsForFile(localId, spId, fileName, currentSupplierSpId);
} else {
await new Promise<void>(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
await pollSyntex(localId, spId, fileName, retries + 1, currentSupplierSpId);
}
} catch {
await new Promise<void>(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
await pollSyntex(localId, spId, fileName, retries + 1, savedSupplierSpId);
}
};
const handleFilesAdded = useCallback(async (files: File[]): Promise<void> => {
const existingNames = new Set([
...kpFilesRef.current.map(f => f.file.name),
...(formDataRef.current.uploadedKpFiles ?? []).map(f => f.name),
]);
const newFiles = files.filter(f => !existingNames.has(f.name));
const skipped = files.filter(f => existingNames.has(f.name));
if (skipped.length > 0) {
setDuplicateWarning(`Файл(ы) уже загружен(ы) в рамках этой СТ: ${skipped.map(f => f.name).join(', ')}`);
} else {
setDuplicateWarning(null);
}
if (newFiles.length === 0) return;
const entries: KpFile[] = newFiles.map(file => ({
localId: `kp_${++localIdCounter}`,
file,
status: 'uploading' as const,
flowDone: 0,
flowTotal: 0,
currencyCode: 'KZT',
flowResults: [],
}));
if (mountedRef.current) setKpFiles(prev => [...prev, ...entries]);
for (const entry of entries) {
try {
let cachedSpId: number | undefined;
try {
const existing = await service.findExistingKpFile(entry.file.name, entry.file.size);
if (existing) cachedSpId = existing.spId;
} catch {
// lookup failed — proceed with normal upload
}
if (cachedSpId !== undefined) {
patchFile(entry.localId, { spId: cachedSpId, status: 'extracting' });
pollSyntex(entry.localId, cachedSpId, entry.file.name, 0).catch(err => {
patchFile(entry.localId, { status: 'error', error: (err as Error).message });
});
continue;
}
const { spId, name } = await service.uploadKpFile(entry.file);
patchFile(entry.localId, { spId, status: 'extracting' });
const isWord = /\.(doc|docx|xls|xlsx)$/i.test(entry.file.name);
if (isWord) {
try {
await service.extractWordText(spId);
} catch (err) {
patchFile(entry.localId, { status: 'error', error: `Ошибка извлечения текста Word: ${(err as Error).message}` });
continue;
}
}
pollSyntex(entry.localId, spId, name, 0).catch(err => {
patchFile(entry.localId, { status: 'error', error: (err as Error).message });
});
} catch (err) {
patchFile(entry.localId, { status: 'error', error: (err as Error).message });
}
}
}, [service]);
const handleFileRemove = (localId: string): void => {
setKpFiles(prev => prev.filter(f => f.localId !== localId));
};
const handleCurrencyChange = (localId: string, currency: string): void => {
setKpFiles(prev => prev.map(f => f.localId === localId ? { ...f, currencyCode: currency } : f));
};
const isProcessing = kpFiles.some(f => f.status === 'extracting' || f.status === 'flowing');
return {
kpFiles,
duplicateWarning,
setDuplicateWarning,
handleFilesAdded,
handleFileRemove,
handleCurrencyChange,
isProcessing,
};
}
@@ -0,0 +1,78 @@
import * as React from 'react';
import { Button, TextInput, Icon } from '@tnsu/ui-kit';
import { Step3Matrix } from './useStep3Matrix';
import { getSubCols } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
matrix: Step3Matrix;
isRestrictedView: boolean;
colSpan: number;
}
// Замыкающая строка таблицы: инлайн-форма добавления позиции (Доп. расходы) + кнопка «Добавить логистику».
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
export function AddExtraRow({ matrix, isRestrictedView, colSpan }: Props): JSX.Element | null {
const {
isReadOnly, showAddPosRow, newPosName, newPosUnit, setNewPosName, setNewPosUnit, setShowAddPosRow,
handleAddPosition, handleAddDelivery, suppliersWithOffer, supplierCurrencies,
} = matrix;
if (isReadOnly) return null;
if (showAddPosRow) {
return (
<tr className={styles.addPositionRow}>
<td colSpan={3}>
<div className={styles.addPosInlineForm}>
<div className={styles.addInput}>
<TextInput
placeholder="Наименование позиции"
value={newPosName}
onChange={setNewPosName}
ariaLabel="Наименование позиции"
onKeyDown={e => { if (e.key === 'Enter') handleAddPosition(); }}
autoFocus
/>
</div>
<div className={styles.addInputSmall}>
<TextInput
placeholder="Ед."
value={newPosUnit}
onChange={setNewPosUnit}
ariaLabel="Единица измерения"
/>
</div>
<Button variant="secondary" onClick={handleAddPosition} disabled={!newPosName.trim()}>
<Icon name="check" size="sm" />
Добавить
</Button>
<Button
variant="secondary"
onClick={() => { setShowAddPosRow(false); setNewPosName(''); setNewPosUnit(''); }}
>
<Icon name="close" size="sm" />
Отмена
</Button>
</div>
</td>
{suppliersWithOffer.map(s => (
<td key={s.id} colSpan={getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView)} />
))}
</tr>
);
}
return (
<tr className={styles.addPositionRow}>
<td colSpan={colSpan}>
<button className={styles.addPositionBtn} onClick={() => setShowAddPosRow(true)}>
+ Добавить позицию (Доп. расходы)
</button>
<button className={styles.addPositionBtn} onClick={handleAddDelivery}>
+ Добавить логистику
</button>
</td>
</tr>
);
}
@@ -0,0 +1,43 @@
import * as React from 'react';
import { IconButton, Alert, Spinner } from '@tnsu/ui-kit';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
comment?: string;
decodedComment?: string;
isLoading: boolean;
error?: string;
onDelete: () => void;
}
// Блок «Комментарий ИИ» (Шаг 3): показывается только если есть комментарий, идёт загрузка или ошибка.
export const AiCommentBlock: React.FC<Props> = ({ comment, decodedComment, isLoading, error, onDelete }) => {
if (!comment && !isLoading && !error) return null;
return (
<div className={styles.aiCommentBlock}>
<div className={styles.aiCommentTitle}>
Комментарий ИИ
{comment && !isLoading && (
<IconButton
icon="trash"
title="Удалить комментарий"
className={styles.aiCommentDeleteBtn}
onClick={onDelete}
/>
)}
</div>
{isLoading && (
<div className={styles.aiCommentBody}>
<Spinner size="small" label="Генерация комментария..." />
</div>
)}
{error && (
<div className={styles.aiCommentBody}>
<Alert variant="err">{error}</Alert>
</div>
)}
{comment && <p className={styles.aiCommentText}>{decodedComment}</p>}
</div>
);
};
@@ -0,0 +1,230 @@
import * as React from 'react';
import { CurrencyRate } from '../types/domain';
import { Step3Matrix } from './useStep3Matrix';
import { MatrixRowView } from './MatrixRowView';
import { ExtraPositionRow } from './ExtraPositionRow';
import { AddExtraRow } from './AddExtraRow';
import { SUPPLIER_STATUS_OPTIONS, getSubCols, formatKzt } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss';
const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во
interface Props {
matrix: Step3Matrix;
currencyRates: CurrencyRate[];
isRestrictedView: boolean;
}
// Таблица сравнения целиком: шапка (фикс-колонки + заголовки поставщиков) + строки позиций
// (MatrixRowView/ExtraPositionRow) + итоговые строки + строка добавления (AddExtraRow).
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView }: Props): JSX.Element {
const {
isReadOnly, suppliersWithOffer, supplierCurrencies, selectedSupplierId, recommendedSupplierId,
supplierStatuses, nameColWidth, regularPositions, extraPositionsRows, allPositions, computedOffers,
setSelectedSupplierId, handleDeleteSupplier, handleStatusChange, handleNameColResizeMouseDown,
} = matrix;
const totalColSpan = FIXED_COLS + suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0);
return (
<div className={styles.tableScrollWrapper}>
<table className={styles.ctTable}>
<thead>
{/* Row 1: fixed headers (rowSpan=2) + supplier group headers (colSpan varies by currency) */}
<tr className={styles.headerRow1}>
<th rowSpan={2} className={styles.colPosition}>Наименование</th>
<th rowSpan={2} className={styles.colUnit}>Ед. изм.</th>
<th rowSpan={2} className={styles.colQty}>Кол-во</th>
{suppliersWithOffer.map(s => {
const currency = supplierCurrencies[s.id] ?? 'KZT';
const subCols = getSubCols(currency, isRestrictedView);
const isRec = s.id === recommendedSupplierId;
const isSel = s.id === selectedSupplierId;
return (
<th
key={s.id}
colSpan={subCols}
className={[
styles.supplierGroupHeader,
isRec ? styles.colRecommended : '',
isSel ? styles.colSelected : '',
].filter(Boolean).join(' ')}
>
<div className={styles.supplierHeader}>
<span className={styles.supplierName}>{s.name}</span>
{isRec && <span className={styles.recommendedBadge}>Рекомендуется</span>}
{s.isNoOffer && <span className={styles.noOfferTag}>Без КП</span>}
{currency !== 'KZT' && <span className={styles.currencyTag}>{currency}</span>}
{!isReadOnly ? (
<select
className={styles.statusInput}
value={supplierStatuses[s.id] ?? ''}
onChange={e => handleStatusChange(s.id, e.target.value)}
aria-label={`Статус поставщика ${s.name}`}
>
<option value="">Статус…</option>
{SUPPLIER_STATUS_OPTIONS.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
) : (
supplierStatuses[s.id] ? <span className={styles.statusBadge}>{supplierStatuses[s.id]}</span> : undefined
)}
<div className={styles.supplierActions}>
{!isReadOnly ? (
<>
<button
className={[styles.selectSupplierBtn, isSel ? styles.selectSupplierBtnActive : ''].filter(Boolean).join(' ')}
onClick={() => setSelectedSupplierId(s.id)}
aria-pressed={isSel}
>
{isSel ? 'Выбран' : 'Выбрать'}
</button>
<button
className={styles.deleteSupplierBtn}
onClick={() => handleDeleteSupplier(s.id, s.isExtraSupplier)}
title="Удалить потенциального поставщика"
>×</button>
</>
) : (
isSel && <span className={styles.selectedBadge}>Выбран</span>
)}
</div>
</div>
</th>
);
})}
</tr>
{/* Row 2: sub-headers — 4 cols for KZT, 6 cols for foreign currency */}
<tr className={styles.headerRow2}>
{suppliersWithOffer.map(s => {
const currency = supplierCurrencies[s.id] ?? 'KZT';
const isForeign = currency !== 'KZT';
const isSel = s.id === selectedSupplierId;
const selCls = isSel ? styles.colSelected : '';
return (
<React.Fragment key={s.id}>
<th className={[styles.subHeader, styles.subHeaderName, selCls].filter(Boolean).join(' ')} style={{ width: nameColWidth, minWidth: nameColWidth }}>
Наименование ТМЦ
<span className={styles.colResizeHandle} onMouseDown={handleNameColResizeMouseDown} />
</th>
<th className={[styles.subHeader, selCls].filter(Boolean).join(' ')}>Ед. изм.</th>
<th className={[styles.subHeader, selCls].filter(Boolean).join(' ')}>Кол-во</th>
{!isRestrictedView && isForeign && (
<>
<th className={[styles.subHeader, selCls].filter(Boolean).join(' ')}>Цена, {currency}</th>
<th className={[styles.subHeader, selCls].filter(Boolean).join(' ')}>Сумма, {currency}</th>
</>
)}
{!isRestrictedView && <th className={[styles.subHeader, selCls].filter(Boolean).join(' ')}>Цена, ₸</th>}
{!isRestrictedView && <th className={[styles.subHeader, selCls].filter(Boolean).join(' ')}>Сумма, ₸</th>}
</React.Fragment>
);
})}
</tr>
</thead>
<tbody>
{/* Regular positions — with bundle (комплект) support: multiple rows per position */}
{regularPositions.map((pos, posIndex) => (
<MatrixRowView
key={pos.id}
pos={pos}
posIndex={posIndex}
matrix={matrix}
currencyRates={currencyRates}
isRestrictedView={isRestrictedView}
/>
))}
{/* Сумма потребностей */}
<tr className={styles.subtotalRow}>
<td colSpan={FIXED_COLS} className={styles.subtotalLabel}><strong>Итого, ₸</strong></td>
{suppliersWithOffer.map(s => {
const currency = supplierCurrencies[s.id] ?? 'KZT';
const subCols = getSubCols(currency, isRestrictedView);
const isSel = s.id === selectedSupplierId;
const total = regularPositions.reduce((sum, pos) => {
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
return sum + posOffers.reduce((s2, o) => s2 + (o.totalWithExtraKzt ?? 0), 0);
}, 0);
return (
<React.Fragment key={s.id}>
{isRestrictedView
? <td colSpan={subCols} className={isSel ? styles.colSelected : undefined} />
: <><td colSpan={subCols - 1} className={isSel ? styles.colSelected : undefined} /><td className={[styles.subtotalCell, isSel ? styles.colSelected : ''].filter(Boolean).join(' ')}><strong>{formatKzt(total)}</strong></td></>
}
</React.Fragment>
);
})}
</tr>
{/* Доп. расходы positions — single row each (no bundle support needed) */}
{extraPositionsRows.map((pos, posIndex) => (
<ExtraPositionRow
key={pos.id}
pos={pos}
posIndex={posIndex}
regularPositionsCount={regularPositions.length}
matrix={matrix}
currencyRates={currencyRates}
isRestrictedView={isRestrictedView}
/>
))}
{/* Сумма доп. расходов */}
{extraPositionsRows.length > 0 && (
<tr className={styles.subtotalRow}>
<td colSpan={FIXED_COLS} className={styles.subtotalLabel}><strong>Сумма доп. расходов, ₸</strong></td>
{suppliersWithOffer.map(s => {
const currency = supplierCurrencies[s.id] ?? 'KZT';
const subCols = getSubCols(currency, isRestrictedView);
const isSel = s.id === selectedSupplierId;
const total = extraPositionsRows.reduce((sum, pos) => {
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
return sum + posOffers.reduce((s2, o) => s2 + (o.totalWithExtraKzt ?? 0), 0);
}, 0);
return (
<React.Fragment key={s.id}>
{isRestrictedView
? <td colSpan={subCols} className={isSel ? styles.colSelected : undefined} />
: <><td colSpan={subCols - 1} className={isSel ? styles.colSelected : undefined} /><td className={[styles.subtotalCell, isSel ? styles.colSelected : ''].filter(Boolean).join(' ')}><strong>{formatKzt(total)}</strong></td></>
}
</React.Fragment>
);
})}
</tr>
)}
{/* Общая сумма */}
<tr className={styles.grandTotalRow}>
<td colSpan={FIXED_COLS}><strong>Общая сумма, ₸</strong></td>
{suppliersWithOffer.map(s => {
const currency = supplierCurrencies[s.id] ?? 'KZT';
const subCols = getSubCols(currency, isRestrictedView);
const total = allPositions.reduce((sum, pos) => {
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
return sum + posOffers.reduce((s2, o) => s2 + (o.totalWithExtraKzt ?? 0), 0);
}, 0);
const isRec = s.id === recommendedSupplierId;
const isSel = s.id === selectedSupplierId;
return (
<React.Fragment key={s.id}>
{isRestrictedView
? <td colSpan={subCols} className={isSel ? styles.colSelected : undefined} />
: <><td colSpan={subCols - 1} className={isSel ? styles.colSelected : undefined} /><td className={[styles.grandTotalCell, isRec && !isSel ? styles.grandTotalRecommended : '', isSel ? styles.colSelected : ''].filter(Boolean).join(' ')}><strong>{formatKzt(total)}</strong></td></>
}
</React.Fragment>
);
})}
</tr>
{/* Inline add-position row */}
<AddExtraRow matrix={matrix} isRestrictedView={isRestrictedView} colSpan={totalColSpan} />
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,141 @@
import * as React from 'react';
import { Button, IconButton, TextInput, TextArea, Icon } from '@tnsu/ui-kit';
import { Step3Matrix } from './useStep3Matrix';
import { CriteriaEntry } from './matrixTypes';
import { DELIVERY_TYPE_OPTIONS } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
matrix: Step3Matrix;
}
// Блок «Дополнительная информация» (условия оплаты/поставки/сроки/сертификаты + произвольные условия).
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
export function CriteriaTable({ matrix }: Props): JSX.Element | null {
const {
isReadOnly, suppliersWithOffer, criteriaMap, customCriteria, newCriteriaLabel,
setNewCriteriaLabel, updateCriteriaValue, updateCustomCriteriaValue,
handleAddCustomCriteria, handleRemoveCustomCriteria,
} = matrix;
const fixedRows: Array<{ key: keyof CriteriaEntry; label: string }> = [
{ key: 'paymentType', label: 'Условия оплаты' },
{ key: 'deliveryType', label: 'Условия поставки' },
{ key: 'deadline', label: 'Сроки изготовления и доставки' },
{ key: 'sertificates', label: 'Сертификаты' },
];
const hasAnyData = fixedRows.some(row => suppliersWithOffer.some(s => criteriaMap[s.id]?.[row.key]?.trim()))
|| customCriteria.length > 0;
if (!isReadOnly && suppliersWithOffer.length === 0) return null;
if (isReadOnly && !hasAnyData) return null;
return (
<div className={styles.criteriaSection}>
<h4 className={styles.criteriaSectionTitle}>Дополнительная информация</h4>
<div className={styles.criteriaTableWrapper}>
<table className={styles.criteriaTable}>
<thead>
<tr>
<th className={styles.criteriaLabelHeader} />
{suppliersWithOffer.map(s => (
<th key={s.id} className={styles.criteriaSupplierHeader}>{s.name}</th>
))}
</tr>
</thead>
<tbody>
{fixedRows.map(row => (
<tr key={row.key}>
<td className={styles.criteriaLabel}>{row.label}</td>
{suppliersWithOffer.map(s => (
<td key={s.id} className={styles.criteriaValue}>
{!isReadOnly ? (
row.key === 'deliveryType' ? (
(criteriaMap[s.id]?.deliveryType ?? '').trim() ? (
// Уже заполнено (в т.ч. ИИ) — показываем как текст, без селекта.
criteriaMap[s.id]?.deliveryType
) : (
// Пусто — даём выбрать условие поставки из списка.
<select
className={styles.criteriaSelect}
value=""
onChange={e => updateCriteriaValue(s.id, 'deliveryType', e.target.value)}
aria-label={`${row.label} — ${s.name}`}
>
<option value="">—</option>
{DELIVERY_TYPE_OPTIONS.map(o => (
<option key={o.code} value={o.code}>{o.label}</option>
))}
</select>
)
) : (
<div className={styles.criteriaInput}>
<TextArea
value={criteriaMap[s.id]?.[row.key] ?? ''}
onChange={val => updateCriteriaValue(s.id, row.key, val)}
placeholder="—"
borderless
rows={1}
ariaLabel={`${row.label} — ${s.name}`}
/>
</div>
)
) : (
criteriaMap[s.id]?.[row.key] || '—'
)}
</td>
))}
</tr>
))}
{customCriteria.map(row => (
<tr key={row.id}>
<td className={styles.criteriaLabel}>
<div className={styles.criteriaCustomLabel}>
<span>{row.label}</span>
{!isReadOnly && (
<IconButton icon="trash" className={styles.criteriaRemoveBtn} onClick={() => handleRemoveCustomCriteria(row.id)} title="Удалить строку" />
)}
</div>
</td>
{suppliersWithOffer.map(s => (
<td key={s.id} className={styles.criteriaValue}>
{!isReadOnly ? (
<div className={styles.criteriaInput}>
<TextArea
value={row.values[s.id] ?? ''}
onChange={val => updateCustomCriteriaValue(row.id, s.id, val)}
placeholder="—"
borderless
rows={1}
ariaLabel={`${row.label} — ${s.name}`}
/>
</div>
) : (
row.values[s.id] || '—'
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{!isReadOnly && (
<div className={styles.criteriaAddPanel}>
<div className={styles.criteriaAddPanelInput}>
<TextInput
value={newCriteriaLabel}
onChange={setNewCriteriaLabel}
placeholder="Название нового условия"
ariaLabel="Название нового условия"
onKeyDown={e => { if (e.key === 'Enter') handleAddCustomCriteria(); }}
/>
</div>
<Button variant="secondary" onClick={handleAddCustomCriteria} disabled={!newCriteriaLabel.trim()}>
<Icon name="plus" size="sm" />
Добавить условие
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,218 @@
import * as React from 'react';
import { IconButton, TextInput } from '@tnsu/ui-kit';
import { CurrencyRate } from '../types/domain';
import { PositionRow } from './matrixTypes';
import { Step3Matrix } from './useStep3Matrix';
import { convertQuantity, unitsEquivalent } from '../utils/unitConversion';
import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
pos: PositionRow;
posIndex: number;
regularPositionsCount: number;
matrix: Step3Matrix;
currencyRates: CurrencyRate[];
isRestrictedView: boolean;
}
// Одна строка «Доп. расходы» / «Логистика» (без поддержки комплекта — один item на поставщика).
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix, currencyRates, isRestrictedView }: Props): JSX.Element {
const {
isReadOnly, edits, extraPositions, translateUnits, rawValues, deliveryKm, selectedSupplierId, nameColWidth,
suppliersWithOffer, supplierCurrencies, computedOffers, minTotalByPosition,
updateSupplierProductName, updateUnit, updateEdit, updatePriceKzt, handleDeliveryKmChange,
handleDeleteExtraPosition,
} = matrix;
const ep = extraPositions.find(e => e.id === pos.id);
const key0 = (s: { id: string }): string => buildOfferKey(s.id, pos.id);
const minTotal = minTotalByPosition.get(pos.id);
return (
<tr className={styles.extraRow}>
<td className={styles.positionNameCell}>
<span className={styles.positionIndex}>{regularPositionsCount + posIndex + 1}.</span>{' '}
{pos.materialName}
{!ep?.isDelivery && <span className={styles.extraBadge}>Доп. расходы</span>}
{!isReadOnly && (
<IconButton
icon="trash"
title="Удалить"
className={styles.deleteExtraBtn}
onClick={() => handleDeleteExtraPosition(pos.id)}
/>
)}
</td>
<td className={styles.unitCell}>{pos.unit}</td>
<td className={styles.qtyCell}>—</td>
{suppliersWithOffer.map(s => {
const key = key0(s);
const item = (edits[key] ?? [])[0];
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
const currency = supplierCurrencies[s.id] ?? 'KZT';
const isForeign = currency !== 'KZT';
const qty = item?.quantity;
const price = item?.pricePerUnit;
const rate = isForeign ? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1) : 1;
const totalInCurrency = (price ?? 0) * (qty ?? 0);
const priceKzt = (price ?? 0) * rate;
const priceKztEffective = item?.priceKztOverride !== undefined ? item.priceKztOverride : priceKzt;
const kztTotal = posOffers.reduce((sum, o) => sum + (o.totalWithExtraKzt ?? 0), 0);
const isMin = minTotal !== undefined && kztTotal === minTotal && kztTotal > 0;
const isSel = s.id === selectedSupplierId;
const selCls = isSel ? styles.colSelected : '';
// Перевод в ед. изм. заявки (тумблер) — как в обычных позициях.
const reqUnit = pos.unit;
const supUnit = item?.unit || pos.unit;
const tRatio = translateUnits && !unitsEquivalent(supUnit, reqUnit) ? convertQuantity(1, supUnit, reqUnit) : null;
const cannotTranslate = translateUnits && !unitsEquivalent(supUnit, reqUnit) && tRatio === null;
const dispUnit = tRatio !== null ? reqUnit : (item?.unit || pos.unit);
const dispQty = (tRatio !== null && qty !== undefined) ? qty * tRatio : qty;
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
const showInput = !isReadOnly && !translateUnits;
return (
<React.Fragment key={s.id}>
<td className={[styles.dataCell, styles.dataCellName, selCls].filter(Boolean).join(' ')} style={{ width: nameColWidth, minWidth: nameColWidth }}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={item?.supplierProductName || pos.materialName}
onChange={val => updateSupplierProductName(s.id, pos.id, 0, val)}
borderless
ariaLabel={`Наименование ТМЦ ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{item?.supplierProductName || pos.materialName}</span>
)}
</td>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={item?.unit || pos.unit}
onChange={val => updateUnit(s.id, pos.id, 0, val)}
borderless
ariaLabel={`Ед. изм. ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{(dispUnit || '—') + (cannotTranslate ? ' *' : '')}</span>
)}
</td>
<td className={[styles.dataCell, ep?.isDelivery ? styles.deliveryQtyCell : '', selCls].filter(Boolean).join(' ')}>
{ep?.isDelivery ? (
<div className={styles.deliveryQtyFlex}>
<div className={styles.deliveryQtyBox}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={rawValues[`${s.id}::${pos.id}::0::quantity`] ?? (qty !== undefined ? String(qty) : '')}
onChange={val => updateEdit(s.id, pos.id, 0, 'quantity', val)}
placeholder="рейсы"
borderless
ariaLabel={`Кол-во рейсов ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{dispQty !== undefined ? formatQty(dispQty) : '—'}</span>
)}
</div>
<div className={styles.deliveryKmBox}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
type="number"
value={deliveryKm[`${pos.id}::${s.id}`] !== undefined ? String(deliveryKm[`${pos.id}::${s.id}`]) : ''}
onChange={val => handleDeliveryKmChange(pos.id, s.id, val)}
placeholder="—"
borderless
ariaLabel={`Километраж ${s.name}`}
/>
</div>
) : (
<span className={styles.computedValue}>{deliveryKm[`${pos.id}::${s.id}`] ?? '—'}</span>
)}
<span className={styles.kmSuffix}>км</span>
</div>
</div>
) : (
showInput ? (
<div className={styles.offerInput}>
<TextInput
value={rawValues[`${s.id}::${pos.id}::0::quantity`] ?? (qty !== undefined ? String(qty) : '')}
onChange={val => updateEdit(s.id, pos.id, 0, 'quantity', val)}
placeholder="—"
borderless
ariaLabel={`Кол-во ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{dispQty !== undefined ? formatQty(dispQty) : '—'}</span>
)
)}
</td>
{isForeign && (
<>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={rawValues[`${s.id}::${pos.id}::0::pricePerUnit`] ?? (price !== undefined ? String(price) : '')}
onChange={val => updateEdit(s.id, pos.id, 0, 'pricePerUnit', val)}
placeholder="0"
borderless
ariaLabel={`Цена ${currency} ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{dispPrice ? formatNum(dispPrice) : '—'}</span>
)}
</td>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
<span className={styles.totalValue}>{totalInCurrency > 0 ? formatNum(totalInCurrency) : '—'}</span>
</td>
</>
)}
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={
isForeign
? (rawValues[`${s.id}::${pos.id}::0::priceKzt`] ?? (priceKztEffective > 0 ? round2Str(priceKztEffective) : ''))
: (rawValues[`${s.id}::${pos.id}::0::pricePerUnit`] ?? (price !== undefined ? String(price) : ''))
}
onChange={val => isForeign
? updatePriceKzt(s.id, pos.id, 0, val)
: updateEdit(s.id, pos.id, 0, 'pricePerUnit', val)
}
placeholder="0"
borderless
ariaLabel={`Цена ₸ ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>
{isForeign ? (dispPriceKzt > 0 ? formatNum(dispPriceKzt) : '—') : (dispPrice ? formatNum(dispPrice) : '—')}
</span>
)}
</td>
<td className={[
styles.dataCell,
isMin ? styles.minPriceCell : '',
selCls,
].filter(Boolean).join(' ')}>
<span className={styles.totalValue}>{kztTotal > 0 ? formatKzt(kztTotal) : '—'}</span>
</td>
</React.Fragment>
);
})}
</tr>
);
}
@@ -0,0 +1,169 @@
import * as React from 'react';
import { Button, Alert, Spinner, Dialog, TextArea, TextInput } from '@tnsu/ui-kit';
import { ApprovalMemberStatus } from '../types/domain';
import { useStep3Approval } from './useStep3Approval';
import styles from './Step3ComparisonTable.module.scss';
const APPROVAL_STATUS_LABELS: Record<ApprovalMemberStatus, string> = {
pending: 'На согласовании',
recommends: 'Рекомендую',
approved: 'Согласовано',
rejected: 'Отклонено',
skipped: 'Пропущено',
};
const APPROVAL_STATUS_STYLE: Record<ApprovalMemberStatus, { bg: string; color: string }> = {
pending: { bg: '#f3f2f1', color: '#605e5c' },
recommends: { bg: '#dff6dd', color: '#107c10' },
approved: { bg: '#dff6dd', color: '#107c10' },
rejected: { bg: '#fde7e9', color: '#a4262c' },
skipped: { bg: '#edebe9', color: '#a19f9d' },
};
function formatApprovalDate(iso: string): string {
return new Date(iso).toLocaleString('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
interface Props {
approval: ReturnType<typeof useStep3Approval>;
isNotificationLink: boolean;
isReadOnly: boolean;
}
// Инлайн-цепочка согласования Шага 3 + диалоги (решение/делегат) + баннер уведомлений.
// Показывается только по Teams-ссылке (?ctid) в read-only. Логика — в useStep3Approval.
export const InlineApprovalChain: React.FC<Props> = ({ approval: a, isNotificationLink, isReadOnly }) => {
return (
<>
{a.notifyWarning && <Alert variant="warn">{a.notifyWarning}</Alert>}
{/* Цепочка согласования — только по Teams-ссылке (ctid); обычный reload → согласование на Шаге 4 */}
{isNotificationLink && isReadOnly && a.approvalChain.length > 0 && (
<div className={styles.approvalChainSection}>
<h4 className={styles.approvalChainTitle}>Цепочка согласования</h4>
<ol className={styles.approvalChainList}>
{a.approvalChain.map((member) => {
const st = APPROVAL_STATUS_STYLE[member.status];
return (
<li key={member.id} className={styles.approvalChainRow}>
<span className={styles.approvalChainOrder}>{member.order}</span>
<span className={styles.approvalChainName}>{member.userName}</span>
{member.role && <span className={styles.approvalChainRole}>{member.role}</span>}
<span className={styles.approvalChainStatusBadge} style={{ background: st.bg, color: st.color }}>
{APPROVAL_STATUS_LABELS[member.status]}
</span>
{member.comment && <span className={styles.approvalChainComment}>«{member.comment}»</span>}
{member.actionDate && <span className={styles.approvalChainDate}>{formatApprovalDate(member.actionDate)}</span>}
{a.isOnApproval && a.currentUserMember?.id === member.id && (
<div className={styles.approvalChainActions}>
{member.canAddApprover && (
<Button variant="secondary" onClick={() => a.openDelegate(member)}>
Добавить делегата
</Button>
)}
<Button variant="primary" onClick={() => a.openApprove(member)}>
Согласовать
</Button>
<Button variant="danger" onClick={() => a.openReject(member)}>
Отклонить
</Button>
</div>
)}
</li>
);
})}
</ol>
</div>
)}
{a.delegateDialog && (
<Dialog
open
title="Добавить делегата"
subText="Выбранный пользователь будет вставлен перед вами в цепочку согласования."
onDismiss={a.closeDelegate}
footer={
<>
<Button variant="secondary" onClick={a.closeDelegate} disabled={a.isDelegateSubmitting}>
Отмена
</Button>
<Button
variant="primary"
onClick={a.handleAddDelegate}
disabled={a.isDelegateSubmitting || !a.delegateDialog.selected}
>
{a.isDelegateSubmitting ? <Spinner size="small" /> : 'Добавить'}
</Button>
</>
}
>
<div className={styles.searchBox}>
<TextInput
placeholder="Поиск делегата (имя)…"
ariaLabel="Поиск делегата"
value={a.delegateDialog.query}
onChange={a.setDelegateQuery}
/>
{a.delegateDialog.suggestions.length > 0 && (
<ul className={styles.suggestions}>
{a.delegateDialog.suggestions.map((s) => (
<li key={s.spId} className={styles.suggestion} onClick={() => a.selectDelegate(s)}>
<span className={styles.suggestionName}>{s.displayName}</span>
<span className={styles.suggestionEmail}>{s.email}</span>
</li>
))}
</ul>
)}
</div>
{a.delegateError && (
<div className={styles.approvalDialogError}>
<Alert variant="err">{a.delegateError}</Alert>
</div>
)}
</Dialog>
)}
{a.actionDialog && (
<Dialog
open
title={a.actionDialog.action === 'approve' ? 'Согласование' : 'Отклонение'}
subText={
a.actionDialog.action === 'approve'
? 'Вы подтверждаете согласование СТ? Комментарий необязателен.'
: 'Укажите причину отклонения (обязательно).'
}
onDismiss={a.closeAction}
footer={
<>
<Button variant="secondary" onClick={a.closeAction} disabled={a.isApprovalSubmitting}>
Отмена
</Button>
<Button
variant={a.actionDialog.action === 'approve' ? 'primary' : 'danger'}
onClick={a.handleApprovalConfirm}
disabled={a.isApprovalSubmitting || (a.actionDialog.action === 'reject' && !a.actionDialog.comment.trim())}
>
{a.isApprovalSubmitting ? <Spinner size="small" /> : a.actionDialog.action === 'approve' ? 'Согласовать' : 'Отклонить'}
</Button>
</>
}
>
<TextArea
value={a.actionDialog.comment}
onChange={a.setActionComment}
rows={3}
ariaLabel="Комментарий"
placeholder={a.actionDialog.action === 'reject' ? 'Укажите причину отклонения...' : 'Комментарий (необязательно)...'}
/>
{a.approvalError && (
<div className={styles.approvalDialogError}>
<Alert variant="err">{a.approvalError}</Alert>
</div>
)}
</Dialog>
)}
</>
);
};
@@ -0,0 +1,240 @@
import * as React from 'react';
import { TextInput } from '@tnsu/ui-kit';
import { CurrencyRate } from '../types/domain';
import { PositionRow } from './matrixTypes';
import { Step3Matrix } from './useStep3Matrix';
import { convertQuantity, unitsEquivalent } from '../utils/unitConversion';
import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
pos: PositionRow;
posIndex: number;
matrix: Step3Matrix;
currencyRates: CurrencyRate[];
isRestrictedView: boolean;
}
// Одна обычная позиция матрицы: строка(и) с поддержкой комплекта (bundle) + подытог по позиции при
// комплекте. Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrictedView }: Props): JSX.Element {
const {
isReadOnly, edits, translateUnits, rawValues, posQtyOverrides, selectedSupplierId, nameColWidth,
suppliersWithOffer, supplierCurrencies, minTotalByPosition,
updatePosQty, updateSupplierProductName, updateUnit, updateEdit, updatePriceKzt,
removeBundleItem, addBundleItem,
} = matrix;
const maxSubRows = Math.max(1, ...suppliersWithOffer.map(s => {
const items = edits[buildOfferKey(s.id, pos.id)];
return items ? items.length : 1;
}));
const hasBundle = maxSubRows > 1;
const posRowSpan = hasBundle ? maxSubRows + 1 : maxSubRows;
const minTotal = minTotalByPosition.get(pos.id);
const rowEls: JSX.Element[] = [];
for (let rowIdx = 0; rowIdx < maxSubRows; rowIdx++) {
rowEls.push(
<tr key={rowIdx} className={[styles.positionRow, rowIdx > 0 ? styles.bundleSubRow : ''].filter(Boolean).join(' ')}>
{rowIdx === 0 && (
<>
<td className={styles.positionNameCell} rowSpan={posRowSpan}>
<span className={styles.positionIndex}>{posIndex + 1}.</span>{' '}{pos.materialName}
</td>
<td className={styles.unitCell} rowSpan={posRowSpan}>{pos.unit}</td>
<td className={styles.qtyCell} rowSpan={posRowSpan}>
{!isReadOnly ? (
<div className={styles.qtyInput}>
<TextInput
value={posQtyOverrides[pos.id] ?? String(pos.quantity)}
onChange={val => updatePosQty(pos.id, val)}
borderless
ariaLabel={`Кол-во ${pos.materialName}`}
/>
</div>
) : (
posQtyOverrides[pos.id] !== undefined ? parseFloat(posQtyOverrides[pos.id]) : pos.quantity
)}
</td>
</>
)}
{suppliersWithOffer.map(s => {
const key = buildOfferKey(s.id, pos.id);
const items = edits[key] ?? [{ supplierId: s.id, positionId: pos.id, itemIndex: 0, unit: pos.unit, supplierProductName: pos.materialName, quantity: pos.quantity || undefined }];
const item = items[rowIdx];
const currency = supplierCurrencies[s.id] ?? 'KZT';
const isForeign = currency !== 'KZT';
const isSel = s.id === selectedSupplierId;
const selCls = isSel ? styles.colSelected : '';
const isLastItemRow = rowIdx === items.length - 1;
if (!item) {
return <td key={s.id} colSpan={getSubCols(currency, isRestrictedView)} className={[styles.dataCell, selCls].filter(Boolean).join(' ')} />;
}
const rate = isForeign ? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1) : 1;
const qty = item.quantity ?? ((s.isNoOffer || s.isExtraSupplier) && pos.quantity ? pos.quantity : undefined);
const price = item.pricePerUnit;
const totalInCurrency = (price ?? 0) * (qty ?? 0);
const priceKzt = (price ?? 0) * rate;
const priceKztEffective = item.priceKztOverride !== undefined ? item.priceKztOverride : priceKzt;
const kztTotal = priceKztEffective * (qty ?? 0);
// Перевод в ед. изм. заявки (тумблер). tRatio = коэффициент кол-ва;
// null — единицы совпадают или пересчёт невозможен.
const reqUnit = pos.unit;
const supUnit = item.unit || pos.unit;
const tRatio = translateUnits && !unitsEquivalent(supUnit, reqUnit) ? convertQuantity(1, supUnit, reqUnit) : null;
const cannotTranslate = translateUnits && !unitsEquivalent(supUnit, reqUnit) && tRatio === null;
const dispUnit = tRatio !== null ? reqUnit : (item.unit || pos.unit);
const dispQty = (tRatio !== null && qty !== undefined) ? qty * tRatio : qty;
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
const showInput = !isReadOnly && !translateUnits;
return (
<React.Fragment key={s.id}>
<td className={[styles.dataCell, styles.dataCellName, selCls].filter(Boolean).join(' ')} style={{ width: nameColWidth, minWidth: nameColWidth }}>
<div className={styles.bundleNameCell}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={item.supplierProductName || pos.materialName}
onChange={val => updateSupplierProductName(s.id, pos.id, rowIdx, val)}
borderless
ariaLabel={`Наименование ТМЦ ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{item.supplierProductName || pos.materialName}</span>
)}
{showInput && (
<div className={styles.bundleBtns}>
{items.length > 1 && (
<button className={styles.bundleRemoveBtn} onClick={() => removeBundleItem(s.id, pos.id, rowIdx)} title="Удалить строку">×</button>
)}
{isLastItemRow && (
<button className={styles.bundleAddBtn} onClick={() => addBundleItem(s.id, pos.id)} title="Добавить строку комплекта">+</button>
)}
</div>
)}
</div>
</td>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={item.unit || pos.unit}
onChange={val => updateUnit(s.id, pos.id, rowIdx, val)}
borderless
ariaLabel={`Ед. изм. ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{(dispUnit || '—') + (cannotTranslate ? ' *' : '')}</span>
)}
</td>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={rawValues[`${s.id}::${pos.id}::${rowIdx}::quantity`] ?? (qty !== undefined ? String(qty) : '')}
onChange={val => updateEdit(s.id, pos.id, rowIdx, 'quantity', val)}
placeholder="—"
borderless
ariaLabel={`Кол-во ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{dispQty !== undefined ? formatQty(dispQty) : '—'}</span>
)}
</td>
{!isRestrictedView && isForeign && (
<>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={rawValues[`${s.id}::${pos.id}::${rowIdx}::pricePerUnit`] ?? (price !== undefined ? String(price) : '')}
onChange={val => updateEdit(s.id, pos.id, rowIdx, 'pricePerUnit', val)}
placeholder="0"
borderless
ariaLabel={`Цена ${currency} ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{dispPrice ? formatNum(dispPrice) : '—'}</span>
)}
</td>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
<span className={styles.totalValue}>{totalInCurrency > 0 ? formatNum(totalInCurrency) : '—'}</span>
</td>
</>
)}
{!isRestrictedView && <td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
value={
isForeign
? (rawValues[`${s.id}::${pos.id}::${rowIdx}::priceKzt`] ?? (priceKztEffective > 0 ? round2Str(priceKztEffective) : ''))
: (rawValues[`${s.id}::${pos.id}::${rowIdx}::pricePerUnit`] ?? (price !== undefined ? String(price) : ''))
}
onChange={val => isForeign
? updatePriceKzt(s.id, pos.id, rowIdx, val)
: updateEdit(s.id, pos.id, rowIdx, 'pricePerUnit', val)
}
placeholder="0"
borderless
ariaLabel={`Цена ₸ ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>
{isForeign ? (dispPriceKzt > 0 ? formatNum(dispPriceKzt) : '—') : (dispPrice ? formatNum(dispPrice) : '—')}
</span>
)}
</td>}
{!isRestrictedView && <td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
<span className={styles.totalValue}>{kztTotal > 0 ? formatKzt(kztTotal) : '—'}</span>
</td>}
</React.Fragment>
);
})}
</tr>
);
}
// Bundle subtotal row — shows position total per supplier when there are multiple items
if (hasBundle) {
rowEls.push(
<tr key="subtotal" className={styles.bundleSubtotalRow}>
{suppliersWithOffer.map(s => {
const key = buildOfferKey(s.id, pos.id);
const items = edits[key] ?? [];
const currency = supplierCurrencies[s.id] ?? 'KZT';
const isForeign = currency !== 'KZT';
const isSel = s.id === selectedSupplierId;
const selCls = isSel ? styles.colSelected : '';
const subCols = getSubCols(currency, isRestrictedView);
const rate = isForeign ? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1) : 1;
const totalKzt = items.reduce((sum, it) => sum + (it.pricePerUnit ?? 0) * rate * (it.quantity ?? 0), 0);
const isMin = minTotal !== undefined && totalKzt === minTotal && totalKzt > 0;
return (
<React.Fragment key={s.id}>
<td colSpan={isRestrictedView ? subCols : subCols - 1} className={[styles.bundleSubtotalCell, selCls].filter(Boolean).join(' ')}>
<span className={styles.bundleSubtotalLabel}>Итого по позиции:</span>
</td>
{!isRestrictedView && <td className={[styles.bundleSubtotalCell, isMin ? styles.minPriceCell : '', selCls].filter(Boolean).join(' ')}>
<span className={styles.totalValue}>{totalKzt > 0 ? formatKzt(totalKzt) : '—'}</span>
</td>}
</React.Fragment>
);
})}
</tr>
);
}
return <React.Fragment>{rowEls}</React.Fragment>;
}
@@ -0,0 +1,207 @@
import * as React from 'react';
import { Button, Alert, TextInput, Toggle, Spinner, Dialog, Icon } from '@tnsu/ui-kit';
import { FormStep } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import styles from './Step3ComparisonTable.module.scss';
import { useStep3Approval } from './useStep3Approval';
import { InlineApprovalChain } from './InlineApprovalChain';
import { useAiComment } from './useAiComment';
import { AiCommentBlock } from './AiCommentBlock';
import { useStep3Matrix } from './useStep3Matrix';
import { ComparisonMatrix } from './ComparisonMatrix';
import { CriteriaTable } from './CriteriaTable';
interface Props {
service: CtService;
formData: CTFormData;
userDisplayName: string;
isNotificationLink: boolean;
isRestrictedView?: boolean;
onSave: (partial: Partial<CTFormData>) => void;
onNext: () => void;
onPrev: () => void;
onStepComplete: (step: FormStep) => void;
}
export const Step3ComparisonTable: React.FC<Props> = ({
service,
formData,
userDisplayName,
isNotificationLink,
isRestrictedView = false,
onSave,
onNext,
onPrev,
onStepComplete,
}) => {
const { selectedPositions, currencyRates, ct } = formData;
// Ядро матрицы (стейт/рефы/эффекты/мемо/хендлеры/handleSave) вынесено в хук; результат передаём
// как единый проп `matrix` в под-компоненты рендера (ComparisonMatrix/CriteriaTable/AddExtraRow),
// здесь дополнительно достаём то, что нужно контейнеру напрямую (тулбары, AI-payload, футер).
const matrix = useStep3Matrix({ service, formData, onSave, onNext, onStepComplete });
const {
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
handleAddSupplier, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave,
extraPositions, suppliersWithOffer, allPositions, computedOffers, selectedSupplierId,
} = matrix;
// Согласование (инлайн-цепочка Teams-ссылки) вынесено в хук.
const approval = useStep3Approval({ service, formData, userDisplayName, onSave, onStepComplete });
// AI-комментарий + печать PDF вынесены в хук.
const ai = useAiComment({
service,
ct,
buildPayload: () => ({
positions: [
...selectedPositions.map(p => ({ name: p.materialName, unit: p.unit, quantity: p.quantity })),
...extraPositions.map(p => ({ name: p.materialName, unit: p.unit || '—', quantity: 0 })),
],
suppliers: suppliersWithOffer
.map(s => ({
name: s.name,
grandTotal: allPositions.reduce((sum, pos) => {
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
return sum + posOffers.reduce((s2, o) => s2 + (o.totalWithExtraKzt ?? 0), 0);
}, 0),
}))
.filter(s => s.grandTotal > 0),
recommendedSupplier: suppliersWithOffer.find(s => s.id === selectedSupplierId)?.name,
}),
onPdfError: setSaveError,
});
if (selectedPositions.length === 0) {
return (
<div className={styles.root}>
<Alert variant="warn">Позиции не выбраны. Вернитесь на шаг 1.</Alert>
</div>
);
}
const currencyOptions = ['KZT', ...currencyRates.map(r => r.code)];
return (
<div className={styles.root}>
{/* Toolbar: add supplier — always visible above the table */}
{!isReadOnly && (
<div className={styles.supplierToolbar}>
<div className={styles.addInput}>
<TextInput
placeholder="Наименование потенциального поставщика"
value={newSupplierName}
onChange={setNewSupplierName}
ariaLabel="Наименование нового потенциального поставщика"
onKeyDown={e => { if (e.key === 'Enter') handleAddSupplier(); }}
/>
</div>
<select
className={styles.currencySelect}
value={newSupplierCurrency}
onChange={e => setNewSupplierCurrency(e.target.value)}
aria-label="Валюта нового потенциального поставщика"
>
{currencyOptions.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<Button variant="secondary" onClick={handleAddSupplier} disabled={!newSupplierName.trim()}>
<Icon name="plus" size="sm" />
Добавить потенциального поставщика
</Button>
</div>
)}
{/* Тумблер пересчёта единиц измерения поставщика в ед. изм. заявки */}
<div className={styles.translateToolbar}>
<label className={styles.translateToggleLabel}>
<Toggle checked={translateUnits} onChange={setTranslateUnits} />
<span>Перевести в ед. изм. заявки</span>
</label>
{translateUnits && (
<span className={styles.translateHint}>
кол-во и цена показаны в ед. изм. заявки; «*» — пересчёт невозможен (ред. недоступно)
</span>
)}
</div>
<ComparisonMatrix matrix={matrix} currencyRates={currencyRates} isRestrictedView={isRestrictedView} />
<CriteriaTable matrix={matrix} />
<AiCommentBlock
comment={ai.aiComment}
decodedComment={ai.decodedAiComment}
isLoading={ai.isAiLoading}
error={ai.aiError}
onDelete={ai.handleDeleteAiComment}
/>
<InlineApprovalChain approval={approval} isNotificationLink={isNotificationLink} isReadOnly={isReadOnly} />
<div className={styles.footer}>
<Button variant="secondary" onClick={onPrev} disabled={isReadOnly}>
<Icon name="arrow-left" size="sm" />
Назад
</Button>
<div className={styles.footerRight}>
{saveError && <Alert variant="err">{saveError}</Alert>}
<Button
variant="secondary"
onClick={ai.handleGeneratePdfReport}
disabled={ai.isPdfLoading || !ct || ct.status !== 'approved'}
title={ct?.status !== 'approved' ? 'Доступно после завершения согласования' : undefined}
>
{ai.isPdfLoading ? (
<Spinner size="small" />
) : (
<>
<Icon name="document" size="sm" />
Печать в PDF
</>
)}
</Button>
<Button
variant="primary"
onClick={() => (!ai.aiComment && !ai.isAiLoading) ? ai.setShowAiPromptDialog(true) : handleSave()}
disabled={isSaving || isReadOnly}
>
{isSaving ? (
<Spinner size="small" />
) : (
<>
<Icon name="chevron-right" size="sm" />
Сохранить и продолжить
</>
)}
</Button>
</div>
</div>
{ai.showAiPromptDialog && (
<Dialog
open
title="Комментарий от ИИ"
subText="Хотели бы вы получить комментарий от ИИ перед переходом на следующий шаг?"
onDismiss={() => ai.setShowAiPromptDialog(false)}
footer={
<>
<Button variant="secondary" onClick={() => { ai.setShowAiPromptDialog(false); void handleSave(); }}>
Пропустить
</Button>
<Button
variant="primary"
onClick={() => { ai.setShowAiPromptDialog(false); void ai.handleAiComment(); }}
>
Получить
</Button>
</>
}
/>
)}
</div>
);
};
@@ -0,0 +1,75 @@
import { SupplierOffer } from '../types/domain';
import { CellEdit } from './matrixTypes';
// Статус потенциального поставщика — фиксированный список для <select> в шапке (не обязателен).
export const SUPPLIER_STATUS_OPTIONS = [
'Завод-изготовитель',
'Производитель',
'Официальный дистрибьютор',
'Эксклюзивный дистрибьютор',
'Официальный дилер',
'Авторизованный партнер',
'Торговая компания',
];
// Условия поставки (Incoterms): в <select> показываем полное описание, храним короткий код.
// В режиме чтения показывается только код (аббревиатура).
export const DELIVERY_TYPE_OPTIONS: Array<{ code: string; label: string }> = [
{ code: 'EXW', label: 'Франко-завод (EXW) — самовывоз со склада поставщика' },
{ code: 'FCA', label: 'Франко-перевозчик (FCA) — передача товара перевозчику' },
{ code: 'DAP', label: 'Поставка до объекта/склада покупателя (DAP) — доставка силами поставщика' },
{ code: 'DDP', label: 'Поставка до объекта с оплатой всех расходов (DDP) — доставка силами поставщика с включением транспортных и таможенных расходов' },
];
// Foreign currency = 6 sub-cols (Ед. изм. | Кол-во | Цена_curr | Сумма_curr | Цена ₸ | Сумма ₸)
// KZT = 4 sub-cols (Ед. изм. | Кол-во | Цена ₸ | Сумма ₸)
// Restricted = 3 sub-cols (Наименование ТМЦ | Ед. изм. | Кол-во)
export function getSubCols(currency: string, restricted = false): number {
if (restricted) return 3;
return currency !== 'KZT' ? 7 : 5;
}
export function formatKzt(value: number): string {
if (value === 0) return '—';
return value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ₸';
}
export function formatNum(value: number): string {
if (value === 0) return '—';
return value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
// Кол-во для пересчитанной величины: до 6 знаков, без хвостовых нулей (0.061 → «0,061»).
export function formatQty(value: number): string {
return (Math.round(value * 1e6) / 1e6).toLocaleString('ru-RU', { maximumFractionDigits: 6 });
}
// Round to 2 decimals, dropping float tails (e.g. 275620.94999 → 275620.95) for input display.
export function round2Str(value: number): string {
return String(Math.round(value * 100) / 100);
}
export function computeOffer(draft: CellEdit, currencyCode: string, rate: number): SupplierOffer {
const pricePerUnit = draft.pricePerUnit ?? 0;
const quantity = draft.quantity ?? 0;
const pricePerUnitKzt = draft.priceKztOverride !== undefined ? draft.priceKztOverride : pricePerUnit * rate;
const totalPriceKzt = pricePerUnitKzt * quantity;
return {
id: '',
supplierId: draft.supplierId,
positionId: draft.positionId,
itemIndex: draft.itemIndex,
unit: draft.unit,
supplierPosition: draft.supplierProductName,
pricePerUnit,
quantity,
currencyCode,
pricePerUnitKzt,
totalPriceKzt,
totalWithExtraKzt: totalPriceKzt,
};
}
export function buildOfferKey(supplierId: string, positionId: string): string {
return `${supplierId}::${positionId}`;
}
@@ -0,0 +1,41 @@
// Типы матрицы сравнения (Шаг 3). Перенесено 1:1 из Step3ComparisonTable.
export interface ExtraSupplier {
id: string;
name: string;
currency: string;
spId?: number;
status?: string;
}
export interface ExtraPosition {
id: string;
materialName: string;
unit: string;
spId?: number;
isDelivery?: boolean;
}
export interface PositionRow {
id: string;
materialName: string;
unit: string;
quantity: number;
spId?: number;
isExtra?: boolean;
}
export type CellEdit = {
supplierId: string;
positionId: string;
itemIndex: number;
quantity?: number;
pricePerUnit?: number;
priceKztOverride?: number;
unit?: string;
supplierProductName?: string;
};
export type EditableOffers = Record<string, CellEdit[]>; // key = supplierId::positionId, array = bundle items
export type CriteriaEntry = { paymentType: string; deliveryType: string; deadline: string; sertificates: string };
@@ -0,0 +1,110 @@
import { useEffect, useMemo, useState } from 'react';
import { CT } from '../types/domain';
import { CtService } from '../../services/CtService';
// Комментарий ИИ приходит с HTML-сущностями (напр. &#58; вместо ":") — декодируем перед показом.
// textarea безопасно раскодирует сущности и не исполняет разметку (элемент detached, не в DOM).
function decodeHtmlEntities(input: string): string {
const el = document.createElement('textarea');
el.innerHTML = input;
return el.value;
}
export interface AiCommentPayload {
positions: Array<{ name: string; unit: string; quantity: number }>;
suppliers: Array<{ name: string; grandTotal: number }>;
recommendedSupplier?: string;
}
interface UseAiCommentArgs {
service: CtService;
ct?: CT;
buildPayload: () => AiCommentPayload;
/** Ошибка формирования PDF идёт в общий баннер сохранения Шага 3 (как в исходнике — тот же saveError). */
onPdfError: (message: string) => void;
}
// Логика блока «Комментарий ИИ» + печать PDF (Шаг 3): комментарий хранится в SP (шапка СТ, колонка
// comment), поэтому переживает переходы шагов. Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд).
export function useAiComment(args: UseAiCommentArgs) {
const { service, ct, buildPayload, onPdfError } = args;
const [aiComment, setAiComment] = useState<string | undefined>(undefined);
const decodedAiComment = useMemo(() => (aiComment ? decodeHtmlEntities(aiComment) : aiComment), [aiComment]);
const [isAiLoading, setIsAiLoading] = useState(false);
const [aiError, setAiError] = useState<string | undefined>(undefined);
const [isPdfLoading, setIsPdfLoading] = useState(false);
const [showAiPromptDialog, setShowAiPromptDialog] = useState(false);
// Load existing AI comment from SP on mount
useEffect(() => {
if (!ct?.id) return;
service.loadCtComment(ct.id)
.then(comment => { if (comment) setAiComment(comment); })
.catch(() => undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ct?.id]);
const handleGeneratePdfReport = async (): Promise<void> => {
if (!ct) return;
setIsPdfLoading(true);
try {
await service.generateCtReport(ct.id, 'pdf');
} catch (err) {
onPdfError((err as Error).message ?? 'Ошибка формирования PDF');
} finally {
setIsPdfLoading(false);
}
};
const handleDeleteAiComment = (): void => {
setAiComment(undefined);
if (ct?.id) service.updateCtComment(ct.id, '').catch(() => undefined);
};
const handleAiComment = async (): Promise<void> => {
if (!ct) return;
setIsAiLoading(true);
setAiError(undefined);
try {
const payload = buildPayload();
const flowResponse = await service.triggerAiComment({
ctId: ct.id,
ctNumber: ct.number,
projectName: ct.projectName,
...payload,
});
if (flowResponse) {
setAiComment(flowResponse);
service.updateCtComment(ct.id, flowResponse).catch(() => undefined);
} else {
// Поток не вернул тело — ждём пока он сам запишет в SP
await new Promise<void>(resolve => setTimeout(resolve, 5000));
const spComment = await service.loadCtComment(ct.id);
if (spComment) {
setAiComment(spComment);
} else {
setAiError('Поток не вернул комментарий. Попробуйте нажать кнопку ещё раз через несколько секунд.');
}
}
} catch (err) {
setAiError((err as Error).message ?? 'Ошибка получения комментария ИИ');
} finally {
setIsAiLoading(false);
}
};
return {
aiComment,
decodedAiComment,
isAiLoading,
aiError,
isPdfLoading,
showAiPromptDialog,
setShowAiPromptDialog,
handleGeneratePdfReport,
handleDeleteAiComment,
handleAiComment,
};
}
@@ -0,0 +1,221 @@
import { useRef, useState } from 'react';
import { ApprovalMember, ApprovalMemberStatus, FormStep } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
export type ApprovalAction = 'approve' | 'reject';
export interface UserSuggestion {
spId: number;
displayName: string;
email: string;
}
export interface ActionDialogState {
memberId: string;
memberUserId: string;
memberName: string;
action: ApprovalAction;
comment: string;
}
export interface DelegateDialogState {
memberId: string;
memberOrder: number;
spApprovalId: number;
query: string;
suggestions: UserSuggestion[];
selected?: UserSuggestion;
}
export interface UseStep3ApprovalArgs {
service: CtService;
formData: CTFormData;
userDisplayName: string;
onSave: (partial: Partial<CTFormData>) => void;
onStepComplete: (step: FormStep) => void;
}
// Логика инлайн-цепочки согласования на Шаге 3 (для согласующего, открывшего СТ по Teams-ссылке ?ctid=):
// голосование approve/reject + нотификации, добавление делегата. Работает поверх formData.approvalChain
// через onSave, клубок рефов матрицы не трогает. Перенесено 1:1 из Step3ComparisonTable.
export function useStep3Approval(args: UseStep3ApprovalArgs) {
const { service, formData, userDisplayName, onSave, onStepComplete } = args;
const { ct } = formData;
const [actionDialog, setActionDialog] = useState<ActionDialogState | null>(null);
const [isApprovalSubmitting, setIsApprovalSubmitting] = useState(false);
const [approvalError, setApprovalError] = useState<string | null>(null);
const [notifyWarning, setNotifyWarning] = useState<string | null>(null);
const [delegateDialog, setDelegateDialog] = useState<DelegateDialogState | null>(null);
const [isDelegateSubmitting, setIsDelegateSubmitting] = useState(false);
const [delegateError, setDelegateError] = useState<string | null>(null);
const delegateSearchTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const approvalChain = formData.approvalChain ?? [];
const isOnApproval = ct?.status === 'on_approval' || ct?.status === 'approved';
const currentUserMember = approvalChain.find(
(m) =>
m.userName === userDisplayName &&
m.status === 'pending' &&
m.role !== 'Инициатор' &&
approvalChain.filter((prev) => prev.order < m.order).every((prev) => prev.status !== 'pending'),
);
const handleApprovalConfirm = async (): Promise<void> => {
if (!actionDialog || !ct) return;
const { memberId, action, comment } = actionDialog;
if (action === 'reject' && !comment.trim()) return;
setIsApprovalSubmitting(true);
setApprovalError(null);
const apiStatus = action === 'approve' ? 'approved' : 'rejected';
const currentMember = approvalChain.find((m) => m.id === memberId);
try {
if (!currentMember?.spApprovalId) throw new Error('Не найден ID записи согласования');
await service.submitApprovalStep(currentMember.spApprovalId, apiStatus, comment.trim());
const notify = async (spId: number): Promise<void> => {
try {
await service.triggerApprovalNotification(spId);
} catch (e) {
console.error('[Step3] triggerApprovalNotification failed for spId', spId, e);
setNotifyWarning('Голос записан, но уведомление в Teams не удалось отправить. Проверьте Power Automate.');
}
};
if (action === 'approve') {
const updatedChain = approvalChain.map((m) =>
m.id === memberId
? { ...m, status: 'approved' as ApprovalMemberStatus, comment: comment.trim(), actionDate: new Date().toISOString() }
: m,
);
onSave({ approvalChain: updatedChain });
const nextPending = updatedChain.find((m) => m.status === 'pending');
if (nextPending?.spApprovalId) {
await notify(nextPending.spApprovalId);
}
const allApproved = updatedChain.every((m) => m.status === 'approved' || m.status === 'skipped' || m.status === 'recommends');
const initiator = updatedChain.find((m) => m.role === 'Инициатор');
if (allApproved) {
await service.updateCTStatus(ct.id, 'approved');
onSave({ ct: { ...ct, status: 'approved' } });
if (initiator?.spApprovalId) await notify(initiator.spApprovalId);
onStepComplete(4);
} else if (initiator?.spApprovalId) {
await notify(initiator.spApprovalId);
}
} else {
const othersToDelete = approvalChain.filter((m) => m.id !== memberId && m.status === 'pending' && m.spApprovalId);
if (othersToDelete.length > 0) {
await service.deleteApprovalMembers(othersToDelete.map((m) => m.spApprovalId!));
}
const finalChain = approvalChain
.filter((m) => m.status !== 'pending' || m.id === memberId)
.map((m) =>
m.id === memberId
? { ...m, status: 'rejected' as ApprovalMemberStatus, comment: comment.trim(), actionDate: new Date().toISOString() }
: m,
);
await service.updateCTStatus(ct.id, 'rejected');
onSave({ approvalChain: finalChain, ct: { ...ct, status: 'rejected' } });
const initiator = finalChain.find((m) => m.role === 'Инициатор');
if (initiator?.spApprovalId) await notify(initiator.spApprovalId);
}
setActionDialog(null);
} catch (err) {
setApprovalError((err as Error).message ?? 'Ошибка выполнения действия');
} finally {
setIsApprovalSubmitting(false);
}
};
const setDelegateQuery = (q: string): void => {
setDelegateDialog((prev) => (prev ? { ...prev, query: q, suggestions: [], selected: undefined } : prev));
if (delegateSearchTimer.current) {
clearTimeout(delegateSearchTimer.current);
}
if (q.trim().length < 2) return;
delegateSearchTimer.current = setTimeout(() => {
service
.searchUsers(q.trim())
.then((res) => setDelegateDialog((prev) => (prev ? { ...prev, suggestions: res } : prev)))
.catch(() => undefined);
}, 300);
};
const selectDelegate = (u: UserSuggestion): void =>
setDelegateDialog((prev) => (prev ? { ...prev, selected: u, query: u.displayName, suggestions: [] } : prev));
const handleAddDelegate = async (): Promise<void> => {
if (!delegateDialog || !ct || !delegateDialog.selected) return;
const delegateUserId = delegateDialog.selected.spId;
const delegateName = delegateDialog.selected.displayName;
setIsDelegateSubmitting(true);
setDelegateError(null);
try {
const newMember = await service.addApprovalDelegate(ct.id, delegateDialog.memberOrder, delegateUserId, delegateName);
const updatedChain = approvalChain
.map((m) => (m.order >= delegateDialog.memberOrder ? { ...m, order: m.order + 1 } : m))
.concat(newMember)
.sort((a, b) => a.order - b.order);
onSave({ approvalChain: updatedChain });
if (newMember.spApprovalId) {
try {
await service.triggerApprovalNotification(newMember.spApprovalId);
} catch {
setNotifyWarning('Делегат добавлен, но уведомление в Teams не удалось отправить. Проверьте Power Automate.');
}
}
setDelegateDialog(null);
} catch (err) {
setDelegateError((err as Error).message ?? 'Ошибка добавления делегата');
} finally {
setIsDelegateSubmitting(false);
}
};
// Удобные открыватели диалогов (view не дёргает сырые сеттеры).
const openApprove = (member: ApprovalMember): void =>
setActionDialog({ memberId: member.id, memberUserId: member.userId, memberName: member.userName, action: 'approve', comment: '' });
const openReject = (member: ApprovalMember): void =>
setActionDialog({ memberId: member.id, memberUserId: member.userId, memberName: member.userName, action: 'reject', comment: '' });
const setActionComment = (val: string): void =>
setActionDialog((prev) => (prev ? { ...prev, comment: val } : null));
const openDelegate = (member: ApprovalMember): void => {
setDelegateDialog({ memberId: member.id, memberOrder: member.order, spApprovalId: member.spApprovalId!, query: '', suggestions: [] });
setDelegateError(null);
};
return {
approvalChain,
isOnApproval,
currentUserMember,
notifyWarning,
setNotifyWarning,
// action dialog
actionDialog,
isApprovalSubmitting,
approvalError,
openApprove,
openReject,
setActionComment,
closeAction: () => setActionDialog(null),
handleApprovalConfirm,
// delegate dialog
delegateDialog,
setDelegateQuery,
selectDelegate,
isDelegateSubmitting,
delegateError,
openDelegate,
closeDelegate: () => setDelegateDialog(null),
handleAddDelegate,
};
}
@@ -0,0 +1,860 @@
import * as React from 'react';
import { useState, useMemo, useCallback, useRef } from 'react';
import { SupplierOffer, CurrencyRate, FormStep } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
import { buildOfferKey, computeOffer } from './matrixHelpers';
let extraIdCounter = 0;
export interface UseStep3MatrixArgs {
service: CtService;
formData: CTFormData;
onSave: (partial: Partial<CTFormData>) => void;
onNext: () => void;
onStepComplete: (step: FormStep) => void;
}
// Ядро матрицы сравнения (Шаг 3): весь стейт/рефы/эффекты/мемо/хендлеры + handleSave. Перенесено
// ВЕРБАТИМ из Step3ComparisonTable (§стайл-гайд: логика вне вёрстки). Клубок рефов сохраняет
// несохранённые правки при переходах шагов и F5 (unmount/beforeunload-persist).
export function useStep3Matrix(args: UseStep3MatrixArgs) {
const { service, formData, onSave, onNext, onStepComplete } = args;
const { selectedPositions, suppliers, offers, currencyRates, ct } = formData;
const isReadOnly = formData.approvalSent;
const [extraSuppliers, setExtraSuppliers] = useState<ExtraSupplier[]>(() => {
const persisted = formData.step3ExtraSuppliers ?? [];
persisted.forEach(es => {
const m = es.id.match(/^extra_sup_(\d+)$/);
if (m) extraIdCounter = Math.max(extraIdCounter, parseInt(m[1], 10));
});
return persisted;
});
const [supplierStatuses, setSupplierStatuses] = useState<Record<string, string>>(() => {
const m: Record<string, string> = {};
suppliers.forEach(s => { m[s.id] = s.status ?? ''; });
(formData.step3ExtraSuppliers ?? []).forEach(es => { m[es.id] = es.status ?? ''; });
return m;
});
const [extraPositions, setExtraPositions] = useState<ExtraPosition[]>(() => {
const persisted = formData.step3ExtraPositions ?? [];
persisted.forEach(ep => {
const m = ep.id.match(/^extra_pos_(\d+)$/);
if (m) extraIdCounter = Math.max(extraIdCounter, parseInt(m[1], 10));
});
return persisted;
});
const [newSupplierName, setNewSupplierName] = useState('');
const [newSupplierCurrency, setNewSupplierCurrency] = useState('KZT');
const [newPosName, setNewPosName] = useState('');
const [newPosUnit, setNewPosUnit] = useState('');
const [showAddPosRow, setShowAddPosRow] = useState(false);
const [deliveryKm, setDeliveryKm] = useState<Record<string, number>>(() => formData.step3DeliveryKm ?? {});
const [edits, setEdits] = useState<EditableOffers>(() => {
const allInitPositions = [
...selectedPositions,
...(formData.step3ExtraPositions ?? []),
];
const map: EditableOffers = {};
offers.forEach(o => {
const matchedPos = allInitPositions.find(p => p.id === o.positionId);
const key = buildOfferKey(o.supplierId, o.positionId);
const isForeignOffer = o.currencyCode && o.currencyCode !== 'KZT';
const rate = isForeignOffer
? (formData.currencyRates.find(r => r.code === o.currencyCode)?.rateToKzt ?? 1)
: 1;
const computedKzt = (o.pricePerUnit ?? 0) * rate;
const kztDiffers = isForeignOffer && o.pricePerUnitKzt !== undefined && Math.abs(o.pricePerUnitKzt - computedKzt) > 0.001;
const item: CellEdit = {
supplierId: o.supplierId,
positionId: o.positionId,
itemIndex: o.itemIndex ?? 0,
pricePerUnit: o.pricePerUnit,
priceKztOverride: kztDiffers ? o.pricePerUnitKzt : undefined,
quantity: o.quantity || undefined,
unit: o.unit || matchedPos?.unit,
supplierProductName: o.supplierPosition || matchedPos?.materialName,
};
const existing = map[key] ?? [];
existing.push(item);
map[key] = existing;
});
Object.keys(map).forEach(k => { map[k] = map[k].sort((a, b) => a.itemIndex - b.itemIndex); });
return map;
});
const [selectedSupplierId, setSelectedSupplierId] = useState<string | undefined>(
formData.recommendedSupplierId
);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined);
// Тумблер «Перевести в ед. изм. заявки»: переводит кол-во и цену поставщика в ед. заявки.
// При включении блоки поставщиков показываются как вычисленные значения (без редактирования).
const [translateUnits, setTranslateUnits] = useState(false);
const [criteriaMap, setCriteriaMap] = useState<Record<string, CriteriaEntry>>(() => formData.step3CriteriaEdits ?? {});
const [customCriteria, setCustomCriteria] = useState<Array<{ id: string; label: string; values: Record<string, string> }>>(
() => formData.step3CustomCriteria ?? []
);
const [newCriteriaLabel, setNewCriteriaLabel] = useState('');
const [rawValues, setRawValues] = useState<Record<string, string>>({});
const [nameColWidth, setNameColWidth] = useState(220);
const nameColWidthRef = useRef(220);
const [posQtyOverrides, setPosQtyOverrides] = useState<Record<string, string>>(() => {
const overrides = formData.step3PositionQtyOverrides ?? {};
const result: Record<string, string> = {};
Object.keys(overrides).forEach(id => { result[id] = String(overrides[id]); });
return result;
});
// Refs for preserving unsaved edits on step navigation (unmount without save)
const saveCompletedRef = useRef(false);
const editsRef = useRef<EditableOffers>(edits);
const offersRef = useRef<SupplierOffer[]>(offers);
const currencyRatesRef = useRef<CurrencyRate[]>(currencyRates);
const onSaveRef = useRef(onSave);
const computedOffersRef = useRef<SupplierOffer[]>([]);
const suppliersRef = useRef(suppliers);
const extraPositionsRef = useRef(extraPositions);
const extraSuppliersRef = useRef(extraSuppliers);
const selectedPositionsRef = useRef(selectedPositions);
const allPositionsRef = useRef<typeof allPositions>([]);
const posQtyOverridesRef = useRef(posQtyOverrides);
const criteriaMapRef = useRef(criteriaMap);
const customCriteriaRef = useRef(customCriteria);
// Load criteria from SP "Критерии" list by supplier SP IDs
React.useEffect(() => {
const supplierSpIds = suppliers
.filter(s => s.hasOffer && s.spId !== undefined)
.map(s => s.spId as number);
if (supplierSpIds.length === 0) return;
service.loadCriteria(supplierSpIds)
.then(rawCriteria => {
const spMap: Record<string, CriteriaEntry> = {};
rawCriteria.forEach(c => {
const sup = suppliers.find(s => s.spId === c.supplierSpId);
if (sup) spMap[sup.id] = { paymentType: c.paymentType, deliveryType: c.deliveryType, deadline: c.deadline, sertificates: c.sertificates };
});
// Merge SP data with any user edits from formData — user edits take priority
setCriteriaMap(prev => ({ ...spMap, ...prev }));
})
.catch(() => undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load existing offers from SP if React state is empty (e.g. after page navigation)
React.useEffect(() => {
if (offers.length > 0) return;
const initExtraPositions = formData.step3ExtraPositions ?? [];
const allLoadPositions = [...selectedPositions, ...initExtraPositions];
const positionSpIds = allLoadPositions
.map(p => p.spId)
.filter((id): id is number => id !== undefined);
if (positionSpIds.length === 0) return;
service.loadOffersForPositions(positionSpIds)
.then(rawOffers => {
if (!rawOffers.length) return;
const newEdits: EditableOffers = {};
rawOffers.forEach(raw => {
const pos = allLoadPositions.find(p => p.spId === raw.positionSpId);
const sup = suppliers.find(s => s.spId === raw.supplierSpId);
if (!pos || !sup) return;
const key = buildOfferKey(sup.id, pos.id);
const item: CellEdit = {
supplierId: sup.id,
positionId: pos.id,
itemIndex: raw.itemIndex,
pricePerUnit: raw.price,
quantity: raw.quantity || undefined,
unit: raw.unit || pos.unit,
supplierProductName: raw.supplierPosition || pos.materialName,
};
const existing = newEdits[key] ?? [];
existing.push(item);
newEdits[key] = existing;
});
Object.keys(newEdits).forEach(k => { newEdits[k] = newEdits[k].sort((a, b) => a.itemIndex - b.itemIndex); });
if (Object.keys(newEdits).length > 0) {
setEdits(prev => ({ ...newEdits, ...prev }));
}
})
.catch(() => undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Keep refs current so cleanup can access latest values after unmount
React.useEffect(() => { editsRef.current = edits; }, [edits]);
React.useEffect(() => { extraPositionsRef.current = extraPositions; }, [extraPositions]);
React.useEffect(() => { extraSuppliersRef.current = extraSuppliers; }, [extraSuppliers]);
React.useEffect(() => { offersRef.current = offers; }, [offers]);
React.useEffect(() => { currencyRatesRef.current = currencyRates; }, [currencyRates]);
React.useEffect(() => { onSaveRef.current = onSave; }, [onSave]);
React.useEffect(() => { suppliersRef.current = suppliers; }, [suppliers]);
React.useEffect(() => { selectedPositionsRef.current = selectedPositions; }, [selectedPositions]);
React.useEffect(() => { posQtyOverridesRef.current = posQtyOverrides; }, [posQtyOverrides]);
React.useEffect(() => { criteriaMapRef.current = criteriaMap; }, [criteriaMap]);
React.useEffect(() => { customCriteriaRef.current = customCriteria; }, [customCriteria]);
// On F5/hard reload: React state updates don't commit before unload, so write offers directly to sessionStorage
React.useEffect(() => {
const handleBeforeUnload = (): void => {
if (!ct?.id || saveCompletedRef.current) return;
const currentOffers = computedOffersRef.current;
if (currentOffers.length === 0) return;
const key = `ct_form_${ct.id}`;
try {
const raw = sessionStorage.getItem(key);
if (!raw) return;
const session = JSON.parse(raw);
// Preserve offerId and id from original offers — computeOffer() does not carry these fields.
// Match by React ID first, then fall back to SP ID (formData.offers may contain SP IDs after a prior save).
const prevOffers: SupplierOffer[] = session.formData.offers ?? [];
const merged = currentOffers.map((o: SupplierOffer) => {
const sup = suppliersRef.current.find(s => s.id === o.supplierId);
const pos = selectedPositionsRef.current.find(p => p.id === o.positionId);
const supSpId = sup?.spId !== undefined ? String(sup.spId) : undefined;
const posSpId = pos?.spId !== undefined ? String(pos.spId) : undefined;
const prev = prevOffers.find((p: SupplierOffer) =>
(p.supplierId === o.supplierId || (supSpId !== undefined && p.supplierId === supSpId)) &&
(p.positionId === o.positionId || (posSpId !== undefined && p.positionId === posSpId))
);
return prev ? { ...o, id: prev.id || o.id, offerId: prev.offerId } : o;
});
session.formData.offers = merged;
sessionStorage.setItem(key, JSON.stringify(session));
} catch { /* quota exceeded or private browsing */ }
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ct?.id]);
// On unmount without save: persist edits to formData.offers so they survive step navigation
React.useEffect(() => {
return () => {
if (saveCompletedRef.current) return;
const currentEdits = editsRef.current;
if (Object.keys(currentEdits).length === 0) return;
const currentOrigOffers = offersRef.current;
const currentRates = currencyRatesRef.current;
const editValues: CellEdit[] = Object.keys(currentEdits).reduce<CellEdit[]>((acc, k) => acc.concat(currentEdits[k]), []);
const updatedOffers: SupplierOffer[] = editValues.map((e: CellEdit) => {
const originals = currentOrigOffers.filter(o => o.supplierId === e.supplierId && o.positionId === e.positionId);
const original = originals.find(o => (o.itemIndex ?? 0) === e.itemIndex) ?? originals[0];
const currency = original?.currencyCode ?? 'KZT';
const rate = currency !== 'KZT' ? (currentRates.find(r => r.code === currency)?.rateToKzt ?? 1) : 1;
const pricePerUnit = e.pricePerUnit ?? 0;
const quantity = e.quantity ?? 0;
const pricePerUnitKzt = e.priceKztOverride !== undefined ? e.priceKztOverride : pricePerUnit * rate;
const totalPriceKzt = pricePerUnitKzt * quantity;
return {
id: original?.id ?? '',
supplierId: e.supplierId,
positionId: e.positionId,
itemIndex: e.itemIndex,
unit: e.unit,
supplierPosition: e.supplierProductName,
pricePerUnit,
quantity,
currencyCode: currency,
pricePerUnitKzt,
totalPriceKzt,
totalWithExtraKzt: totalPriceKzt,
offerId: original?.offerId,
};
});
const qtyOverrides: Record<string, number> = {};
Object.keys(posQtyOverridesRef.current).forEach(id => {
const v = parseFloat(posQtyOverridesRef.current[id]);
if (!isNaN(v)) qtyOverrides[id] = v;
});
onSaveRef.current({
offers: updatedOffers,
step3PositionQtyOverrides: qtyOverrides,
step3CriteriaEdits: criteriaMapRef.current,
step3CustomCriteria: customCriteriaRef.current,
step3ExtraPositions: extraPositionsRef.current,
step3ExtraSuppliers: extraSuppliersRef.current,
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const suppliersWithOffer = useMemo(() => [
...suppliers.map(s => ({ ...s, isExtraSupplier: false as const, isNoOffer: !s.hasOffer })),
...extraSuppliers.map(es => ({ id: es.id, name: es.name, hasOffer: true as const, isExtraSupplier: true as const, isNoOffer: false as const })),
], [suppliers, extraSuppliers]);
const allPositions = useMemo((): PositionRow[] => {
// Extra positions take priority: if a selected position shares a name with an extra, hide it from the regular section
const extraNames = new Set(extraPositions.map(ep => ep.materialName.toLowerCase().trim()));
return [
...selectedPositions
.filter(p => !extraNames.has(p.materialName.toLowerCase().trim()))
.map(p => ({
id: p.id,
materialName: p.materialName,
unit: p.unit,
quantity: p.quantity,
spId: p.spId,
})),
...extraPositions.map(ep => ({ ...ep, quantity: 0, isExtra: true as const })),
];
}, [selectedPositions, extraPositions]);
React.useEffect(() => { allPositionsRef.current = allPositions; }, [allPositions]);
const regularPositions = useMemo(() => allPositions.filter(p => !p.isExtra), [allPositions]);
const extraPositionsRows = useMemo(() => allPositions.filter(p => p.isExtra), [allPositions]);
// Currency per supplier: primary source is supplier.currency (set in Step 2),
// fallback to offers (for suppliers saved before this field was added), then KZT.
const supplierCurrencies = useMemo((): Record<string, string> => {
const m: Record<string, string> = {};
suppliers.forEach(s => {
const offer = offers.find(o => o.supplierId === s.id);
m[s.id] = s.currency ?? offer?.currencyCode ?? 'KZT';
});
extraSuppliers.forEach(es => { m[es.id] = es.currency; });
return m;
}, [suppliers, offers, extraSuppliers]);
const computedOffers = useMemo((): SupplierOffer[] => {
const result: SupplierOffer[] = [];
suppliersWithOffer.forEach(s => {
allPositions.forEach(pos => {
const key = buildOfferKey(s.id, pos.id);
const items = edits[key] ?? [{ supplierId: s.id, positionId: pos.id, itemIndex: 0 }];
const currency = supplierCurrencies[s.id] ?? 'KZT';
const rate = currency !== 'KZT'
? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1)
: 1;
// For noOffer/extra suppliers quantity may be undefined in edits (user only entered price).
// Fall back to the position's own quantity so totalWithExtraKzt stays non-zero and the
// offer is included in filteredComputed → persisted in offersForSession → restored on remount.
const posQtyFallback = (s.isNoOffer || s.isExtraSupplier) ? (pos.quantity || undefined) : undefined;
items.forEach(item => result.push(computeOffer(
{
...item,
unit: item.unit || pos.unit,
supplierProductName: item.supplierProductName || pos.materialName,
quantity: item.quantity ?? posQtyFallback,
},
currency, rate
)));
});
});
return result;
}, [edits, supplierCurrencies, currencyRates, allPositions, suppliersWithOffer]);
React.useEffect(() => { computedOffersRef.current = computedOffers; }, [computedOffers]);
const minTotalByPosition = useMemo((): Map<string, number> => {
const map = new Map<string, number>();
allPositions.forEach(pos => {
const values = suppliersWithOffer
.map(s => computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id)
.reduce((sum, o) => sum + (o.totalWithExtraKzt ?? 0), 0))
.filter(v => v > 0);
if (values.length > 0) map.set(pos.id, Math.min(...values));
});
return map;
}, [computedOffers, allPositions, suppliersWithOffer]);
const recommendedSupplierId = useMemo((): string | null => {
if (suppliersWithOffer.length === 0) return null;
const totals = suppliersWithOffer.map(s => ({
supplierId: s.id,
total: allPositions.reduce((sum, pos) => {
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
return sum + posOffers.reduce((s2, o) => s2 + (o.totalWithExtraKzt ?? 0), 0);
}, 0),
}));
const nonZero = totals.filter(t => t.total > 0);
if (nonZero.length === 0) return null;
const minTotal = Math.min(...nonZero.map(t => t.total));
return nonZero.find(t => t.total === minTotal)?.supplierId ?? null;
}, [computedOffers, allPositions, suppliersWithOffer]);
React.useEffect(() => {
if (selectedSupplierId === undefined && recommendedSupplierId !== null) {
setSelectedSupplierId(recommendedSupplierId);
}
}, [recommendedSupplierId, selectedSupplierId]);
const updateEdit = useCallback(
(supplierId: string, positionId: string, rowIdx: number, field: 'pricePerUnit' | 'quantity', value: string) => {
if (isReadOnly) return;
const key = buildOfferKey(supplierId, positionId);
const rawKey = `${supplierId}::${positionId}::${rowIdx}::${field}`;
setRawValues(prev => ({ ...prev, [rawKey]: value }));
setEdits(prev => {
const pos = allPositionsRef.current.find(p => p.id === positionId);
const items = prev[key] ?? [{ supplierId, positionId, itemIndex: rowIdx, quantity: pos?.quantity || undefined, unit: pos?.unit, supplierProductName: pos?.materialName }];
const parsed = parseFloat(value.replace(',', '.'));
const numVal = isNaN(parsed) ? 0 : Math.round(parsed * 1000) / 1000;
return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, [field]: numVal } : it) };
});
},
[isReadOnly]
);
const updatePriceKzt = useCallback(
(supplierId: string, positionId: string, rowIdx: number, value: string) => {
if (isReadOnly) return;
const key = buildOfferKey(supplierId, positionId);
const rawKey = `${supplierId}::${positionId}::${rowIdx}::priceKzt`;
setRawValues(prev => ({ ...prev, [rawKey]: value }));
setEdits(prev => {
const pos = allPositionsRef.current.find(p => p.id === positionId);
const items = prev[key] ?? [{ supplierId, positionId, itemIndex: rowIdx, quantity: pos?.quantity || undefined, unit: pos?.unit, supplierProductName: pos?.materialName }];
const parsed = parseFloat(value.replace(',', '.'));
const override = isNaN(parsed) ? undefined : Math.round(parsed * 1000) / 1000;
return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, priceKztOverride: override } : it) };
});
},
[isReadOnly]
);
const updateUnit = useCallback(
(supplierId: string, positionId: string, rowIdx: number, value: string) => {
if (isReadOnly) return;
const key = buildOfferKey(supplierId, positionId);
setEdits(prev => {
const items = prev[key] ?? [{ supplierId, positionId, itemIndex: 0 }];
return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, unit: value } : it) };
});
},
[isReadOnly]
);
const updateSupplierProductName = useCallback(
(supplierId: string, positionId: string, rowIdx: number, value: string) => {
if (isReadOnly) return;
const key = buildOfferKey(supplierId, positionId);
setEdits(prev => {
const items = prev[key] ?? [{ supplierId, positionId, itemIndex: 0 }];
return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, supplierProductName: value } : it) };
});
},
[isReadOnly]
);
const addBundleItem = useCallback(
(supplierId: string, positionId: string) => {
if (isReadOnly) return;
const key = buildOfferKey(supplierId, positionId);
setEdits(prev => {
const items = prev[key] ?? [{ supplierId, positionId, itemIndex: 0 }];
return { ...prev, [key]: [...items, { supplierId, positionId, itemIndex: items.length }] };
});
},
[isReadOnly]
);
const removeBundleItem = useCallback(
(supplierId: string, positionId: string, rowIdx: number) => {
if (isReadOnly) return;
const key = buildOfferKey(supplierId, positionId);
setEdits(prev => {
const items = prev[key] ?? [];
if (items.length <= 1) return prev;
return { ...prev, [key]: items.filter((_, i) => i !== rowIdx).map((it, i) => ({ ...it, itemIndex: i })) };
});
},
[isReadOnly]
);
const handleDeleteSupplier = useCallback((supplierId: string, isExtra: boolean): void => {
if (isReadOnly) return;
if (isExtra) {
const updated = extraSuppliers.filter(es => es.id !== supplierId);
setExtraSuppliers(updated);
setEdits(prev => {
const next = { ...prev };
Object.keys(next).forEach(k => { if (k.startsWith(`${supplierId}::`)) delete next[k]; });
return next;
});
onSave({ step3ExtraSuppliers: updated });
} else {
setEdits(prev => {
const next = { ...prev };
Object.keys(next).forEach(k => { if (k.startsWith(`${supplierId}::`)) delete next[k]; });
return next;
});
onSave({ suppliers: suppliers.filter(s => s.id !== supplierId) });
}
}, [isReadOnly, extraSuppliers, suppliers, onSave]);
const updatePosQty = useCallback((positionId: string, value: string): void => {
if (isReadOnly) return;
setPosQtyOverrides(prev => ({ ...prev, [positionId]: value }));
}, [isReadOnly]);
const updateCriteriaValue = useCallback((supplierId: string, field: keyof CriteriaEntry, value: string): void => {
setCriteriaMap(prev => {
const next = {
...prev,
[supplierId]: { ...prev[supplierId] ?? { paymentType: '', deliveryType: '', deadline: '', sertificates: '' }, [field]: value },
};
onSave({ step3CriteriaEdits: next });
return next;
});
}, [onSave]);
const updateCustomCriteriaValue = useCallback((rowId: string, supplierId: string, value: string): void => {
setCustomCriteria(prev => {
const next = prev.map(row => row.id === rowId ? { ...row, values: { ...row.values, [supplierId]: value } } : row);
onSave({ step3CustomCriteria: next });
return next;
});
}, [onSave]);
const handleAddCustomCriteria = useCallback((): void => {
if (!newCriteriaLabel.trim()) return;
const id = `custom_${Date.now()}`;
setCustomCriteria(prev => {
const next = [...prev, { id, label: newCriteriaLabel.trim(), values: {} }];
onSave({ step3CustomCriteria: next });
return next;
});
setNewCriteriaLabel('');
}, [newCriteriaLabel, onSave]);
const handleRemoveCustomCriteria = useCallback((rowId: string): void => {
setCustomCriteria(prev => {
const next = prev.filter(r => r.id !== rowId);
onSave({ step3CustomCriteria: next });
return next;
});
}, [onSave]);
const handleNameColResizeMouseDown = useCallback((e: React.MouseEvent<HTMLSpanElement>): void => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startWidth = nameColWidthRef.current;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const onMouseMove = (me: MouseEvent): void => {
const newW = Math.max(80, startWidth + (me.clientX - startX));
nameColWidthRef.current = newW;
setNameColWidth(newW);
};
const onMouseUp = (): void => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}, []);
// Статус поставщика: обновляем локальный стейт и сразу пишем в formData,
// чтобы значение пережило переход между шагами (до большого сохранения в SP).
const handleStatusChange = (id: string, value: string): void => {
setSupplierStatuses(prev => ({ ...prev, [id]: value }));
if (suppliers.some(s => s.id === id)) {
onSave({ suppliers: suppliers.map(s => (s.id === id ? { ...s, status: value } : s)) });
} else {
setExtraSuppliers(prev => prev.map(es => (es.id === id ? { ...es, status: value } : es)));
}
};
const handleAddSupplier = (): void => {
if (!newSupplierName.trim()) return;
const id = `extra_sup_${++extraIdCounter}`;
const newEntry = { id, name: newSupplierName.trim(), currency: newSupplierCurrency };
const updated = [...extraSuppliers, newEntry];
setExtraSuppliers(updated);
setSupplierStatuses(prev => ({ ...prev, [id]: '' }));
setNewSupplierName('');
setNewSupplierCurrency('KZT');
onSave({ step3ExtraSuppliers: updated });
};
const handleAddPosition = (): void => {
if (!newPosName.trim()) return;
const id = `extra_pos_${++extraIdCounter}`;
const newEntry = { id, materialName: newPosName.trim(), unit: newPosUnit.trim() };
const updated = [...extraPositions, newEntry];
setExtraPositions(updated);
setNewPosName('');
setNewPosUnit('');
setShowAddPosRow(false);
onSave({ step3ExtraPositions: updated });
};
const handleAddDelivery = (): void => {
const id = `extra_pos_${++extraIdCounter}`;
const newEntry: ExtraPosition = { id, materialName: 'Логистика', unit: 'рейс', isDelivery: true };
const updated = [...extraPositions, newEntry];
setExtraPositions(updated);
onSave({ step3ExtraPositions: updated });
};
const handleDeliveryKmChange = (posId: string, supId: string, val: string): void => {
const km = parseFloat(val);
const key = `${posId}::${supId}`;
const updated = isNaN(km)
? (({ [key]: _, ...rest }) => rest)(deliveryKm)
: { ...deliveryKm, [key]: km };
setDeliveryKm(updated);
onSave({ step3DeliveryKm: updated });
};
const handleDeleteExtraPosition = (positionId: string): void => {
if (isReadOnly) return;
const updated = extraPositions.filter(ep => ep.id !== positionId);
setExtraPositions(updated);
setEdits(prev => {
const next = { ...prev };
Object.keys(next).forEach(k => {
if (k.split('::')[1] === positionId) delete next[k];
});
return next;
});
onSave({ step3ExtraPositions: updated });
};
const handleSave = async (): Promise<void> => {
if (!ct) return;
const deliveryMissingKm = extraPositions.filter(ep =>
ep.isDelivery && suppliersWithOffer.some(s => !deliveryKm[`${ep.id}::${s.id}`])
);
if (deliveryMissingKm.length > 0) {
setSaveError('Укажите километраж для каждого потенциального поставщика в строке логистики');
return;
}
setIsSaving(true);
setSaveError(undefined);
try {
const extraSupSpIds: Record<string, number> = {};
const extraOfferIds: Record<string, number> = {};
for (const es of extraSuppliers) {
if (es.spId) {
// Already created in SP on a previous save — reuse to avoid duplicates.
extraSupSpIds[es.id] = es.spId;
continue;
}
const { spId, offerId } = await service.saveManualSupplierWithKp(es.name);
extraSupSpIds[es.id] = spId;
extraOfferIds[es.id] = offerId;
}
const noOfferSupSpIds: Record<string, number> = {};
const noOfferOfferIds: Record<string, number> = {};
for (const s of suppliers.filter(sup => !sup.hasOffer)) {
const hasData = computedOffers.some(o => o.supplierId === s.id && o.totalWithExtraKzt > 0);
if (!hasData) continue;
if (s.spId) {
noOfferSupSpIds[s.id] = s.spId;
continue;
}
const { spId, offerId } = await service.saveManualSupplierWithKp(s.name);
noOfferSupSpIds[s.id] = spId;
noOfferOfferIds[s.id] = offerId;
}
const extraPosSpIds: Record<string, number> = {};
for (const ep of extraPositions) {
if (ep.spId) {
// Already created in SP on a previous save — reuse to avoid duplicate Позиции СТ rows.
extraPosSpIds[ep.id] = ep.spId;
continue;
}
const saved = await service.createCTPositions(ct.id, [{
id: ep.id,
requestId: '',
requestNumber: '',
materialName: ep.materialName,
disciplineCode: '',
smrType: '',
unit: ep.unit,
agskCode: '',
uniqueCode: '',
quantity: 0,
positionType: 'Дополнительные затраты',
deadline: '',
}]);
if (saved[0]?.spId) extraPosSpIds[ep.id] = saved[0].spId;
}
const offersToSave = computedOffers
.filter(o => o.totalWithExtraKzt > 0)
.map(o => {
const supSpId = extraSupSpIds[o.supplierId] ?? noOfferSupSpIds[o.supplierId];
const posSpId = extraPosSpIds[o.positionId];
const existingSup = suppliers.find(s => s.id === o.supplierId);
const existingPos = selectedPositions.find(p => p.id === o.positionId);
const supSpIdStr = existingSup?.spId !== undefined ? String(existingSup.spId) : undefined;
const posSpIdStr = existingPos?.spId !== undefined ? String(existingPos.spId) : undefined;
const originalOffer = offers.find(orig =>
(orig.supplierId === o.supplierId || (supSpIdStr !== undefined && orig.supplierId === supSpIdStr)) &&
(orig.positionId === o.positionId || (posSpIdStr !== undefined && orig.positionId === posSpIdStr)) &&
(orig.itemIndex ?? 0) === (o.itemIndex ?? 0)
);
const kpFileOfferId = existingSup
? (formData.uploadedKpFiles ?? []).find(f => f.supplierName === existingSup.name)?.spId
: undefined;
const isDeliveryPos = extraPositions.find(ep => ep.id === o.positionId)?.isDelivery;
const kmVal = isDeliveryPos ? deliveryKm[`${o.positionId}::${o.supplierId}`] : undefined;
return {
...o,
supplierId: supSpId
? String(supSpId)
: (existingSup?.spId ? String(existingSup.spId) : o.supplierId),
positionId: posSpId
? String(posSpId)
: (existingPos?.spId ? String(existingPos.spId) : o.positionId),
offerId: originalOffer?.offerId
?? (extraOfferIds[o.supplierId] !== undefined ? String(extraOfferIds[o.supplierId]) : undefined)
?? (noOfferOfferIds[o.supplierId] !== undefined ? String(noOfferOfferIds[o.supplierId]) : undefined)
?? (kpFileOfferId !== undefined ? String(kpFileOfferId) : undefined),
...(kmVal !== undefined ? { deliveryDistanceKm: kmVal } : {}),
};
});
await service.saveOffers(offersToSave, currencyRates);
// Persist offers with React IDs (sup.id / pos.id) so they survive F5 / step navigation —
// render keys use React IDs, so SP IDs in session cause empty cells on remount.
// Keep offerId from offersToSave (order matches: both derived from same filtered computedOffers).
const filteredComputed = computedOffers.filter(o => o.totalWithExtraKzt > 0);
const offersForSession = filteredComputed.map((o, i) => ({
...o,
offerId: offersToSave[i]?.offerId,
}));
onSave({
offers: offersForSession,
suppliers: [
...suppliers.map(s => ({
...s,
status: supplierStatuses[s.id] ?? s.status,
...(noOfferSupSpIds[s.id] !== undefined ? { spId: noOfferSupSpIds[s.id] } : {}),
})),
// Move extras into suppliers (with React ID kept as `id` — required so formData.offers
// entries with React IDs still match sup.id on remount). Skip extras that are already in
// formData.suppliers from a prior save (avoids duplicate columns).
...extraSuppliers
.filter(es => !suppliers.some(s => s.id === es.id))
.map(es => ({
id: es.id,
spId: extraSupSpIds[es.id],
name: es.name,
hasOffer: true as const,
status: supplierStatuses[es.id] ?? '',
})),
],
recommendedSupplierId: selectedSupplierId,
// Extras moved into `suppliers` — clear extras state so `suppliersWithOffer` doesn't duplicate columns.
step3ExtraSuppliers: [],
// Positions don't migrate into selectedPositions — keep them in extras state so the
// "доп.расходы" rows continue to render after Step 4 → breadcrumb back. Track spId to
// skip re-creating SP rows on subsequent saves.
step3ExtraPositions: extraPositions.map(ep => ({
...ep,
spId: extraPosSpIds[ep.id] ?? ep.spId,
})),
step3PositionQtyOverrides: Object.keys(posQtyOverrides).reduce<Record<string, number>>((acc, id) => {
const v = parseFloat(posQtyOverrides[id]);
if (!isNaN(v)) acc[id] = v;
return acc;
}, {}),
step3DeliveryKm: deliveryKm,
step3CriteriaEdits: criteriaMap,
step3CustomCriteria: customCriteria,
});
saveCompletedRef.current = true;
onStepComplete(3);
onNext();
} catch (err) {
setSaveError((err as Error).message ?? 'Ошибка сохранения');
} finally {
setIsSaving(false);
}
};
return {
isReadOnly,
// state values
extraSuppliers,
supplierStatuses,
extraPositions,
newSupplierName,
newSupplierCurrency,
newPosName,
newPosUnit,
showAddPosRow,
deliveryKm,
edits,
selectedSupplierId,
isSaving,
saveError,
translateUnits,
criteriaMap,
customCriteria,
newCriteriaLabel,
rawValues,
nameColWidth,
posQtyOverrides,
// setters used by render / AI
setNewSupplierName,
setNewSupplierCurrency,
setNewPosName,
setNewPosUnit,
setShowAddPosRow,
setSelectedSupplierId,
setTranslateUnits,
setNewCriteriaLabel,
setSaveError,
// derived
suppliersWithOffer,
allPositions,
regularPositions,
extraPositionsRows,
supplierCurrencies,
computedOffers,
minTotalByPosition,
recommendedSupplierId,
// handlers
updateEdit,
updatePriceKzt,
updateUnit,
updateSupplierProductName,
addBundleItem,
removeBundleItem,
handleDeleteSupplier,
updatePosQty,
updateCriteriaValue,
updateCustomCriteriaValue,
handleAddCustomCriteria,
handleRemoveCustomCriteria,
handleNameColResizeMouseDown,
handleStatusChange,
handleAddSupplier,
handleAddPosition,
handleAddDelivery,
handleDeliveryKmChange,
handleDeleteExtraPosition,
handleSave,
};
}
// Тип результата хука — единый прокидываемый проп для под-компонентов рендера (ComparisonMatrix/
// MatrixRowView/ExtraPositionRow/AddExtraRow/CriteriaTable), чтобы не разводить десятки отдельных пропов.
export type Step3Matrix = ReturnType<typeof useStep3Matrix>;
@@ -0,0 +1,267 @@
@import '../common/theme';
.root {
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
.section {
display: flex;
flex-direction: column;
gap: 14px;
}
.sectionTitle {
font-size: 15px;
font-weight: 600;
color: $text;
margin: 0;
padding-bottom: 8px;
border-bottom: 1px solid $border;
}
.emptyChain {
font-size: 13px;
color: $muted-2;
font-style: italic;
margin: 0;
padding: 16px 0;
}
.chainList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 860px;
}
.chainItem {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: $surface;
border: 1px solid $border;
border-radius: 4px;
flex-wrap: wrap;
&:hover {
background: $head-bg;
}
}
.orderBadge {
width: 28px;
height: 28px;
border-radius: 50%;
background: $border;
color: $muted;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
}
.memberInfo {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 160px;
}
.memberName {
font-size: 13px;
font-weight: 600;
color: $text;
}
.memberRole {
font-size: 12px;
color: $muted;
background: $hover;
padding: 1px 6px;
border-radius: 3px;
}
.statusBadge {
font-size: 11px;
font-weight: 600;
padding: 2px 9px;
border-radius: 10px;
white-space: nowrap;
flex-shrink: 0;
}
.statusPending {
background: $hover;
color: $muted;
}
.statusApproved {
background: $ok-bg;
color: $ok;
}
.statusRejected {
background: $danger-bg;
color: $danger;
}
.statusSkipped {
background: $hover;
color: $muted-2;
}
.memberComment {
font-size: 12px;
color: $muted;
font-style: italic;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actionDate {
font-size: 11px;
color: $muted-2;
white-space: nowrap;
}
.memberActions {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.approvalActions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.rejectBtn {
color: $danger;
border-color: $danger;
&:hover {
background: $danger-bg;
color: $danger;
}
}
// ─── Add member ─────────────────────────────────────────────────────────────
.addMemberRow {
display: flex;
align-items: flex-start;
gap: 10px;
flex-wrap: nowrap;
max-width: 30%;
}
.memberNameInput {
flex: 2;
min-width: 0;
position: relative;
}
.memberRoleInput {
flex: 1;
min-width: 0;
}
.addRowBtn {
align-self: flex-end;
}
// ─── People search suggestions ───────────────────────────────────────────────
.suggestions {
list-style: none;
margin: 2px 0 0;
padding: 0;
border: 1px solid $border;
border-radius: 4px;
background: $surface;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
position: absolute;
z-index: 100;
width: 100%;
max-height: 200px;
overflow-y: auto;
}
.suggestionItem {
display: flex;
flex-direction: column;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
border-bottom: 1px solid $hover;
&:last-child {
border-bottom: none;
}
&:hover {
background: $hover;
}
}
.suggestionName {
font-weight: 500;
color: $text;
}
.suggestionEmail {
font-size: 11px;
color: $muted-2;
}
// ─── Row action buttons ───────────────────────────────────────────────────────
.replaceBtn {
flex-shrink: 0;
font-size: 12px;
}
.deleteBtn {
color: $danger;
flex-shrink: 0;
&:hover {
background: $danger-bg;
color: $danger;
}
}
// ─── Footer ─────────────────────────────────────────────────────────────────
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16px;
border-top: 1px solid $border;
gap: 12px;
flex-wrap: wrap;
}
.footerRight {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.dialogError {
margin-top: 12px;
}
@@ -0,0 +1,384 @@
import * as React from "react";
import { Button, Alert, Spinner, IconButton, Dialog, TextInput, TextArea, Icon, FormField } from "@tnsu/ui-kit";
import { ApprovalMemberStatus, FormStep } from "../types/domain";
import { CtService } from "../../services/CtService";
import { CTFormData } from "../hooks/useCtForm";
import { useStep4Approval } from "./useStep4Approval";
import styles from "./Step4Approval.module.scss";
interface Props {
service: CtService;
formData: CTFormData;
userDisplayName: string;
onSave: (partial: Partial<CTFormData>) => void;
onPrev: () => void;
onStepComplete: (step: FormStep) => void;
}
const STATUS_LABELS: Record<ApprovalMemberStatus, string> = {
pending: "На согласовании",
recommends: "Рекомендую",
approved: "Согласовано",
rejected: "Отклонено",
skipped: "Пропущено",
};
function getStatusClass(status: ApprovalMemberStatus): string {
const map: Record<ApprovalMemberStatus, string> = {
pending: styles.statusPending,
recommends: styles.statusApproved,
approved: styles.statusApproved,
rejected: styles.statusRejected,
skipped: styles.statusSkipped,
};
return map[status];
}
function formatDate(iso?: string): string {
if (!iso) return "";
return new Date(iso).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export const Step4Approval: React.FC<Props> = (props) => {
const { onPrev } = props;
const a = useStep4Approval(props);
return (
<div className={styles.root}>
{/* Current approval status banner */}
{a.ct?.status === "approved" && <Alert variant="ok">СТ согласована всеми участниками.</Alert>}
{a.ct?.status === "rejected" && (
<Alert variant="err">СТ отклонена. Требуется корректировка и повторная отправка.</Alert>
)}
{a.ct?.status === "on_approval" && <Alert variant="info">СТ находится на согласовании.</Alert>}
{a.notifyWarning && <Alert variant="warn">{a.notifyWarning}</Alert>}
{/* Approval chain */}
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Цепочка согласования</h3>
{a.chain.length === 0 && (
<p className={styles.emptyChain}>
Добавьте участников для согласования
</p>
)}
{a.chain.length > 0 && (
<ol className={styles.chainList}>
{a.chain.map((member, index) => (
<li key={member.id} className={styles.chainItem}>
<span className={styles.orderBadge}>{member.order}</span>
<div className={styles.memberInfo}>
<span className={styles.memberName}>{member.userName}</span>
{member.role && (
<span className={styles.memberRole}>{member.role}</span>
)}
</div>
{a.showStatuses && (
<span
className={[
styles.statusBadge,
getStatusClass(member.status),
].join(" ")}
>
{STATUS_LABELS[member.status]}
</span>
)}
{member.comment && (
<span className={styles.memberComment} title={member.comment}>
«{member.comment}»
</span>
)}
{member.actionDate && (
<span className={styles.actionDate}>
{formatDate(member.actionDate)}
</span>
)}
{!a.isOnApproval &&
member.status === "pending" &&
member.role !== "Инициатор" && (
<div className={styles.memberActions}>
<IconButton
icon="arrow-up"
disabled={index === 0}
onClick={() => a.moveMember(member.id, "up")}
title="Переместить вверх"
/>
<IconButton
icon="arrow-down"
disabled={index === a.chain.length - 1}
onClick={() => a.moveMember(member.id, "down")}
title="Переместить вниз"
/>
</div>
)}
{/* Replace button - non-initiator pending members, both before and during approval */}
{member.role !== "Инициатор" &&
member.status === "pending" &&
!(a.isOnApproval && a.currentUserMember?.id === member.id) && (
<Button variant="secondary" className={styles.replaceBtn} onClick={() => a.openReplace(member)}>
<Icon name="swap" size="sm" />
Заменить
</Button>
)}
{/* Delete button - only before approval, non-initiator */}
{!a.isOnApproval && member.role !== "Инициатор" && (
<IconButton
icon="trash"
danger
className={styles.deleteBtn}
onClick={() => a.handleDeleteMember(member)}
title="Удалить согласующего"
/>
)}
{/* Actions for the current user when it's their turn */}
{a.isOnApproval && a.currentUserMember?.id === member.id && (
<div className={styles.approvalActions}>
<Button variant="primary" onClick={() => a.openActionDialog(member, "approve")}>
<Icon name="check" size="sm" />
Согласовать
</Button>
<Button variant="danger" className={styles.rejectBtn} onClick={() => a.openActionDialog(member, "reject")}>
<Icon name="close" size="sm" />
Отклонить
</Button>
</div>
)}
</li>
))}
</ol>
)}
{/* Add member */}
{!a.isOnApproval && (
<div>
{a.showAddRow ? (
<div className={styles.addMemberRow}>
<div className={styles.memberNameInput}>
<FormField label="Поиск участника">
<TextInput
value={a.addQuery}
onChange={a.handleAddQueryChange}
placeholder="Введите имя для поиска..."
/>
</FormField>
{a.addSuggestions.length > 0 && !a.addSelected && (
<ul className={styles.suggestions}>
{a.addSuggestions.map((s) => (
<li
key={s.spId}
className={styles.suggestionItem}
onClick={() => a.selectAddSuggestion(s)}
>
<span className={styles.suggestionName}>{s.displayName}</span>
<span className={styles.suggestionEmail}>{s.email}</span>
</li>
))}
</ul>
)}
</div>
<div className={styles.memberRoleInput}>
<FormField label="Роль (необязательно)">
<TextInput
value={a.addRole}
onChange={a.setAddRole}
placeholder="Напр.: Руководитель"
/>
</FormField>
</div>
<Button
variant="primary"
className={styles.addRowBtn}
disabled={!a.addSelected || a.isAddingMember}
onClick={a.handleAddMember}
>
{a.isAddingMember ? (
<Spinner size="small" />
) : (
<>
<Icon name="plus" size="sm" />
Добавить
</>
)}
</Button>
<Button variant="secondary" className={styles.addRowBtn} onClick={a.cancelAddRow}>
Отмена
</Button>
</div>
) : (
<Button variant="secondary" onClick={() => a.setShowAddRow(true)}>
<Icon name="plus" size="sm" />
Добавить согласующего
</Button>
)}
</div>
)}
</section>
{/* Footer actions */}
<div className={styles.footer}>
<Button variant="secondary" onClick={onPrev} disabled={a.isOnApproval}>
<Icon name="arrow-left" size="sm" />
Назад
</Button>
<div className={styles.footerRight}>
{a.submitError && <Alert variant="err">{a.submitError}</Alert>}
{!a.isOnApproval && (
<Button
variant="primary"
onClick={a.handleSendForApproval}
disabled={a.chain.length === 0 || a.isSubmitting || !a.ct}
>
{a.isSubmitting ? (
<Spinner size="small" />
) : (
<>
<Icon name="send" size="sm" />
Отправить на согласование
</>
)}
</Button>
)}
{a.isOnApproval && a.isInitiator && a.ct?.status !== "approved" && (
<>
<Button variant="secondary" onClick={a.handleSendToIncore} disabled={a.isSendingToIncore}>
{a.isSendingToIncore ? (
<Spinner size="small" />
) : (
<>
<Icon name="download" size="sm" />
Выгрузить пакет документов для INCORE MANAGEMENT
</>
)}
</Button>
<Button variant="secondary" onClick={a.handleRestart} disabled={a.isSubmitting}>
<Icon name="refresh" size="sm" />
Перезапустить согласование
</Button>
</>
)}
</div>
</div>
{/* Replace member dialog */}
{a.replaceDialog && (
<Dialog
open
title="Заменить согласующего"
subText="Найдите нового участника для замены."
onDismiss={a.closeReplace}
footer={
<>
<Button variant="secondary" onClick={a.closeReplace}>
Отмена
</Button>
<Button
variant="primary"
onClick={a.handleReplaceConfirm}
disabled={!a.replaceDialog.selected || a.isReplacing}
>
{a.isReplacing ? <Spinner size="small" /> : "Заменить"}
</Button>
</>
}
>
<div className={styles.memberNameInput}>
<FormField label="Поиск нового участника">
<TextInput
value={a.replaceDialog.query}
onChange={a.handleReplaceQueryChange}
placeholder="Введите имя для поиска..."
/>
</FormField>
{a.replaceDialog.suggestions.length > 0 && !a.replaceDialog.selected && (
<ul className={styles.suggestions}>
{a.replaceDialog.suggestions.map((s) => (
<li
key={s.spId}
className={styles.suggestionItem}
onClick={() => a.selectReplaceSuggestion(s)}
>
<span className={styles.suggestionName}>{s.displayName}</span>
<span className={styles.suggestionEmail}>{s.email}</span>
</li>
))}
</ul>
)}
</div>
</Dialog>
)}
{/* Approve / Reject dialog */}
{a.actionDialog && (
<Dialog
open
title={a.actionDialog.action === "approve" ? "Согласование" : "Отклонение"}
subText={
a.actionDialog.action === "approve"
? "Вы подтверждаете согласование СТ? Комментарий необязателен."
: "Укажите причину отклонения (обязательно)."
}
onDismiss={a.closeActionDialog}
footer={
<>
<Button variant="secondary" onClick={a.closeActionDialog} disabled={a.isSubmitting}>
Отмена
</Button>
<Button
variant={a.actionDialog.action === "approve" ? "primary" : "danger"}
onClick={a.handleActionConfirm}
disabled={
a.isSubmitting ||
(a.actionDialog.action === "reject" && !a.actionDialog.comment.trim())
}
>
{a.isSubmitting ? (
<Spinner size="small" />
) : a.actionDialog.action === "approve" ? (
"Согласовать"
) : (
"Отклонить"
)}
</Button>
</>
}
>
<TextArea
value={a.actionDialog.comment}
onChange={a.setActionComment}
rows={3}
ariaLabel="Комментарий"
placeholder={
a.actionDialog.action === "reject"
? "Укажите причину отклонения..."
: "Комментарий (необязательно)..."
}
/>
{a.submitError && (
<div className={styles.dialogError}>
<Alert variant="err">{a.submitError}</Alert>
</div>
)}
</Dialog>
)}
</div>
);
};
@@ -0,0 +1,423 @@
import { useState, useCallback, useRef } from 'react';
import { ApprovalMember, ApprovalMemberStatus, FormStep } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
export type ApprovalAction = 'approve' | 'reject';
export interface ActionDialogState {
memberId: string;
memberUserId: string;
memberName: string;
action: ApprovalAction;
comment: string;
}
export type UserSuggestion = { spId: number; displayName: string; email: string };
export interface ReplaceDialogState {
memberId: string;
spApprovalId?: number;
query: string;
selected: { spId: number; displayName: string } | null;
suggestions: UserSuggestion[];
}
export interface UseStep4ApprovalArgs {
service: CtService;
formData: CTFormData;
userDisplayName: string;
onSave: (partial: Partial<CTFormData>) => void;
onStepComplete: (step: FormStep) => void;
}
// Логика Шага 4 «Согласование» (цепочка): манипуляции участниками (порядок/добавление/удаление/замена),
// голосование (approve/reject + нотификации), отправка на согласование, выгрузка в INCORE, перезапуск.
// Перенесено 1:1 из Step4Approval.tsx (§стайл-гайд: логика вне вёрстки).
export function useStep4Approval(args: UseStep4ApprovalArgs) {
const { service, formData, userDisplayName, onSave, onStepComplete } = args;
const { ct } = formData;
const [chain, setChain] = useState<ApprovalMember[]>(formData.approvalChain);
const [actionDialog, setActionDialog] = useState<ActionDialogState | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [notifyWarning, setNotifyWarning] = useState<string | null>(null);
const [isSendingToIncore, setIsSendingToIncore] = useState(false);
// ─── Add member ───
const [showAddRow, setShowAddRow] = useState(false);
const [addQuery, setAddQuery] = useState('');
const [addRole, setAddRole] = useState('');
const [addSuggestions, setAddSuggestions] = useState<UserSuggestion[]>([]);
const [addSelected, setAddSelected] = useState<{ spId: number; displayName: string } | null>(null);
const [isAddingMember, setIsAddingMember] = useState(false);
const addTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// ─── Replace member ───
const [replaceDialog, setReplaceDialog] = useState<ReplaceDialogState | null>(null);
const [isReplacing, setIsReplacing] = useState(false);
const replaceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const moveMember = useCallback((id: string, direction: 'up' | 'down'): void => {
setChain((prev) => {
const idx = prev.findIndex((m) => m.id === id);
if (idx === -1) return prev;
const next = [...prev];
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= next.length) return prev;
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
return next.map((m, i) => ({ ...m, order: i + 1 }));
});
}, []);
const openActionDialog = useCallback((member: ApprovalMember, action: ApprovalAction): void => {
setActionDialog({
memberId: member.id,
memberUserId: member.userId,
memberName: member.userName,
action,
comment: '',
});
}, []);
const closeActionDialog = useCallback((): void => setActionDialog(null), []);
const setActionComment = useCallback((val: string): void => {
setActionDialog((prev) => (prev ? { ...prev, comment: val } : null));
}, []);
const handleAddQueryChange = (val: string): void => {
const q = val;
setAddQuery(q);
setAddSelected(null);
setAddSuggestions([]);
if (addTimerRef.current) clearTimeout(addTimerRef.current);
if (q.length < 2) return;
addTimerRef.current = setTimeout(async () => {
const results = await service.searchUsers(q);
setAddSuggestions(results);
}, 300);
};
const selectAddSuggestion = (s: UserSuggestion): void => {
setAddSelected({ spId: s.spId, displayName: s.displayName });
setAddQuery(s.displayName);
setAddSuggestions([]);
};
const cancelAddRow = (): void => {
setShowAddRow(false);
setAddQuery('');
setAddRole('');
setAddSelected(null);
setAddSuggestions([]);
};
const handleAddMember = async (): Promise<void> => {
if (!addSelected || !ct) return;
setIsAddingMember(true);
setSubmitError(null);
try {
const newOrder = chain.length + 1;
const newMember = await service.addApprovalMemberDraft(
ct.id, newOrder, addSelected.spId, addSelected.displayName, addRole.trim() || undefined,
);
const newChain = [...chain, newMember];
setChain(newChain);
onSave({ approvalChain: newChain });
cancelAddRow();
} catch (err) {
setSubmitError((err as Error).message ?? 'Ошибка добавления');
} finally {
setIsAddingMember(false);
}
};
const handleDeleteMember = async (member: ApprovalMember): Promise<void> => {
setSubmitError(null);
try {
if (member.spApprovalId) {
await service.deleteApprovalMembers([member.spApprovalId]);
}
const updated = chain
.filter((m) => m.id !== member.id)
.map((m, i) => ({ ...m, order: i + 1 }));
setChain(updated);
onSave({ approvalChain: updated });
} catch (err) {
setSubmitError((err as Error).message ?? 'Ошибка удаления');
}
};
const openReplace = (member: ApprovalMember): void => {
setReplaceDialog({
memberId: member.id,
spApprovalId: member.spApprovalId,
query: '',
selected: null,
suggestions: [],
});
};
const closeReplace = (): void => setReplaceDialog(null);
const handleReplaceQueryChange = (val: string): void => {
const q = val;
setReplaceDialog((prev) => (prev ? { ...prev, query: q, selected: null, suggestions: [] } : null));
if (replaceTimerRef.current) clearTimeout(replaceTimerRef.current);
if (q.length < 2) return;
replaceTimerRef.current = setTimeout(async () => {
const results = await service.searchUsers(q);
setReplaceDialog((prev) => (prev ? { ...prev, suggestions: results } : null));
}, 300);
};
const selectReplaceSuggestion = (s: UserSuggestion): void => {
setReplaceDialog((prev) =>
prev ? { ...prev, selected: { spId: s.spId, displayName: s.displayName }, query: s.displayName, suggestions: [] } : null,
);
};
const handleReplaceConfirm = async (): Promise<void> => {
if (!replaceDialog?.selected) return;
setIsReplacing(true);
setSubmitError(null);
try {
const { memberId, spApprovalId, selected } = replaceDialog;
if (spApprovalId) {
await service.replaceApprovalMember(spApprovalId, selected.spId);
}
const updated = chain.map((m) =>
m.id === memberId ? { ...m, userId: String(selected.spId), userName: selected.displayName } : m,
);
setChain(updated);
onSave({ approvalChain: updated });
setReplaceDialog(null);
} catch (err) {
setSubmitError((err as Error).message ?? 'Ошибка замены');
} finally {
setIsReplacing(false);
}
};
const handleActionConfirm = async (): Promise<void> => {
if (!actionDialog) return;
const { memberId, action, comment } = actionDialog;
if (action === 'reject' && !comment.trim()) return;
setIsSubmitting(true);
setSubmitError(null);
const apiStatus: 'approved' | 'rejected' = action === 'approve' ? 'approved' : 'rejected';
const currentMember = chain.find((m) => m.id === memberId);
try {
if (!currentMember?.spApprovalId) throw new Error('Не найден ID записи согласования');
await service.submitApprovalStep(currentMember.spApprovalId, apiStatus, comment.trim());
const notify = async (spId: number): Promise<void> => {
try {
await service.triggerApprovalNotification(spId);
} catch (e) {
console.error('[Step4] triggerApprovalNotification failed for spId', spId, e);
setNotifyWarning('Голос записан, но уведомление в Teams не удалось отправить. Проверьте Power Automate.');
}
};
if (action === 'approve') {
const updatedChain = chain.map((m) =>
m.id === memberId
? { ...m, status: 'approved' as ApprovalMemberStatus, comment: comment.trim(), actionDate: new Date().toISOString() }
: m,
);
setChain(updatedChain);
onSave({ approvalChain: updatedChain });
if (currentMember?.spApprovalId) {
await notify(currentMember.spApprovalId);
}
const nextPending = updatedChain.find((m) => m.status === 'pending');
if (nextPending?.spApprovalId) {
await notify(nextPending.spApprovalId);
}
const allApproved = updatedChain.every(
(m) => m.status === 'approved' || m.status === 'skipped' || m.status === 'recommends',
);
if (allApproved) {
await service.updateCTStatus(ct!.id, 'approved');
onSave({ ct: { ...ct!, status: 'approved' } });
onStepComplete(4);
}
} else {
const othersToDelete = chain.filter(
(m) => m.id !== memberId && m.status === 'pending' && m.spApprovalId,
);
if (othersToDelete.length > 0) {
await service.deleteApprovalMembers(othersToDelete.map((m) => m.spApprovalId!));
}
const finalChain = chain
.filter((m) => m.status !== 'pending' || m.id === memberId)
.map((m) =>
m.id === memberId
? { ...m, status: 'rejected' as ApprovalMemberStatus, comment: comment.trim(), actionDate: new Date().toISOString() }
: m,
);
setChain(finalChain);
await service.updateCTStatus(ct!.id, 'rejected');
onSave({ approvalChain: finalChain, ct: { ...ct!, status: 'rejected' } });
const initiator = finalChain.find((m) => m.role === 'Инициатор');
if (initiator?.spApprovalId) await notify(initiator.spApprovalId);
}
closeActionDialog();
} catch (err) {
setSubmitError((err as Error).message ?? 'Ошибка выполнения действия');
} finally {
setIsSubmitting(false);
}
};
const handleSendForApproval = async (): Promise<void> => {
if (!ct || chain.length === 0) return;
setIsSubmitting(true);
setSubmitError(null);
try {
await service.activateApprovalChain(chain);
const winSupplier = formData.suppliers.find((s) => s.id === formData.recommendedSupplierId);
service.updateCTStatus(ct.id, 'on_approval', winSupplier?.spId).catch(() => undefined);
const activatedChain = chain.map((m) => ({
...m,
status: (m.role === 'Инициатор' ? 'recommends' : 'pending') as ApprovalMemberStatus,
}));
setChain(activatedChain);
onSave({ approvalChain: activatedChain, ct: { ...ct, status: 'on_approval' }, approvalSent: true });
const firstApprover = chain.find((m) => m.role !== 'Инициатор' && m.spApprovalId);
if (firstApprover?.spApprovalId) {
try {
await service.triggerApprovalNotification(firstApprover.spApprovalId);
} catch (e) {
console.error('[Step4] triggerApprovalNotification (send) failed for spId', firstApprover.spApprovalId, e);
setNotifyWarning('СТ отправлена на согласование, но уведомление в Teams не удалось отправить. Проверьте Power Automate.');
}
}
onStepComplete(4);
} catch (err) {
setSubmitError((err as Error).message ?? 'Ошибка отправки на согласование');
} finally {
setIsSubmitting(false);
}
};
const handleSendToIncore = async (): Promise<void> => {
if (!ct) return;
setIsSendingToIncore(true);
setSubmitError(null);
try {
await service.generateCtReport(ct.id);
const kpFiles = formData.uploadedKpFiles ?? [];
for (const f of kpFiles) {
await service.downloadKpFile(f.name);
await new Promise<void>((resolve) => setTimeout(resolve, 300));
}
} catch (err) {
setSubmitError((err as Error).message ?? 'Ошибка скачивания файлов');
} finally {
setIsSendingToIncore(false);
}
};
const handleRestart = async (): Promise<void> => {
if (!ct) return;
setIsSubmitting(true);
setSubmitError(null);
try {
const resetChain = chain.map((m) => ({
...m,
status: 'pending' as ApprovalMemberStatus,
comment: undefined,
actionDate: undefined,
}));
await service.updateCTStatus(ct.id, 'formed');
setChain(resetChain);
onSave({ approvalChain: resetChain, ct: { ...ct, status: 'formed' }, approvalSent: false });
} catch (err) {
setSubmitError((err as Error).message ?? 'Ошибка перезапуска согласования');
} finally {
setIsSubmitting(false);
}
};
const isOnApproval = ct?.status === 'on_approval' || ct?.status === 'approved';
const showStatuses =
formData.approvalSent ||
ct?.status === 'on_approval' ||
ct?.status === 'approved' ||
ct?.status === 'rejected';
const isInitiator = chain.some((m) => m.userName === userDisplayName && m.role === 'Инициатор');
const currentUserMember = chain.find(
(m) =>
m.userName === userDisplayName &&
m.status === 'pending' &&
m.role !== 'Инициатор' &&
chain.filter((prev) => prev.order < m.order).every((prev) => prev.status !== 'pending'),
);
return {
ct,
chain,
submitError,
notifyWarning,
dismissNotifyWarning: () => setNotifyWarning(null),
isSubmitting,
isSendingToIncore,
isOnApproval,
showStatuses,
isInitiator,
currentUserMember,
// chain ops
moveMember,
handleDeleteMember,
openActionDialog,
openReplace,
// add member
showAddRow,
setShowAddRow,
addQuery,
addRole,
setAddRole,
addSuggestions,
addSelected,
isAddingMember,
handleAddQueryChange,
selectAddSuggestion,
cancelAddRow,
handleAddMember,
// action dialog
actionDialog,
setActionComment,
closeActionDialog,
handleActionConfirm,
// replace dialog
replaceDialog,
isReplacing,
handleReplaceQueryChange,
selectReplaceSuggestion,
closeReplace,
handleReplaceConfirm,
// footer
handleSendForApproval,
handleSendToIncore,
handleRestart,
};
}
@@ -0,0 +1,35 @@
// МОСТ семантики формы CT → бренд-токены TNSU (@tnsu/ui-kit, зеркало в src/styles).
// Имена переменных сохранены (был Fluent-синий) — все *.module.scss шагов, которые
// делают `@import '../common/theme'`, перекрашиваются в navy/gold БЕЗ своих правок.
// Источник цвета — только бренд-токены ниже. Точное зеркало forms/kps/components/common/_theme.scss
// (тот же исходный Fluent-набор, тот же маппинг — сверено 1:1).
@import '../../../../../../styles/design-tokens';
// ---- Акцент (титул, текущий шаг, ссылки, «рекомендуемый поставщик») → navy ----
$accent: $navy-800; // был #0078d4
$accent-bg: $navy-50; // был #e8f2fc / #deecf9 / #e9f1fc / #f0f6fc / #f0f6ff / #cce5ff
$accent-border: $navy-100; // был #c7e0f4
$accent-dark: $navy-950; // тёмный текст/акцент (был #004578 / #003a6c)
$accent-hover: $navy-700; // ховер акцентных кнопок (был #106ebe)
// ---- Статусы ----
$ok: $green-600;
$ok-bg: $green-50;
$danger: $red-500;
$danger-bg: $red-50;
$warn-fg: $amber-700; // тёмно-жёлтый текст (был #7d5900)
$warn-bg: $amber-50; // жёлтый фон предупреждений (был #fff4ce / #fffde7 / #fffbf5)
// ---- Текст/границы/фоны ----
$text: $gray-900;
$muted: $gray-500;
$muted-2: $gray-400; // приглушённый серый (был #a19f9d)
$border: $gray-200;
$border-strong: $gray-300;
$head-bg: $gray-50; // фон шапок таблиц
$hover: $gray-100; // ховер строк/кнопок
$surface: $white;
// ---- Кремовая панель поставщика (Шаг 3) → gold ----
$supplier-bg: $gold-50;
$supplier-border: $gold-100;
@@ -0,0 +1,176 @@
import { useState, useCallback, useEffect } from 'react';
import { CT, FormStep, Position, Supplier, CurrencyRate, SupplierOffer, ApprovalMember } from '../types/domain';
export interface CTFormData {
ct: CT | undefined;
selectedProject: { id: string; name: string; code: string } | undefined;
selectedPositions: Position[];
suppliers: Supplier[];
currencyRates: CurrencyRate[];
offers: SupplierOffer[];
recommendedSupplierId: string | undefined;
approvalChain: ApprovalMember[];
initiatorDepartment: string;
initiatorCompany: string;
step3ExtraSuppliers: Array<{ id: string; name: string; currency: string; spId?: number; status?: string }>;
step3ExtraPositions: Array<{ id: string; materialName: string; unit: string; spId?: number; isDelivery?: boolean }>;
step3DeliveryKm?: Record<string, number>; // key = `${posId}::${supId}`
uploadedKpFiles: Array<{ name: string; spId: number; supplierName?: string }>;
approvalSent: boolean;
step3PositionQtyOverrides?: Record<string, number>;
step3CriteriaEdits?: Record<string, { paymentType: string; deliveryType: string; deadline: string; sertificates: string }>;
step3CustomCriteria?: Array<{ id: string; label: string; values: Record<string, string> }>;
}
const INITIAL_FORM_DATA: CTFormData = {
ct: undefined,
selectedProject: undefined,
selectedPositions: [],
suppliers: [],
currencyRates: [],
offers: [],
recommendedSupplierId: undefined,
approvalChain: [],
initiatorDepartment: '',
initiatorCompany: '',
step3ExtraSuppliers: [],
step3ExtraPositions: [],
uploadedKpFiles: [],
approvalSent: false,
step3PositionQtyOverrides: {},
step3CriteriaEdits: {},
step3CustomCriteria: [],
};
const SESSION_KEY_PREFIX = 'ct_form_';
interface PersistedFormState {
formData: CTFormData;
currentStep: FormStep;
completedSteps: FormStep[];
highestStep: FormStep;
}
function getUrlCtId(): string | undefined {
const params = new URLSearchParams(window.location.search);
// Accept 'ctId' (our format) and 'ctid' (Power Automate notification link)
return params.get('ctId') ?? params.get('ctid') ?? undefined;
}
function setUrlCtId(ctId: string): void {
const params = new URLSearchParams(window.location.search);
params.set('ctId', ctId);
history.replaceState(null, '', `?${params.toString()}`);
}
function readSession(ctId: string): PersistedFormState | null {
try {
const raw = sessionStorage.getItem(`${SESSION_KEY_PREFIX}${ctId}`);
return raw ? (JSON.parse(raw) as PersistedFormState) : null;
} catch {
return null;
}
}
function writeSession(ctId: string, state: PersistedFormState): void {
try {
sessionStorage.setItem(`${SESSION_KEY_PREFIX}${ctId}`, JSON.stringify(state));
} catch { /* quota exceeded or private browsing */ }
}
export interface UseCtFormResult {
currentStep: FormStep;
formData: CTFormData;
initialCtId: string | undefined;
isNotificationLink: boolean;
goToStep: (step: FormStep) => void;
goNext: () => void;
goPrev: () => void;
updateFormData: (partial: Partial<CTFormData>) => void;
completedSteps: FormStep[];
markStepComplete: (step: FormStep) => void;
highestStep: FormStep;
ensureHighestStep: (step: FormStep) => void;
}
export function useCtForm(): UseCtFormResult {
const [initialCtId] = useState<string | undefined>(getUrlCtId);
const isNotificationLink = new URLSearchParams(window.location.search).has('ctid');
const [currentStep, setCurrentStep] = useState<FormStep>(() => {
const saved = initialCtId ? readSession(initialCtId) : null;
return saved?.currentStep ?? 1;
});
const [formData, setFormData] = useState<CTFormData>(() => {
const saved = initialCtId ? readSession(initialCtId) : null;
return saved?.formData ?? INITIAL_FORM_DATA;
});
const [completedSteps, setCompletedSteps] = useState<FormStep[]>(() => {
const saved = initialCtId ? readSession(initialCtId) : null;
return saved?.completedSteps ?? [];
});
const [highestStep, setHighestStep] = useState<FormStep>(() => {
const saved = initialCtId ? readSession(initialCtId) : null;
return saved?.highestStep ?? 1;
});
// Persist state to sessionStorage whenever it changes (once ctId is known)
useEffect(() => {
const ctId = formData.ct?.id;
if (!ctId) return;
writeSession(ctId, { formData, currentStep, completedSteps, highestStep });
}, [formData, currentStep, completedSteps, highestStep]);
const goToStep = useCallback((step: FormStep) => {
setCurrentStep(step);
setHighestStep(prev => Math.max(prev, step) as FormStep);
}, []);
const goNext = useCallback(() => {
setCurrentStep(s => {
const next = Math.min(4, s + 1) as FormStep;
setHighestStep(prev => Math.max(prev, next) as FormStep);
return next;
});
}, []);
const goPrev = useCallback(() => {
setCurrentStep(s => (Math.max(1, s - 1) as FormStep));
}, []);
const updateFormData = useCallback((partial: Partial<CTFormData>) => {
setFormData(prev => {
const next = { ...prev, ...partial };
if (partial.ct?.id && partial.ct.id !== prev.ct?.id) {
setUrlCtId(partial.ct.id);
}
return next;
});
}, []);
const markStepComplete = useCallback((step: FormStep) => {
setCompletedSteps(prev => prev.indexOf(step) !== -1 ? prev : [...prev, step]);
}, []);
const ensureHighestStep = useCallback((step: FormStep) => {
setHighestStep(prev => Math.max(prev, step) as FormStep);
}, []);
return {
currentStep,
formData,
initialCtId,
isNotificationLink,
goToStep,
goNext,
goPrev,
updateFormData,
completedSteps,
markStepComplete,
highestStep,
ensureHighestStep,
};
}
@@ -0,0 +1,55 @@
import { useState, useEffect } from 'react';
import { Request } from '../types/domain';
import { CtService } from '../../services/CtService';
interface UseRequestsResult {
requests: Request[];
isLoading: boolean;
error: string | undefined;
reload: () => void;
}
export function useRequests(service: CtService, days: number, skip?: boolean): UseRequestsResult {
const [requests, setRequests] = useState<Request[]>([]);
const [isLoading, setIsLoading] = useState(!skip);
const [error, setError] = useState<string | undefined>(undefined);
const [reloadToken, setReloadToken] = useState(0);
useEffect(() => {
if (skip) {
setIsLoading(false);
return;
}
let cancelled = false;
setIsLoading(true);
setError(undefined);
service
.fetchRequests(days)
.then(data => {
if (!cancelled) {
setRequests(data);
setIsLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError((err as Error).message ?? 'Ошибка загрузки потребностей');
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [service, days, reloadToken, skip]);
return {
requests,
isLoading,
error,
reload: () => setReloadToken(t => t + 1),
};
}
@@ -0,0 +1,147 @@
export interface Project {
id: string;
name: string;
code: string;
}
export interface Position {
id: string;
spId?: number; // SP item ID in Позиции СТ (populated after createCTPositions)
requestId: string; // GUID потребности
requestNumber: string; // demandNumber — номер потребности (e.g. "081/26-04/0017")
materialName: string; // position — наименование позиции
disciplineCode: string; // disciplineCode — марка
smrType: string; // SMR — тип СМР
unit: string;
agskCode: string;
uniqueCode: string;
quantity: number;
positionType: string; // 'ТМЦ' | 'Дополнительные затраты'
deadline: string;
organization?: string; // copied from parent Request for read-only display
requestCreatedAt?: string; // copied from parent Request for read-only display
}
export interface Request {
id: string;
requestNumber: string;
project: Project;
discipline: string;
workType: string;
organization: string;
createdAt: string;
positions: Position[];
}
export interface CT {
id: string;
number: string; // sequential number within project
projectName: string; // 'project' SP field
status?: CTStatus; // not in SP list, tracked in memory only
}
export type CTStatus =
| "draft"
| "kp_loading"
| "kp_loaded"
| "formed"
| "on_approval"
| "approved"
| "rejected";
export interface Supplier {
id: string;
spId?: number; // SP item ID in Поставщики (populated after saveSupplierWithOffer)
name: string;
inn?: string; // UI only — not stored in SP
hasOffer?: boolean; // determines target list: Поставщики vs Не получены КП
status?: string;
currency?: string; // currency code for KP (KZT by default); stored here so Step 3 can show correct columns even if offers are empty
}
export interface SyntexFields {
customer?: string;
documentNumber?: string;
deadline?: string;
fileContent?: string;
}
export interface FlowResult {
position?: string;
unit?: string;
conditions?: string;
deliveryType?: string;
deadline?: string;
sertificates?: string;
cost?: number;
price?: number;
quantity?: number;
supplier?: string;
}
export interface CurrencyRate {
id?: string; // SP item ID — needed for lookup in Оценка поставщиков
code: string; // 'code' SP field
name: string; // 'Title' SP field
rateToKzt: number; // 'course' SP field
courseDate?: string; // 'courseDate' SP field
}
export interface SupplierOffer {
id: string;
supplierId: string;
positionId: string;
itemIndex?: number; // bundle sub-row index (0 = default/only row)
supplierPosition?: string;
unit?: string;
quantity: number;
pricePerUnit: number; // 'price' SP field
totalPriceKzt: number; // 'summa' SP field
currencyCode: string; // 'valuta' lookup — code value
pricePerUnitKzt: number; // 'priceTenge' SP field
totalWithExtraKzt: number; // 'summaTenge' SP field
deliveryCostKzt?: number; // UI only — not in SP list
additionalCostsKzt?: number; // UI only — not in SP list
comment?: string;
offerId?: string; // 'offer' lookup — document library item ID
deliveryDistanceKm?: number; // km for delivery rows — stored in 'Оценка поставщиков'
}
export interface SupplierCriteria {
id?: string;
supplierId: string;
deliveryType: string; // DAP | FOB | DDP | EXW
paymentType: string;
deadline: string;
certificates: string; // Да | Нет
comment?: string;
}
export interface ApprovalMember {
id: string;
order: number;
userId: string;
userName: string;
role?: string;
status: ApprovalMemberStatus;
comment?: string;
actionDate?: string;
spApprovalId?: number;
canAddApprover?: boolean;
}
export type ApprovalMemberStatus =
| "pending"
| "recommends"
| "approved"
| "rejected"
| "skipped";
export type FormStep = 1 | 2 | 3 | 4;
export const FORM_STEPS: Array<{ step: FormStep; label: string }> = [
{ step: 1, label: "Выбор позиций" },
{ step: 2, label: "Загрузка КП" },
{ step: 3, label: "Формирование СТ" },
{ step: 4, label: "Согласование" },
];
@@ -0,0 +1,75 @@
// Линейный пересчёт количества/цены между совместимыми единицами измерения.
// Применяется в СТ: тумблер «Перевести в ед. изм. заявки» переводит кол-во и цену
// поставщика из ед. КП в ед. изм. позиции заявки.
// Несовместимые размерности (напр. кг ↔ м³ — нужна плотность) не пересчитываются.
interface UnitDef {
dim: 'mass' | 'length' | 'area' | 'volume';
toBase: number; // множитель к базовой единице размерности
}
// Базовые единицы: масса — кг, длина — м, площадь — м², объём — л.
const UNIT_TABLE: Record<string, UnitDef> = {
// масса (база — кг)
'мг': { dim: 'mass', toBase: 0.000001 },
'г': { dim: 'mass', toBase: 0.001 },
'гр': { dim: 'mass', toBase: 0.001 },
'грамм': { dim: 'mass', toBase: 0.001 },
'кг': { dim: 'mass', toBase: 1 },
'килограмм': { dim: 'mass', toBase: 1 },
'ц': { dim: 'mass', toBase: 100 },
'центнер': { dim: 'mass', toBase: 100 },
'т': { dim: 'mass', toBase: 1000 },
'тн': { dim: 'mass', toBase: 1000 },
'тонна': { dim: 'mass', toBase: 1000 },
'тонн': { dim: 'mass', toBase: 1000 },
// длина (база — м)
'мм': { dim: 'length', toBase: 0.001 },
'см': { dim: 'length', toBase: 0.01 },
'дм': { dim: 'length', toBase: 0.1 },
'м': { dim: 'length', toBase: 1 },
'метр': { dim: 'length', toBase: 1 },
'пм': { dim: 'length', toBase: 1 },
'км': { dim: 'length', toBase: 1000 },
// площадь (база — м²)
'мм2': { dim: 'area', toBase: 0.000001 },
'см2': { dim: 'area', toBase: 0.0001 },
'дм2': { dim: 'area', toBase: 0.01 },
'м2': { dim: 'area', toBase: 1 },
// объём (база — л)
'мл': { dim: 'volume', toBase: 0.001 },
'см3': { dim: 'volume', toBase: 0.001 },
'л': { dim: 'volume', toBase: 1 },
'дм3': { dim: 'volume', toBase: 1 },
'м3': { dim: 'volume', toBase: 1000 },
};
// Приводим написание единицы к ключу таблицы: нижний регистр, без пробелов,
// ³/² → 3/2, частые синонимы (куб.м → м3, кв.м → м2, пог.м → пм), без хвостовых точек.
export function normalizeUnit(raw: string | undefined): string {
let u = (raw ?? '').toLowerCase().trim();
u = u.replace(/³/g, '3').replace(/²/g, '2');
u = u.replace(/\s+/g, '');
u = u.replace(/^куб\.?м3?$/, 'м3').replace(/^кубм$/, 'м3');
u = u.replace(/^кв\.?м2?$/, 'м2').replace(/^квм$/, 'м2');
u = u.replace(/^пог\.?м$/, 'пм');
u = u.replace(/\.+$/, '');
return u;
}
// true, если единицы по сути одинаковые (тн == т == Тонна).
export function unitsEquivalent(a: string | undefined, b: string | undefined): boolean {
return normalizeUnit(a) === normalizeUnit(b);
}
// Пересчитывает qty из fromUnit в toUnit. Возвращает null, если единицы
// неизвестны или относятся к разным размерностям (пересчёт невозможен).
// convertQuantity(1, from, to) удобно использовать как коэффициент пересчёта:
// кол-во_в_ед_заявки = qty_поставщика * ratio
// цена_за_ед_заявки = цена_поставщика / ratio
export function convertQuantity(qty: number, fromUnit: string | undefined, toUnit: string | undefined): number | null {
const from = UNIT_TABLE[normalizeUnit(fromUnit)];
const to = UNIT_TABLE[normalizeUnit(toUnit)];
if (!from || !to || from.dim !== to.dim) return null;
return qty * (from.toBase / to.toBase);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
@import '../common/theme';
.stepperWrap {
width: 30vw;
min-width: 320px;
margin: 0 auto 0 0;
@include below($bp-md) {
width: 100%;
min-width: 0;
}
}
@@ -0,0 +1,30 @@
import * as React from 'react';
import { Stepper } from '@tnsu/ui-kit';
import { FormStep, FormStepMeta } from '../types/domain';
import styles from './Breadcrumbs.module.scss';
export interface BreadcrumbsProps {
steps: ReadonlyArray<FormStepMeta>;
currentStep: FormStep;
completedSteps: FormStep[];
highestStep: FormStep; // максимально доступный шаг (§5.5)
onStepClick: (step: FormStep) => void;
}
// Адаптер над Stepper из @tnsu/ui-kit: кружки/галочки/коннекторы + адаптивность (подписи прячутся
// на узких экранах). Кликабельны только шаги в пределах highestStep. Контракт props формы сохранён.
export function Breadcrumbs(props: BreadcrumbsProps): JSX.Element {
const { steps, currentStep, completedSteps, highestStep, onStepClick } = props;
return (
<div className={styles.stepperWrap}>
<Stepper
steps={steps.map((s) => ({ id: s.step, label: s.label }))}
currentId={currentStep}
completedIds={completedSteps}
highestId={highestStep}
onStepClick={(id) => onStepClick(id as FormStep)}
/>
</div>
);
}
@@ -0,0 +1,11 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
// Props корневого компонента: контекст SPFx + данные пользователя/темы (§4).
export interface IKpsCtFormProps {
context: WebPartContext;
userDisplayName: string;
isDarkTheme: boolean;
/** URL сайта со списками KPS (напр. /sites/kps), когда форма встроена в портал
* на другом сайте. Не задан — текущий сайт (самостоятельная форма). */
siteUrl?: string;
}
@@ -0,0 +1,39 @@
@import './common/theme';
// Full-width (§5.7): снимаем max-width канвы SharePoint и у воркбенча (dev), и на живой странице.
// !important здесь оправдан — перебиваем встроенные стили канвы, иначе неотключаемые.
// Если после обновления платформы ширина «вернётся» — проверить актуальные селекторы в DevTools.
:global {
#workbenchPageContent,
.CanvasComponent.LCS .CanvasZone {
max-width: 100% !important;
}
}
.root {
min-height: 100vh; // тянем на всю высоту вьюпорта
color: var(--bodyText);
*,
*::before,
*::after {
box-sizing: border-box;
}
}
.inner {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
}
// Единый вид состояний loading/error/empty (§8).
.state {
padding: 24px;
font-size: 14px;
}
.error {
color: $danger;
}
@@ -0,0 +1,210 @@
import * as React from 'react';
import { useEffect, useMemo, useState } from 'react';
import styles from './KpsCtForm.module.scss';
import { IKpsCtFormProps } from './IKpsCtFormProps';
import { KpsCtFormService } from '../services/KpsCtFormService';
import { useKpsCtForm } from './hooks/useKpsCtForm';
import { Breadcrumbs } from './Breadcrumbs/Breadcrumbs';
import { FormHeader } from './common/FormHeader';
import { Step1SelectPositions } from './Step1SelectPositions/Step1SelectPositions';
import { Step2LoadKp } from './Step2LoadKp/Step2LoadKp';
import { Step3BuildCt } from './Step3BuildCt/Step3BuildCt';
import { Step4Approval } from './Step4Approval/Step4Approval';
import { loadFormState, readEntityFromUrl, sessionKey } from './utils/session';
import {
CtFormData,
CtStatus,
CT_STATUS_META,
EMPTY_FORM_DATA,
FORM_STEPS,
FormStep,
READ_ONLY_STATUSES,
StepProps
} from './types/domain';
// Главный контейнер (§4): мемоизирует сервис, держит стейт через хук, рендерит шаги.
export default function KpsCtForm(props: IKpsCtFormProps): JSX.Element {
const { context, userDisplayName } = props;
// Единственное место создания сервиса — мемоизируем на context (§5.3).
const service = useMemo(() => new KpsCtFormService(context, props.siteUrl), [context, props.siteUrl]);
// id сущности и признак ссылки из уведомления читаем из URL один раз (§5.4).
const url = useMemo(() => readEntityFromUrl(), []);
const form = useKpsCtForm(url.ctId);
const { formData, currentStep, completedSteps, highestStep } = form;
// Загружаем свежие данные из SharePoint только если это ссылка из уведомления
// ИЛИ в сессии пусто — иначе не затираем несохранённые правки (§5.4).
const shouldLoadFromSp = useMemo(
() => !!url.ctId && (url.isNotificationLink || loadFormState(sessionKey(url.ctId)) === undefined),
[url.ctId, url.isNotificationLink]
);
const spLoad = useSharePointLoad(service, form.replaceFormData, form.goToStep, form.markStepComplete, url, shouldLoadFromSp);
// Режимы отображения (§5.5): read-only по статусу + отдельный флаг ограниченного доступа.
const status: CtStatus = deriveCtStatus(formData);
const isReadOnly = READ_ONLY_STATUSES.indexOf(status) !== -1;
// Для КПС ограничение по компании (скрытие цен, как у СТ для astel-k) не требуется — подтверждено (2026-08-28).
const isRestrictedView = false;
if (spLoad.isLoading) {
return <div className={styles.state}>Загрузка…</div>;
}
if (spLoad.error) {
return <div className={`${styles.state} ${styles.error}`}>Ошибка загрузки: {spLoad.error}</div>;
}
const stepProps: StepProps = {
service,
formData,
onSave: form.updateFormData,
onNext: form.next,
onPrev: form.prev,
onStepComplete: form.markStepComplete,
isReadOnly,
isRestrictedView,
userDisplayName,
isNotificationLink: url.isNotificationLink
};
const title = formData.ctId ? `Сравнительная таблица №${formData.ctId}` : 'Новая сравнительная таблица';
return (
<section className={styles.root}>
<div className={styles.inner}>
<FormHeader title={title} statusLabel={CT_STATUS_META[status].label} statusKind={CT_STATUS_META[status].kind} />
<Breadcrumbs
steps={FORM_STEPS}
currentStep={currentStep}
completedSteps={completedSteps}
highestStep={highestStep}
onStepClick={form.goToStep}
/>
{renderStep(currentStep, stepProps)}
</div>
</section>
);
}
// Рендер текущего шага. Все шаги получают единый контракт props (§5.2).
function renderStep(step: FormStep, stepProps: StepProps): JSX.Element {
switch (step) {
case 1:
return <Step1SelectPositions {...stepProps} />;
case 2:
return <Step2LoadKp {...stepProps} />;
case 3:
return <Step3BuildCt {...stepProps} />;
case 4:
return <Step4Approval {...stepProps} />;
default:
return <React.Fragment />;
}
}
// Деривация статуса сущности из цепочки согласования (§12), по образцу CT (deriveCtStatus в
// CtCreateForm.tsx). До появления цепочки (Шаги 1–3) статус берём как есть из formData.status —
// его пишут явно (Step1 → 'draft', Step4 → 'on_approval'/'approved'/'rejected'/'formed').
// Когда в цепочке есть участники, статус выводим из их решений — это подстраховка на случай,
// если formData.status не успел обновиться (напр. цепочка подгружена из SP при повторном входе).
function deriveCtStatus(formData: CtFormData): CtStatus {
const chain = formData.approvers;
if (chain.length === 0) return formData.status;
if (chain.some((m) => m.status === 'rejected')) return 'rejected';
if (chain.every((m) => m.status === 'approved' || m.status === 'recommends' || m.status === 'skipped')) return 'approved';
// 'recommends' у инициатора появляется только при активации цепочки (отправке на согласование,
// handleSendForApproval → useStep4Approval); просто добавленные в черновик участники — 'pending',
// это ещё не «на согласовании».
if (chain.some((m) => m.status === 'recommends')) return 'on_approval';
return formData.status;
}
// Самый дальний «заполненный» шаг существующей СТ — для приземления при входе по URL (режим редактирования).
// По фактическим данным реконструкции: цепочка/на согласовании → 4, есть офферы → 3, есть поставщики → 2,
// иначе (есть позиции) → 1.
function furthestFilledStep(data: CtFormData): FormStep {
if (data.approvers.length > 0 || data.status === 'on_approval' || data.status === 'approved' || data.status === 'rejected') {
return 4;
}
if (data.offers.length > 0) {
return 3;
}
if (data.suppliers.length > 0) {
return 2;
}
return 1;
}
interface SpLoadState {
isLoading: boolean;
error?: string;
}
// Подгрузка сущности из SharePoint (ссылка из уведомления / восстановление при пустой сессии).
// Отменяет setState на unmount (§8). По уведомлению открывает Шаг 3 — он главный (§12).
function useSharePointLoad(
service: KpsCtFormService,
replaceFormData: (data: CtFormData) => void,
goToStep: (step: FormStep) => void,
markStepComplete: (step: FormStep) => void,
url: { ctId?: number; isNotificationLink: boolean },
shouldLoad: boolean
): SpLoadState {
const [state, setState] = useState<SpLoadState>({ isLoading: shouldLoad });
useEffect(() => {
if (!shouldLoad || !url.ctId) {
return undefined;
}
let cancelled = false;
setState({ isLoading: true });
const run = async (): Promise<void> => {
try {
const data = await service.loadCt(url.ctId as number);
if (cancelled) {
return;
}
const loaded = { ...EMPTY_FORM_DATA, ...data, ctId: url.ctId };
replaceFormData(loaded);
if (url.isNotificationLink) {
// Согласующий по ссылке: открываем Шаг 3 (главный) и открываем навигацию — Шаги 1–3 к этому
// моменту уже пройдены инициатором (СТ «на согласовании»), поэтому помечаем их пройденными
// (→ highestStep=4, доступны все шаги), как в эталоне.
goToStep(3);
markStepComplete(1);
markStepComplete(2);
markStepComplete(3);
} else {
// Обычный вход по URL (режим редактирования): открываем навигацию по фактическому прогрессу
// и приземляем на самый дальний заполненный шаг.
const furthest = furthestFilledStep(loaded);
for (let s = 1; s <= Math.min(furthest, 3); s++) {
markStepComplete(s as FormStep);
}
if (loaded.status === 'approved' || loaded.status === 'rejected') {
markStepComplete(4);
}
goToStep(furthest);
}
setState({ isLoading: false });
} catch (error) {
if (!cancelled) {
setState({ isLoading: false, error: (error as Error).message });
}
}
};
// run() сам ловит ошибки внутрь state; .catch — чтобы промис не «висел» необработанным.
run().catch(() => undefined);
return () => {
cancelled = true;
};
}, [service, replaceFormData, goToStep, markStepComplete, url.ctId, url.isNotificationLink, shouldLoad]);
return state;
}
@@ -0,0 +1,46 @@
import * as React from 'react';
import { Checkbox } from '@tnsu/ui-kit';
import styles from './Step1SelectPositions.module.scss';
import { PurchaseRequestDetail } from '../types/domain';
import { UseStep1SelectionResult } from './useStep1Selection';
interface RequestLinesProps {
detail: PurchaseRequestDetail;
selection: UseStep1SelectionResult;
disabled?: boolean; // форма только для чтения
}
// Позиции (строки) заявки: таблица с чекбоксами. То, что попадёт в СТ.
// Цены на Шаге 1 не показываем — они появятся на Шагах 2–3 (КП/сравнение).
export function RequestLines({ detail, selection, disabled }: RequestLinesProps): JSX.Element {
return (
<div className={styles.lines}>
<div className={`${styles.lineRow} ${styles.lineHead}`}>
<span />
<span>№</span>
<span>Код</span>
<span>Наименование</span>
<span>Кат. №</span>
<span>Кол-во</span>
<span>Ед. изм.</span>
</div>
{detail.lines.map((line) => (
<div key={line.id} className={styles.lineRow}>
<Checkbox
checked={selection.selectedIds.has(line.id)}
onChange={() => selection.toggleLine(detail, line)}
disabled={disabled}
ariaLabel={`Выбрать позицию ${line.name}`}
/>
<span>{line.lineNo}</span>
<span>{line.code || '—'}</span>
<span className={styles.lineName}>{line.name}</span>
<span>{line.catalogNumber || '—'}</span>
<span>{line.quantity}</span>
<span>{line.unit}</span>
</div>
))}
</div>
);
}
@@ -0,0 +1,87 @@
import * as React from 'react';
import { Checkbox, Icon } from '@tnsu/ui-kit';
import styles from './Step1SelectPositions.module.scss';
import { Pill } from '../common/Pill';
import { PurchaseRequest, PurchaseRequestDetail } from '../types/domain';
import { RequestState } from '../hooks/useRequests';
import { UseStep1SelectionResult } from './useStep1Selection';
import { RequestLines } from './RequestLines';
import { formatDateTime } from '../utils/format';
interface RequestRowProps {
request: PurchaseRequest;
expanded: boolean;
disabled: boolean; // заявка другого проекта — выбор недоступен (фиксация одного проекта)
detailState?: RequestState<PurchaseRequestDetail>;
selection: UseStep1SelectionResult;
isReadOnly?: boolean;
onToggleExpand: () => void;
onToggleSelectAll: () => void;
}
// Раскрытое тело заявки: состояния загрузки/ошибки/пусто или таблица позиций (§8).
function renderBody(
detailState: RequestState<PurchaseRequestDetail> | undefined,
selection: UseStep1SelectionResult,
isReadOnly?: boolean
): JSX.Element {
if (!detailState || detailState.isLoading) {
return <div className={styles.reqBody}>Загрузка позиций…</div>;
}
if (detailState.error) {
return <div className={`${styles.reqBody} ${styles.error}`}>Ошибка: {detailState.error.message}</div>;
}
if (!detailState.data || detailState.data.lines.length === 0) {
return <div className={styles.reqBody}>В заявке нет позиций.</div>;
}
return <RequestLines detail={detailState.data} selection={selection} disabled={isReadOnly} />;
}
// Заявка (потребность) — строка таблицы + раскрытие в позиции по клику.
export function RequestRow(props: RequestRowProps): JSX.Element {
const { request, expanded, disabled, detailState, selection, isReadOnly, onToggleExpand, onToggleSelectAll } = props;
const detail = detailState?.data;
const total = detail?.lines.length ?? 0;
const allSelected = detail !== undefined && selection.isRequestFullySelected(detail);
const someSelected = selection.selectedCountForRequest(request.id) > 0 && !allSelected;
const onKeyDown = (event: React.KeyboardEvent): void => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggleExpand();
}
};
return (
<div className={disabled ? `${styles.request} ${styles.requestDisabled}` : styles.request}>
<div
className={styles.trow}
role="button"
tabIndex={disabled ? -1 : 0}
aria-expanded={expanded}
onClick={disabled ? undefined : onToggleExpand}
onKeyDown={disabled ? undefined : onKeyDown}
>
<span className={styles.cellCheck} onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={allSelected}
indeterminate={someSelected}
disabled={disabled || isReadOnly}
onChange={onToggleSelectAll}
ariaLabel={`Выбрать все позиции заявки ${request.number}`}
/>
</span>
<Icon name={expanded ? 'chevron-down' : 'chevron-right'} size="sm" className={styles.chevron} />
<span className={styles.reqNumber}>{request.number}</span>
<span className={styles.ellipsis}>{request.projectName || '—'}</span>
<span className={styles.ellipsis}>{request.vehicleName || '—'}</span>
<span className={styles.ellipsis}>{request.initiatorFullName || '—'}</span>
<span>{formatDateTime(request.createdAt)}</span>
<span className={styles.cellPositions}>{detail ? <Pill kind="neutral">{total}</Pill> : '—'}</span>
</div>
{expanded ? renderBody(detailState, selection, isReadOnly) : undefined}
</div>
);
}
@@ -0,0 +1,146 @@
import * as React from 'react';
import { useState } from 'react';
import { Checkbox, Icon } from '@tnsu/ui-kit';
import styles from './Step1SelectPositions.module.scss';
import { Pill } from '../common/Pill';
import { SelectedPosition } from '../types/domain';
interface Step1ReadOnlyViewProps {
positions: SelectedPosition[];
}
interface RequestGroup {
key: string;
number: string;
projectName: string;
positions: SelectedPosition[];
}
// Группируем сохранённые позиции по номеру потребности. При реконструкции из SP requestId пустой,
// поэтому ключ — requestNumber; если и он пуст — одна общая группа.
function groupByRequest(positions: SelectedPosition[]): RequestGroup[] {
const map = new Map<string, RequestGroup>();
positions.forEach((p) => {
const key = p.requestNumber || p.requestId || '—';
const group = map.get(key);
if (group) {
group.positions.push(p);
} else {
map.set(key, { key, number: p.requestNumber || '—', projectName: p.projectName, positions: [p] });
}
});
return Array.from(map.values());
}
// Позиции группы (раскрытие потребности) — только для чтения. Цены на Шаге 1 не показываем.
function ReadOnlyLines({ positions }: { positions: SelectedPosition[] }): JSX.Element {
return (
<div className={styles.lines}>
<div className={`${styles.lineRow} ${styles.lineHead}`}>
<span />
<span>№</span>
<span>Код</span>
<span>Наименование</span>
<span>Кат. №</span>
<span>Кол-во</span>
<span>Ед. изм.</span>
</div>
{positions.map((p, i) => (
<div key={p.lineId} className={styles.lineRow}>
<Checkbox checked disabled onChange={() => undefined} ariaLabel={`Позиция ${p.name} (выбрана)`} />
<span>{i + 1}</span>
<span>{p.code || '—'}</span>
<span className={styles.lineName}>{p.name}</span>
<span>{p.catalogNumber || '—'}</span>
<span>{p.quantity}</span>
<span>{p.unit}</span>
</div>
))}
</div>
);
}
// Шаг 1 в режиме просмотра готовой СТ: таблица строится из сохранённых позиций (formData),
// БЕЗ обращения к API заявок. Иначе показывались бы актуальные заявки за последние N дней, не
// относящиеся к этой СТ (у сохранённых позиций ключ = SP Id, а не lineId из API — не сматчить).
export function Step1ReadOnlyView({ positions }: Step1ReadOnlyViewProps): JSX.Element {
const [expanded, setExpanded] = useState<Set<string>>(new Set());
if (positions.length === 0) {
return <p className={styles.state}>В этой СТ нет сохранённых позиций.</p>;
}
const groups = groupByRequest(positions);
const projectName = positions[0].projectName;
const toggle = (key: string): void => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const onKeyDown = (event: React.KeyboardEvent, key: string): void => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggle(key);
}
};
return (
<div className={styles.wrap}>
<div className={styles.lockBanner}>
Проект: <b>{projectName || '—'}</b>. Позиций в СТ: {positions.length}.
</div>
<div className={styles.tableScroll}>
<div className={styles.table}>
<div className={`${styles.trow} ${styles.thead}`}>
<span />
<span />
<span>№ потребности</span>
<span>Проект</span>
<span>Техника</span>
<span>Инициатор</span>
<span>Дата создания</span>
<span className={styles.cellPositions}>Позиций</span>
</div>
{groups.map((group) => (
<div key={group.key} className={styles.request}>
<div
className={styles.trow}
role="button"
tabIndex={0}
aria-expanded={expanded.has(group.key)}
onClick={() => toggle(group.key)}
onKeyDown={(e) => onKeyDown(e, group.key)}
>
<span className={styles.cellCheck} onClick={(e) => e.stopPropagation()}>
<Checkbox checked disabled onChange={() => undefined} ariaLabel={`Потребность ${group.number} (выбрана)`} />
</span>
<Icon name={expanded.has(group.key) ? 'chevron-down' : 'chevron-right'} size="sm" className={styles.chevron} />
<span className={styles.reqNumber}>{group.number}</span>
<span className={styles.ellipsis}>{group.projectName || '—'}</span>
<span className={styles.ellipsis}>—</span>
<span className={styles.ellipsis}>—</span>
<span>—</span>
<span className={styles.cellPositions}>
<Pill kind="neutral">{group.positions.length}</Pill>
</span>
</div>
{expanded.has(group.key) ? <ReadOnlyLines positions={group.positions} /> : undefined}
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,148 @@
@import '../common/theme';
.wrap {
display: flex;
flex-direction: column;
gap: 12px;
}
// Состояния списка (loading/error/empty), §8.
.state {
margin: 0;
color: $muted;
}
.error {
color: $danger;
}
.lockBanner {
padding: 8px 12px;
border-left: 3px solid $accent;
background: $accent-bg;
color: $text;
font-size: 13px;
}
.footHint {
margin: 0;
color: $muted;
font-style: italic;
font-size: 13px;
}
// --- Таблица заявок ---
.tableScroll {
overflow-x: auto;
border: 1px solid $border;
border-radius: 4px;
}
.table {
min-width: 900px; // ниже — горизонтальный скролл, чтобы колонки не схлопывались
}
// Общий грид строки: [чекбокс][стрелка] № | проект | техника | инициатор | дата | позиций.
.trow {
display: grid;
grid-template-columns: 40px 24px 140px minmax(140px, 1fr) minmax(160px, 1.4fr) 160px 140px 90px;
align-items: center;
gap: 10px;
padding: 10px 12px;
font-size: 13px;
}
.thead {
background: $head-bg;
font-weight: 600;
color: $muted;
}
// Строка-потребность.
.request {
border-top: 1px solid $border;
.trow {
cursor: pointer;
&:hover {
background: $hover;
}
}
}
.requestDisabled {
opacity: 0.5;
.trow {
cursor: default;
&:hover {
background: none;
}
}
}
.cellCheck {
display: inline-flex;
align-items: center;
}
// Стрелка раскрытия — SVG-иконка ui-kit (размер задаётся size="sm", цвет — currentColor).
.chevron {
color: $muted;
}
.reqNumber {
font-weight: 600;
color: $text;
}
// Обрезаем длинные значения многоточием (проект/техника/инициатор).
.ellipsis {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $muted;
}
.cellPositions {
text-align: right;
}
// --- Позиции (раскрытие заявки) ---
.reqBody {
padding: 10px 12px;
border-top: 1px solid $border;
color: $muted;
}
.lines {
border-top: 1px solid $border;
background: $surface;
}
// Табличная строка позиции: [чекбокс] № | код | наименование | кат.№ | кол-во | ед. изм.
.lineRow {
display: grid;
grid-template-columns: 36px 44px 130px minmax(180px, 1fr) 120px 90px 90px;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-top: 1px solid $border;
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.lineHead {
color: $muted;
font-weight: 600;
background: $head-bg;
}
.lineName {
font-variant-numeric: normal;
}
@@ -0,0 +1,175 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import { StepShell } from '../common/StepShell';
import { PurchaseRequest, StepProps } from '../types/domain';
import { useRequests } from '../hooks/useRequests';
import { usePurchaseRequestDetails } from '../hooks/usePurchaseRequestDetails';
import { useStep1Selection } from './useStep1Selection';
import { RequestRow } from './RequestRow';
import { Step1ReadOnlyView } from './Step1ReadOnlyView';
import styles from './Step1SelectPositions.module.scss';
// Шаг 1 «Выбор позиций» (§12): таблица заявок TNSU → раскрытие в позиции → выбор строк.
// Все выбранные позиции — из одного проекта; выбор живёт в formData (источник истины).
export function Step1SelectPositions(props: StepProps): JSX.Element {
const { service, formData, onSave, onNext, onStepComplete, isReadOnly } = props;
// В режиме просмотра готовой СТ в API заявок НЕ ходим — таблица строится из сохранённых позиций
// (Step1ReadOnlyView). Иначе показывались бы актуальные заявки, не относящиеся к этой СТ.
const requests = useRequests<PurchaseRequest[]>(
() => (isReadOnly ? Promise.resolve([]) : service.getPurchaseRequests()),
[service, isReadOnly]
);
const { details, loadDetail } = usePurchaseRequestDetails(service);
const selection = useStep1Selection(formData, onSave);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
// Заявки, у которых по чекбоксу «выбрать все» ждём догрузки позиций, чтобы выделить их.
const [pendingSelectAll, setPendingSelectAll] = useState<Set<string>>(new Set());
// Создание СТ на «Далее»: индикатор + ошибка.
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined);
const openRequest = (id: string): void => {
setExpanded((prev) => new Set(prev).add(id));
loadDetail(id); // повторный вызов безопасен (кэш в хуке)
};
const toggleExpand = (request: PurchaseRequest): void => {
if (!expanded.has(request.id)) {
openRequest(request.id);
return;
}
setExpanded((prev) => {
const next = new Set(prev);
next.delete(request.id);
return next;
});
};
const toggleSelectAll = (request: PurchaseRequest): void => {
const detail = details[request.id]?.data;
if (detail) {
selection.toggleAllLines(detail);
return;
}
// Позиции ещё не загружены — раскрываем, грузим и выбираем все по готовности.
openRequest(request.id);
setPendingSelectAll((prev) => new Set(prev).add(request.id));
};
// Догруженные заявки из очереди «выбрать все» — выделяем и убираем из очереди.
useEffect(() => {
if (pendingSelectAll.size === 0) {
return;
}
const done: string[] = [];
pendingSelectAll.forEach((id) => {
const detail = details[id]?.data;
if (detail) {
selection.selectAllLines(detail);
done.push(id);
}
});
if (done.length > 0) {
setPendingSelectAll((prev) => {
const next = new Set(prev);
done.forEach((id) => next.delete(id));
return next;
});
}
}, [details, pendingSelectAll, selection]);
const handleNext = (): void => {
// СТ уже создана (повторный вход) или read-only — просто идём дальше, без повторного POST.
if (formData.ctId || isReadOnly) {
onStepComplete(1);
onNext?.();
return;
}
setSaving(true);
setSaveError(undefined);
service
.createCt(formData)
.then(({ ctId, positionSpIds }) => {
// сохраняем SP Id созданных позиций в formData — нужны для офферов на Шаге 3
const selectedPositions = formData.selectedPositions.map((p) => ({ ...p, spId: positionSpIds[p.lineId] }));
onSave({ ctId, status: 'draft', selectedPositions }); // ctId → в URL (эффект хука), форма продолжится после перезагрузки
onStepComplete(1);
onNext?.();
})
.catch((e: Error) => {
setSaveError(e.message);
setSaving(false); // при успехе шаг сменится — стейт не трогаем (размонтирование)
});
};
return (
<StepShell
title="Выбор позиций"
onNext={handleNext}
nextLabel={saving ? 'Создание…' : isReadOnly ? 'Далее' : 'Создать и далее'}
nextDisabled={saving || (!isReadOnly && selection.selectedCount === 0)}
>
{isReadOnly ? <Step1ReadOnlyView positions={formData.selectedPositions} /> : renderBody()}
</StepShell>
);
function renderBody(): JSX.Element {
if (requests.isLoading) {
return <p className={styles.state}>Загрузка заявок…</p>;
}
if (requests.error) {
return <p className={`${styles.state} ${styles.error}`}>Ошибка загрузки заявок: {requests.error.message}</p>;
}
const list = requests.data ?? [];
if (list.length === 0) {
return <p className={styles.state}>Заявок на закупку нет.</p>;
}
return (
<div className={styles.wrap}>
{selection.lockedProjectName ? (
<div className={styles.lockBanner}>
Проект зафиксирован: <b>{selection.lockedProjectName}</b>. Позиции — только из него. Выбрано позиций:{' '}
{selection.selectedCount}.
</div>
) : undefined}
<div className={styles.tableScroll}>
<div className={styles.table}>
<div className={`${styles.trow} ${styles.thead}`}>
<span />
<span />
<span>№ потребности</span>
<span>Проект</span>
<span>Техника</span>
<span>Инициатор</span>
<span>Дата создания</span>
<span className={styles.cellPositions}>Позиций</span>
</div>
{list.map((request) => (
<RequestRow
key={request.id}
request={request}
expanded={expanded.has(request.id)}
disabled={!!selection.lockedProjectName && request.projectName !== selection.lockedProjectName}
detailState={details[request.id]}
selection={selection}
isReadOnly={isReadOnly}
onToggleExpand={() => toggleExpand(request)}
onToggleSelectAll={() => toggleSelectAll(request)}
/>
))}
</div>
</div>
{saveError ? (
<p className={`${styles.state} ${styles.error}`}>Не удалось создать СТ: {saveError}</p>
) : undefined}
<p className={styles.footHint}>Выберите позиции для формирования СТ.</p>
</div>
);
}
}
@@ -0,0 +1,135 @@
import { useCallback, useMemo } from 'react';
import {
CtFormData,
PurchaseRequestDetail,
PurchaseRequestLine,
SelectedPosition
} from '../types/domain';
export interface UseStep1SelectionResult {
selectedIds: Set<string>; // id выбранных строк — для быстрой проверки в UI
selectedCount: number;
lockedProjectId?: string; // проект зафиксирован после первого выбора
lockedProjectName?: string; // имя зафиксированного проекта (в списке заявок есть только имя)
selectedCountForRequest: (requestId: string) => number; // для бейджа на свёрнутой строке
isRequestFullySelected: (detail: PurchaseRequestDetail) => boolean;
toggleLine: (detail: PurchaseRequestDetail, line: PurchaseRequestLine) => void;
toggleAllLines: (detail: PurchaseRequestDetail) => void;
selectAllLines: (detail: PurchaseRequestDetail) => void; // идемпотентно добавить все строки
}
// Собираем позицию-снимок из строки заявки и её деталей.
function toSelectedPosition(detail: PurchaseRequestDetail, line: PurchaseRequestLine): SelectedPosition {
return {
lineId: line.id,
code: line.code,
name: line.name,
catalogNumber: line.catalogNumber,
quantity: line.quantity,
unit: line.unit,
estimatedUnitPrice: line.estimatedUnitPrice,
estimatedAmount: line.estimatedAmount,
requestId: detail.id,
requestNumber: detail.number,
vehicleName: detail.vehicleName,
projectId: detail.projectId,
projectCode: detail.projectCode,
projectName: detail.projectName
};
}
// Выбор позиций Шага 1. Источник истины — formData.selectedPositions (§5.1):
// пишем через onSave, так выбор переживает переходы между шагами и персист в сессии.
export function useStep1Selection(
formData: CtFormData,
onSave: (partial: Partial<CtFormData>) => void
): UseStep1SelectionResult {
const selected = formData.selectedPositions;
const selectedIds = useMemo(() => new Set(selected.map((p) => p.lineId)), [selected]);
// Проект берём из первой выбранной позиции — все остальные обязаны совпадать (бизнес-правило).
const first = selected[0];
const lockedProjectId = first?.projectId;
const lockedProjectName = first?.projectName;
// Сколько позиций выбрано у каждой заявки — считаем один раз на изменение выбора.
const countByRequest = useMemo(() => {
const acc: Record<string, number> = {};
selected.forEach((p) => {
acc[p.requestId] = (acc[p.requestId] ?? 0) + 1;
});
return acc;
}, [selected]);
const commit = useCallback(
(next: SelectedPosition[]): void => {
onSave({
selectedPositions: next,
lockedProjectId: next[0]?.projectId,
selectedProject: next[0]?.projectName
});
},
[onSave]
);
const isRequestFullySelected = useCallback(
(detail: PurchaseRequestDetail): boolean =>
detail.lines.length > 0 && detail.lines.every((line) => selectedIds.has(line.id)),
[selectedIds]
);
const toggleLine = useCallback(
(detail: PurchaseRequestDetail, line: PurchaseRequestLine): void => {
if (selectedIds.has(line.id)) {
commit(selected.filter((p) => p.lineId !== line.id));
} else {
commit([...selected, toSelectedPosition(detail, line)]);
}
},
[selected, selectedIds, commit]
);
const toggleAllLines = useCallback(
(detail: PurchaseRequestDetail): void => {
const lineIds = detail.lines.map((line) => line.id);
if (isRequestFullySelected(detail)) {
commit(selected.filter((p) => lineIds.indexOf(p.lineId) === -1));
} else {
const additions = detail.lines
.filter((line) => !selectedIds.has(line.id))
.map((line) => toSelectedPosition(detail, line));
commit([...selected, ...additions]);
}
},
[selected, selectedIds, isRequestFullySelected, commit]
);
const selectAllLines = useCallback(
(detail: PurchaseRequestDetail): void => {
const additions = detail.lines
.filter((line) => !selectedIds.has(line.id))
.map((line) => toSelectedPosition(detail, line));
if (additions.length > 0) {
commit([...selected, ...additions]);
}
},
[selected, selectedIds, commit]
);
const selectedCountForRequest = useCallback(
(requestId: string): number => countByRequest[requestId] ?? 0,
[countByRequest]
);
return {
selectedIds,
selectedCount: selected.length,
lockedProjectId,
lockedProjectName,
selectedCountForRequest,
isRequestFullySelected,
toggleLine,
toggleAllLines,
selectAllLines
};
}
@@ -0,0 +1,93 @@
@import '../common/theme';
.root {
display: flex;
flex-direction: column;
gap: 10px;
}
.hint {
margin: 0;
color: $muted;
font-size: 13px;
}
.muted {
margin: 0;
color: $muted;
font-size: 13px;
font-style: italic;
}
.presets {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.presetsLabel {
font-size: 13px;
color: $muted;
}
.presetBtn {
height: 28px;
font-size: 12px;
}
.nbkRow {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.error {
flex: 1 1 220px;
}
.table {
width: auto;
align-self: flex-start;
border-collapse: collapse;
font-size: 13px;
th {
text-align: left;
font-weight: 600;
color: $muted;
padding: 6px 12px;
border-bottom: 1px solid $border;
}
td {
padding: 6px 12px;
vertical-align: middle;
}
tr:hover td {
background: $head-bg;
}
}
.code {
display: inline-block;
background: $border;
color: $text;
border-radius: 3px;
padding: 2px 6px;
font-family: monospace;
font-size: 12px;
font-weight: 600;
}
.rateInput {
max-width: 160px;
}
.courseDate {
color: $muted;
font-size: 12px;
white-space: nowrap;
}
@@ -0,0 +1,171 @@
import * as React from 'react';
import { useState } from 'react';
import { Button, IconButton, Alert, Spinner, TextInput, Icon } from '@tnsu/ui-kit';
import { CurrencyRate } from '../types/domain';
import { formatDate } from '../utils/format';
import styles from './CurrencyRatesForm.module.scss';
interface CurrencyRatesFormProps {
rates: CurrencyRate[];
onChange: (rates: CurrencyRate[]) => void;
readOnly?: boolean;
}
// Пресеты валют для быстрого добавления (name — для отображения; currency = код).
const PRESET_CURRENCIES: Array<{ currency: string; name: string }> = [
{ currency: 'USD', name: 'Доллар США' },
{ currency: 'EUR', name: 'Евро' },
{ currency: 'RUB', name: 'Российский рубль' },
{ currency: 'CNY', name: 'Китайский юань' }
];
// Бесплатный API курсов (без ключа): база USD, кросс-курсом считаем каждую валюту к KZT.
const RATES_API_URL = 'https://open.er-api.com/v6/latest/USD';
// Блок «Курсы валют» Шага 2: добавление валют, ручной ввод курса и авто-подтягивание актуального курса.
export function CurrencyRatesForm({ rates, onChange, readOnly = false }: CurrencyRatesFormProps): JSX.Element {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const addCurrency = (preset: { currency: string; name: string }): void => {
if (rates.some((r) => r.currency === preset.currency)) {
return;
}
onChange([...rates, { currency: preset.currency, name: preset.name, rate: 0 }]);
};
const updateRate = (currency: string, value: string): void => {
const parsed = parseFloat(value.replace(',', '.'));
// Дата курса = момент ручного ввода (когда курс зафиксирован пользователем).
const now = new Date().toISOString();
onChange(
rates.map((r) => (r.currency === currency ? { ...r, rate: isNaN(parsed) ? 0 : parsed, courseDate: now } : r))
);
};
const removeRate = (currency: string): void => {
onChange(rates.filter((r) => r.currency !== currency));
};
const fetchLiveRates = (): void => {
if (rates.length === 0) {
return;
}
setLoading(true);
setError(undefined);
fetch(RATES_API_URL)
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
})
.then((data: { result: string; rates: Record<string, number> }) => {
const usdToKzt = data.rates?.KZT;
if (data.result !== 'success' || !usdToKzt) {
throw new Error('Курс не получен');
}
// Дата курса = момент запроса актуального курса.
const now = new Date().toISOString();
onChange(
rates.map((r) => {
const rateToUsd = data.rates[r.currency];
if (!rateToUsd) {
return r;
}
const rateToKzt = r.currency === 'USD' ? usdToKzt : usdToKzt / rateToUsd;
return { ...r, rate: Math.round(rateToKzt * 100) / 100, courseDate: now };
})
);
setLoading(false);
})
.catch(() => {
setError('Не удалось получить курсы. Проверьте подключение к интернету.');
setLoading(false);
});
};
const presetsToShow = PRESET_CURRENCIES.filter((p) => !rates.some((r) => r.currency === p.currency));
return (
<div className={styles.root}>
{!readOnly ? (
<p className={styles.hint}>Укажите курсы валют к тенге. Если все КП в тенге — оставьте пустым.</p>
) : undefined}
{!readOnly && presetsToShow.length > 0 ? (
<div className={styles.presets}>
<span className={styles.presetsLabel}>Добавить:</span>
{presetsToShow.map((p) => (
<Button key={p.currency} variant="secondary" size="sm" className={styles.presetBtn} onClick={() => addCurrency(p)}>
{p.currency} — {p.name}
</Button>
))}
</div>
) : undefined}
{!readOnly && rates.length > 0 ? (
<div className={styles.nbkRow}>
<Button variant="secondary" onClick={fetchLiveRates} disabled={loading}>
{loading ? (
<Spinner size="small" />
) : (
<>
<Icon name="download" size="sm" />
Получить актуальный курс
</>
)}
</Button>
{error ? <Alert variant="err">{error}</Alert> : undefined}
</div>
) : undefined}
{rates.length === 0 ? (
<p className={styles.muted}>Все предложения в тенге (KZT).</p>
) : (
<table className={styles.table}>
<thead>
<tr>
<th>Валюта</th>
<th>Код</th>
<th>Курс к KZT</th>
<th>Дата курса</th>
{!readOnly ? <th /> : undefined}
</tr>
</thead>
<tbody>
{rates.map((rate) => (
<tr key={rate.currency}>
<td>{rate.name ?? rate.currency}</td>
<td>
<span className={styles.code}>{rate.currency}</span>
</td>
<td>
{readOnly ? (
<span>{rate.rate === 0 ? '—' : `${rate.rate} KZT`}</span>
) : (
<div className={styles.rateInput}>
<TextInput
value={rate.rate === 0 ? '' : String(rate.rate)}
onChange={(val) => updateRate(rate.currency, val)}
placeholder="0.00 KZT"
/>
</div>
)}
</td>
<td>
<span className={styles.courseDate}>{formatDate(rate.courseDate)}</span>
</td>
{!readOnly ? (
<td>
<IconButton icon="trash" danger title={`Удалить ${rate.currency}`} onClick={() => removeRate(rate.currency)} />
</td>
) : undefined}
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
@@ -0,0 +1,94 @@
@import '../common/theme';
.fileList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.fileItem {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid $border;
border-radius: 4px;
font-size: 13px;
}
.fileIcon {
color: $accent;
}
.fileName {
color: $text;
text-decoration: none;
&[href]:hover {
color: $accent;
text-decoration: underline;
}
}
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 8px;
border-radius: 10px;
font-size: 12px;
background: $head-bg;
color: $muted;
}
.badge_done {
background: $ok-bg;
color: $ok;
}
.badge_error {
background: $danger-bg;
color: $danger;
}
.spinner {
display: inline-block;
}
.progress {
color: $muted;
font-size: 12px;
}
.supplierTag {
padding: 1px 8px;
border-radius: 10px;
background: $accent-bg;
color: $accent;
font-size: 12px;
}
.currency {
padding: 2px 6px;
border: 1px solid $border-strong;
border-radius: 4px;
font-size: 12px;
}
.errorMsg {
color: $danger;
font-size: 12px;
}
.removeBtn {
margin-left: auto;
background: none;
border: none;
cursor: pointer;
color: $muted;
font-size: 14px;
line-height: 1;
}
@@ -0,0 +1,87 @@
import * as React from 'react';
import { Icon, Spinner, IconButton } from '@tnsu/ui-kit';
import { KpFile, KpFileStatus } from './kpTypes';
import styles from './KpFileList.module.scss';
// Список загруженных файлов КП со статусом обработки (заливка/Syntex/поток), поставщиком и валютой.
// Презентационный компонент: всё поведение приходит через props (стайл-гайд).
interface Props {
files: KpFile[];
currencyOptions: string[];
onRemove: (localId: string) => void;
onCurrencyChange: (localId: string, currency: string) => void;
disabled?: boolean;
}
const STATUS_LABEL: Record<KpFileStatus, string> = {
uploading: 'Загрузка…',
extracting: 'Обработка…',
flowing: 'Разбор КП…',
done: 'Готово',
error: 'Ошибка'
};
const isBusy = (status: KpFileStatus): boolean =>
status === 'uploading' || status === 'extracting' || status === 'flowing';
// Модификатор-класс бейджа задан только для done/error; для остальных — только базовый.
const badgeModifier = (status: KpFileStatus): string =>
status === 'done' ? styles.badge_done : status === 'error' ? styles.badge_error : '';
// Родитель рендерит список только при непустом files (см. Step2LoadKp).
export function KpFileList(props: Props): JSX.Element {
const { files, currencyOptions, onRemove, onCurrencyChange, disabled } = props;
return (
<ul className={styles.fileList}>
{files.map((f) => (
<li key={f.localId} className={styles.fileItem}>
<Icon name="document" size="sm" className={styles.fileIcon} />
{f.url ? (
<a className={styles.fileName} href={f.url} target="_blank" rel="noreferrer">
{f.file.name}
</a>
) : (
<span className={styles.fileName}>{f.file.name}</span>
)}
<span className={`${styles.badge} ${badgeModifier(f.status)}`.trim()}>
{isBusy(f.status) ? <Spinner size="xSmall" className={styles.spinner} /> : undefined}
{STATUS_LABEL[f.status]}
</span>
{f.status === 'flowing' && f.flowTotal > 0 ? (
<span className={styles.progress}>
{f.flowDone}/{f.flowTotal} позиций
</span>
) : undefined}
{f.supplierName ? <span className={styles.supplierTag}>{f.supplierName}</span> : undefined}
{f.status === 'done' && currencyOptions.length > 0 ? (
<select
className={styles.currency}
value={f.currencyCode}
onChange={(e) => onCurrencyChange(f.localId, e.currentTarget.value)}
aria-label={`Валюта КП ${f.file.name}`}
disabled={disabled}
>
{currencyOptions.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
) : undefined}
{f.error ? <span className={styles.errorMsg}>{f.error}</span> : undefined}
{!disabled && !isBusy(f.status) ? (
<IconButton icon="close" size="sm" danger title="Удалить файл" onClick={() => onRemove(f.localId)} />
) : undefined}
</li>
))}
</ul>
);
}
@@ -0,0 +1,221 @@
@import '../common/theme';
.body {
display: flex;
flex-direction: column;
gap: 32px;
}
.grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
align-items: start;
}
// Узкий экран — колонки в одну.
@media (max-width: 900px) {
.grid2 {
grid-template-columns: 1fr;
}
}
.section {
display: flex;
flex-direction: column;
gap: 12px;
}
// Заголовок секции с разделительной линией снизу (как в эталоне).
.h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: $text;
padding-bottom: 8px;
border-bottom: 1px solid $border;
}
.hint {
margin: 0;
color: $muted;
font-size: 13px;
}
.muted {
margin: 0;
color: $muted;
font-size: 13px;
font-style: italic;
}
.error {
margin: 0;
color: $danger;
font-size: 13px;
}
// --- Зона загрузки ---
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 40px 24px;
border: 2px dashed $border-strong;
border-radius: 8px;
background: $head-bg;
color: $muted;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
&:hover {
border-color: $accent;
background: $accent-bg;
}
}
.dropzoneOver {
border-color: $accent;
background: $accent-bg;
}
.dropzoneDisabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
&:hover {
border-color: $border-strong;
background: $head-bg;
}
}
.dropIcon {
width: 40px;
height: 40px;
color: $muted-2;
margin-bottom: 6px;
}
.dropText {
font-size: 14px;
color: $text;
}
.link {
color: $accent;
text-decoration: underline;
}
.formats {
font-size: 12px;
color: $muted-2;
}
.hiddenInput {
display: none;
}
.fileList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.fileItem {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid $border;
border-radius: 4px;
font-size: 13px;
}
.fileIcon {
color: $accent;
}
// --- Курсы валют ---
.rateList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.rateItem {
display: flex;
justify-content: space-between;
padding: 6px 10px;
border: 1px solid $border;
border-radius: 4px;
font-size: 13px;
}
// --- Поставщики без КП ---
.supplierForm {
display: flex;
align-items: flex-end;
gap: 8px;
flex-wrap: wrap;
max-width: 860px;
}
.supplierName {
flex: 1 1 260px;
}
.supplierComment {
flex: 1 1 200px;
}
.supplierList {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
max-width: 860px;
}
.supplierItem {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border: 1px solid $border;
border-radius: 4px;
background: $head-bg;
font-size: 13px;
}
.supplierItemName {
font-weight: 600;
}
.supplierItemComment {
color: $muted;
font-style: italic;
}
.removeBtn {
margin-left: auto;
background: none;
border: none;
cursor: pointer;
color: $muted;
font-size: 14px;
}
@@ -0,0 +1,178 @@
import * as React from 'react';
import { useRef, useState } from 'react';
import { Alert } from '@tnsu/ui-kit';
import { StepShell } from '../common/StepShell';
import { StepProps } from '../types/domain';
import { SuppliersWithoutKp } from './SuppliersWithoutKp';
import { CurrencyRatesForm } from './CurrencyRatesForm';
import { KpFileList } from './KpFileList';
import { UploadedKpList } from './UploadedKpList';
import { useKpProcessing } from './useKpProcessing';
import { persistStep2 } from './step2Persist';
import styles from './Step2LoadKp.module.scss';
const ACCEPTED_FORMATS = '.pdf,.docx,.doc';
// Шаг 2 «Загрузка КП»: заливка коммерческих предложений в библиотеку, обработка потоком (Syntex →
// поток по позициям → офферы), курсы валют, поставщики без КП. Конвейер — в useKpProcessing/kpPipeline;
// запись на «Далее» — в step2Persist. Компонент только компонует блоки и колбэки (стайл-гайд).
export function Step2LoadKp(props: StepProps): JSX.Element {
const { service, formData, onSave, onPrev, onNext, onStepComplete, isReadOnly } = props;
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined);
const { kpFiles, isProcessing, duplicateWarning, addFiles, removeFile, setCurrency } = useKpProcessing(
service,
formData,
!!isReadOnly
);
const suppliers = formData.suppliers ?? [];
const withOffer = suppliers.filter((s) => s.hasOffer !== false); // поставщики с КП — не трогаем здесь
const noKp = suppliers.filter((s) => s.hasOffer === false); // поставщики без КП (блок ниже)
const rates = formData.currencyRates ?? [];
const currencyOptions = ['KZT', ...rates.filter((r) => r.currency !== 'KZT').map((r) => r.currency)];
// Постоянный список КП (из formData) минус файлы текущей сессии (они уже видны в KpFileList со статусом).
const uploadedKp = (formData.uploadedKpFiles ?? []).filter((u) => !kpFiles.some((f) => f.file.name === u.name));
const removeUploaded = (spId: number): void =>
onSave({ uploadedKpFiles: (formData.uploadedKpFiles ?? []).filter((u) => u.spId !== spId) });
const pickFiles = (list: FileList | null): void => {
if (list && list.length > 0) {
addFiles(Array.from(list));
}
};
const onDrop = (event: React.DragEvent): void => {
event.preventDefault();
setDragOver(false);
if (!isReadOnly) {
pickFiles(event.dataTransfer.files);
}
};
const handleNext = (): void => {
if (isReadOnly) {
onStepComplete(2);
onNext?.();
return;
}
if (isProcessing) {
return; // не уходим, пока файлы обрабатываются
}
setSaving(true);
setSaveError(undefined);
persistStep2({ service, ctId: formData.ctId, kpFiles, formData })
.then((patch) => {
onSave(patch);
onStepComplete(2);
onNext?.();
})
.catch((e: Error) => {
setSaveError(e.message);
setSaving(false);
});
};
return (
<StepShell
title="Загрузка КП"
onPrev={onPrev}
onNext={handleNext}
nextLabel={saving ? 'Сохранение…' : 'Далее'}
nextDisabled={saving || isProcessing}
>
<div className={styles.body}>
<div className={styles.grid2}>
<section className={styles.section}>
<h3 className={styles.h3}>Загрузка коммерческих предложений</h3>
<div
className={[styles.dropzone, dragOver ? styles.dropzoneOver : '', isReadOnly ? styles.dropzoneDisabled : '']
.filter(Boolean)
.join(' ')}
onClick={() => {
if (!isReadOnly) {
inputRef.current?.click();
}
}}
onDragOver={(event) => {
event.preventDefault();
if (!isReadOnly) {
setDragOver(true);
}
}}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
>
<svg className={styles.dropIcon} viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path
d="M16 22V10M10 16l6-6 6 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<rect x="4" y="24" width="24" height="4" rx="2" fill="currentColor" opacity=".2" />
</svg>
<div className={styles.dropText}>
Перетащите файлы или <span className={styles.link}>выберите на диске</span>
</div>
<div className={styles.formats}>Поддерживаемые форматы: .pdf, .docx, .doc</div>
<input
ref={inputRef}
type="file"
multiple
accept={ACCEPTED_FORMATS}
className={styles.hiddenInput}
onChange={(event) => pickFiles(event.currentTarget.files)}
disabled={isReadOnly}
/>
</div>
{duplicateWarning ? <Alert variant="warn">{duplicateWarning}</Alert> : undefined}
{kpFiles.length > 0 ? (
<KpFileList
files={kpFiles}
currencyOptions={currencyOptions}
onRemove={removeFile}
onCurrencyChange={setCurrency}
disabled={isReadOnly}
/>
) : undefined}
{uploadedKp.length > 0 ? (
<UploadedKpList files={uploadedKp} onRemove={isReadOnly ? undefined : removeUploaded} />
) : undefined}
</section>
<section className={styles.section}>
<h3 className={styles.h3}>Курсы валют</h3>
<CurrencyRatesForm rates={rates} onChange={(next) => onSave({ currencyRates: next })} readOnly={isReadOnly} />
</section>
</div>
<SuppliersWithoutKp
items={noKp.map((s) => ({ name: s.name, comment: s.comment ?? '' }))}
onChange={(items) =>
onSave({
suppliers: [
...withOffer,
...items.map((i) => ({ id: `nokp_${i.name}`, name: i.name, comment: i.comment, hasOffer: false }))
]
})
}
disabled={isReadOnly}
/>
{saveError ? <p className={styles.error}>Не удалось сохранить Шаг 2: {saveError}</p> : undefined}
</div>
</StepShell>
);
}
@@ -0,0 +1,71 @@
import * as React from 'react';
import { useState } from 'react';
import { Button, TextInput, IconButton, Icon } from '@tnsu/ui-kit';
import { NoKpSupplier } from '../types/domain';
import styles from './Step2LoadKp.module.scss';
interface SuppliersWithoutKpProps {
items: NoKpSupplier[]; // источник истины — formData.noKpSuppliers
onChange: (items: NoKpSupplier[]) => void;
disabled?: boolean;
}
// Поставщики, не приславшие КП. Источник истины — formData (через onChange), запись в SP на «Далее».
export function SuppliersWithoutKp({ items, onChange, disabled }: SuppliersWithoutKpProps): JSX.Element {
const [name, setName] = useState('');
const [comment, setComment] = useState('');
const add = (): void => {
const trimmed = name.trim();
if (!trimmed) {
return;
}
// Дедуп по имени (без учёта регистра) — тот же поставщик не добавляется дважды.
if (items.some((i) => i.name.toLowerCase() === trimmed.toLowerCase())) {
setName('');
setComment('');
return;
}
onChange([...items, { name: trimmed, comment: comment.trim() }]);
setName('');
setComment('');
};
const remove = (index: number): void => {
onChange(items.filter((_, i) => i !== index));
};
return (
<section className={styles.section}>
<h3 className={styles.h3}>Поставщики без КП</h3>
<p className={styles.hint}>Укажите поставщиков, которые не предоставили коммерческое предложение.</p>
<div className={styles.supplierForm}>
<div className={styles.supplierName}>
<TextInput placeholder="Наименование поставщика" value={name} onChange={setName} readOnly={disabled} />
</div>
<div className={styles.supplierComment}>
<TextInput placeholder="Комментарий (необязательно)" value={comment} onChange={setComment} readOnly={disabled} />
</div>
<Button variant="secondary" onClick={add} disabled={disabled || !name.trim()}>
<Icon name="plus" size="sm" />
Добавить
</Button>
</div>
{items.length > 0 ? (
<ul className={styles.supplierList}>
{items.map((item, index) => (
<li key={`${item.name}-${index}`} className={styles.supplierItem}>
<span className={styles.supplierItemName}>{item.name}</span>
{item.comment ? <span className={styles.supplierItemComment}>{item.comment}</span> : undefined}
{!disabled ? (
<IconButton icon="close" size="sm" danger title="Удалить" onClick={() => remove(index)} />
) : undefined}
</li>
))}
</ul>
) : undefined}
</section>
);
}
@@ -0,0 +1,52 @@
@import '../common/theme';
.root {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
}
.title {
margin: 0;
font-size: 13px;
font-weight: 600;
color: $muted;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border: 1px solid $border;
border-radius: 4px;
font-size: 13px;
}
.icon {
color: $accent;
font-size: 16px;
}
.name {
font-weight: 500;
}
.supplier {
font-size: 12px;
color: $muted;
}
.spacer {
flex: 1 1 auto;
}
@@ -0,0 +1,39 @@
import * as React from 'react';
import { Icon, IconButton, Link } from '@tnsu/ui-kit';
import { UploadedKpFile } from '../types/domain';
import styles from './UploadedKpList.module.scss';
interface UploadedKpListProps {
files: UploadedKpFile[];
onRemove?: (spId: number) => void; // в read-only не передаём — кнопку удаления не показываем
}
// Постоянный список загруженных КП (из formData.uploadedKpFiles): переживает перезагрузку и вход по
// ссылке-уведомлению. Отличается от KpFileList — тот показывает файлы ТЕКУЩЕЙ сессии со статусами обработки.
// Пустой список не рендерим на уровне родителя (как KpFileList).
export function UploadedKpList({ files, onRemove }: UploadedKpListProps): JSX.Element {
return (
<div className={styles.root}>
<h4 className={styles.title}>Загруженные КП</h4>
<ul className={styles.list}>
{files.map((f) => (
<li key={f.spId} className={styles.row}>
<Icon name="document" size="sm" className={styles.icon} />
{f.url ? (
<Link href={f.url} target="_blank" className={styles.name}>
{f.name}
</Link>
) : (
<span className={styles.name}>{f.name}</span>
)}
{f.supplierName ? <span className={styles.supplier}>{f.supplierName}</span> : undefined}
<span className={styles.spacer} />
{onRemove ? (
<IconButton icon="trash" danger title={`Убрать ${f.name} из списка`} onClick={() => onRemove(f.spId)} />
) : undefined}
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,132 @@
import { CurrencyRate, SelectedPosition, Supplier, SupplierCriteria, SupplierOffer } from '../types/domain';
import { KpFile } from './kpTypes';
// Чистые сборщики доменных сущностей из обработанных файлов КП (без React, без сети).
// Поставщики с КП — по одному на файл с распознанным поставщиком. id = String(spId) для связи с офферами.
export function buildKpSuppliers(files: KpFile[]): Supplier[] {
return files
.filter((f) => f.supplierName)
.map((f) => ({
id: f.supplierSpId !== undefined ? String(f.supplierSpId) : f.localId,
spId: f.supplierSpId,
name: f.supplierName as string,
hasOffer: true,
currency: f.currencyCode
}));
}
// Офферы из результатов потока (позиция × поставщик). Цена/кол-во — в валюте поставщика.
export function buildKpOffers(files: KpFile[]): SupplierOffer[] {
const offers: SupplierOffer[] = [];
for (const f of files) {
if (f.supplierSpId === undefined || f.flowResults.length === 0) {
continue;
}
const supplierId = String(f.supplierSpId);
for (const fr of f.flowResults) {
offers.push({
supplierId,
positionId: fr.positionId,
itemIndex: 0,
supplierPosition: fr.flowResult.position,
unit: fr.flowResult.unit,
quantity: fr.flowResult.quantity ?? 0,
pricePerUnit: fr.flowResult.price ?? 0,
offerId: f.spId !== undefined ? String(f.spId) : undefined
});
}
}
return offers;
}
// Критерии из результатов потока — один набор на поставщика (первый файл с любым критерием).
export function buildKpCriteria(files: KpFile[]): SupplierCriteria[] {
const criteria: SupplierCriteria[] = [];
for (const f of files) {
if (f.supplierSpId === undefined) {
continue;
}
const entry = f.flowResults.find(
(r) => r.flowResult.conditions || r.flowResult.deliveryType || r.flowResult.deadline || r.flowResult.sertificates
);
if (!entry) {
continue;
}
const fr = entry.flowResult;
criteria.push({
supplierId: String(f.supplierSpId),
paymentType: fr.conditions,
deliveryType: fr.deliveryType,
deadline: fr.deadline,
sertificates: fr.sertificates
});
}
return criteria;
}
// Строка оффера для записи в SP (как ожидает service.saveOffers).
export interface OfferSpRow {
positionSpId: number;
supplierSpId: number;
supplierPosition: string;
unit: string;
itemIndex: number;
quantity: number;
price: number;
summa: number;
priceTenge: number;
summaTenge: number;
valutaSpId?: number;
offerSpId?: number;
}
// Маппинг офферов formData → строки SP: разрешаем positionId (lineId → spId), считаем ₸ по курсу.
export function buildOffersForSp(
offers: SupplierOffer[],
suppliers: Supplier[],
positions: SelectedPosition[],
rates: CurrencyRate[]
): { positionSpIds: number[]; rows: OfferSpRow[] } {
const posByLineId: Record<string, { spId: number; name: string; unit: string }> = {};
positions.forEach((p) => {
if (p.spId !== undefined) {
posByLineId[p.lineId] = { spId: p.spId, name: p.name, unit: p.unit };
}
});
const rows: OfferSpRow[] = [];
for (const o of offers) {
const sup = suppliers.find((s) => s.id === o.supplierId);
const pos = posByLineId[o.positionId];
if (sup?.spId === undefined || pos === undefined) {
continue;
}
const currency = sup.currency ?? 'KZT';
const rateObj = currency !== 'KZT' ? rates.find((r) => r.currency === currency) : undefined;
const rate = rateObj?.rate ?? 1;
const qty = o.quantity || 0;
const price = o.pricePerUnit || 0;
const summaTenge = qty * price * rate;
if (summaTenge <= 0) {
continue;
}
rows.push({
positionSpId: pos.spId,
supplierSpId: sup.spId,
supplierPosition: o.supplierPosition ?? pos.name,
unit: o.unit ?? pos.unit,
itemIndex: o.itemIndex,
quantity: qty,
price,
summa: qty * price,
priceTenge: price * rate,
summaTenge,
valutaSpId: rateObj?.spId,
offerSpId: o.offerId ? parseInt(o.offerId, 10) || undefined : undefined
});
}
const positionSpIds = Array.from(new Set(rows.map((r) => r.positionSpId)));
return { positionSpIds, rows };
}
@@ -0,0 +1,119 @@
import { KpsCtFormService } from '../../services/KpsCtFormService';
import { SelectedPosition } from '../types/domain';
import { FlowPositionResult, PatchFile } from './kpTypes';
// Чистый конвейер обработки файла КП (без React). Мирроринг эталона Step2CommercialOffers:
// заливка → (extractWordText для Word) → поллинг Syntex (file_content) → поток по каждой позиции.
const POLL_INTERVAL_MS = 5000;
const MAX_POLL_RETRIES = 60; // ~5 минут
export interface PipelineDeps {
service: KpsCtFormService;
getPositions: () => SelectedPosition[];
isMounted: () => boolean;
patch: PatchFile;
incFlowDone: (localId: string) => void;
}
const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
const isWordOrExcel = (name: string): boolean => /\.(doc|docx|xls|xlsx)$/i.test(name);
// Полный цикл одного файла. Бросает — вызывающий переводит файл в статус 'error'.
export async function processKpFile(file: File, localId: string, deps: PipelineDeps): Promise<void> {
const { service, patch } = deps;
// Уже загруженный (в этой СТ) файл с готовым разбором — не гоняем Syntex повторно.
let spId: number | undefined;
try {
const existing = await service.findExistingKpFile(file.name, file.size);
if (existing) {
spId = existing.spId;
}
} catch {
/* поиск не удался — грузим как обычно */
}
if (spId === undefined) {
const uploaded = await service.uploadKpFile(file);
spId = uploaded.spId;
patch(localId, { spId, url: uploaded.url, status: 'extracting' });
if (isWordOrExcel(file.name)) {
await service.extractWordText(spId);
}
} else {
patch(localId, { spId, status: 'extracting' });
}
await pollSyntex(localId, spId, file.name, 0, deps);
}
// Поллинг: ждём, пока Syntex проставит file_content (в kps это единственная колонка модели).
async function pollSyntex(
localId: string,
fileSpId: number,
fileName: string,
retries: number,
deps: PipelineDeps
): Promise<void> {
const { service, isMounted, patch } = deps;
if (!isMounted()) {
return;
}
if (retries >= MAX_POLL_RETRIES) {
patch(localId, { status: 'error', error: 'Syntex не завершил обработку за 5 минут' });
return;
}
try {
const fields = await service.getKpItemFields(fileSpId);
if (fields.fileContent) {
await runFlow(localId, fileSpId, fileName, deps);
} else {
await wait(POLL_INTERVAL_MS);
await pollSyntex(localId, fileSpId, fileName, retries + 1, deps);
}
} catch {
await wait(POLL_INTERVAL_MS);
await pollSyntex(localId, fileSpId, fileName, retries + 1, deps);
}
}
// Вызов потока по каждой выбранной позиции; сбой по одной позиции не останавливает остальные.
async function runFlow(localId: string, fileSpId: number, fileName: string, deps: PipelineDeps): Promise<void> {
const { service, getPositions, isMounted, patch, incFlowDone } = deps;
const positions = getPositions();
if (!isMounted()) {
return;
}
patch(localId, { status: 'flowing', flowTotal: positions.length, flowDone: 0, flowResults: [] });
const collected: FlowPositionResult[] = [];
await Promise.all(
positions.map(async (pos) => {
try {
const flowResult = await service.triggerKpFlow(fileName, fileSpId, pos.name);
collected.push({ positionId: pos.lineId, flowResult });
} catch {
/* сбой по одной позиции игнорируем */
}
if (isMounted()) {
incFlowDone(localId);
}
})
);
// Поставщик — из результата потока (в kps отдельной колонки customer нет).
const supplierName = collected.find((r) => r.flowResult.supplier)?.flowResult.supplier;
if (supplierName) {
try {
const supplierSpId = await service.saveSupplier(supplierName);
patch(localId, { supplierName, supplierSpId });
} catch {
/* продолжаем без поставщика */
}
}
patch(localId, { status: 'done', flowResults: collected });
}
@@ -0,0 +1,30 @@
import { FlowResult } from '../types/domain';
// Локальные типы обработки КП на Шаге 2 (стейт файла в процессе конвейера).
export type KpFileStatus = 'uploading' | 'extracting' | 'flowing' | 'done' | 'error';
// Результат потока по одной позиции (для последующей сборки офферов).
export interface FlowPositionResult {
positionId: string; // локальный id позиции (SelectedPosition.lineId)
flowResult: FlowResult;
}
// Файл КП в процессе обработки. Живёт в локальном стейте Шага 2; готовые (done) попадают в formData.
export interface KpFile {
localId: string;
file: File;
status: KpFileStatus;
spId?: number; // Id файла в библиотеке «Коммерческие предложения» (offerId для офферов)
url?: string; // ссылка на залитый файл
supplierName?: string; // распознанный поставщик (Syntex customer / поток supplier)
supplierSpId?: number; // Id поставщика в «Поставщики»
currencyCode: string; // валюта КП (KZT по умолчанию)
flowDone: number; // сколько позиций уже обработал поток
flowTotal: number; // всего позиций
error?: string;
flowResults: FlowPositionResult[];
}
// Точечное обновление файла в стейте (передаётся в конвейер вместо прямого доступа к React-стейту).
export type PatchFile = (localId: string, patch: Partial<KpFile>) => void;
@@ -0,0 +1,69 @@
import { CtFormData, Supplier, SupplierCriteria, SupplierOffer, UploadedKpFile } from '../types/domain';
import { KpsCtFormService } from '../../services/KpsCtFormService';
import { KpFile } from './kpTypes';
import { buildKpCriteria, buildKpOffers, buildKpSuppliers, buildOffersForSp } from './kpOffers';
// Чистая логика сохранения Шага 2 (без React) — вынесено из компонента ради читаемости (стайл-гайд).
// Порядок: курсы → поставщики без КП → офферы КП в SP → мерж КП-данных в formData.
export interface PersistStep2Input {
service: KpsCtFormService;
ctId?: number;
kpFiles: KpFile[];
formData: CtFormData;
}
export async function persistStep2(input: PersistStep2Input): Promise<Partial<CtFormData>> {
const { service, ctId, kpFiles, formData } = input;
const doneFiles = kpFiles.filter((f) => f.status === 'done');
// 1) Курсы валют — первыми: их spId нужны как valuta в офферах.
const savedRates = await service.saveCurrencyRates(formData.currencyRates ?? []);
// 2) Поставщики без КП → «Не получены КП», привязка к СТ (ctId) для восстановления у согласующего.
const noKp = (formData.suppliers ?? []).filter((s) => s.hasOffer === false);
for (const s of noKp) {
await service.saveNoKpSupplier(s.name, s.comment ?? '', ctId);
}
// 3) Сущности из обработанных КП.
const kpSuppliers = buildKpSuppliers(doneFiles);
const kpOffers = buildKpOffers(doneFiles);
const kpCriteria = buildKpCriteria(doneFiles);
// 4) Офферы → «Оценка поставщиков» (на Шаге 2, как эталон). Нужен ctId — позиции должны иметь spId.
if (ctId !== undefined && kpOffers.length > 0) {
const { positionSpIds, rows } = buildOffersForSp(kpOffers, kpSuppliers, formData.selectedPositions, savedRates);
if (rows.length > 0) {
await service.saveOffers(positionSpIds, rows);
}
}
// 5) Мерж КП-данных в formData: заменяем записи по поставщикам из этой загрузки.
const kpNames = new Set(kpSuppliers.map((s) => s.name));
const kpIds = new Set(kpSuppliers.map((s) => s.id));
const existing = formData.suppliers ?? [];
const suppliers: Supplier[] = [
...existing.filter((s) => s.hasOffer === false), // без-КП — управляются своим блоком, сохраняем
...existing.filter((s) => s.hasOffer !== false && !kpNames.has(s.name)), // прочие с-КП (напр. с Шага 3)
...kpSuppliers
];
const offers: SupplierOffer[] = [...(formData.offers ?? []).filter((o) => !kpIds.has(o.supplierId)), ...kpOffers];
const criteria: SupplierCriteria[] = [
...(formData.criteria ?? []).filter((c) => !kpIds.has(c.supplierId)),
...kpCriteria
];
const uploadedKpFiles = mergeUploaded(formData.uploadedKpFiles ?? [], doneFiles);
return { currencyRates: savedRates, suppliers, offers, criteria, uploadedKpFiles };
}
function mergeUploaded(existing: UploadedKpFile[], doneFiles: KpFile[]): UploadedKpFile[] {
const bySpId = new Map<number, UploadedKpFile>();
existing.forEach((u) => bySpId.set(u.spId, u));
doneFiles.forEach((f) => {
if (f.spId !== undefined) {
bySpId.set(f.spId, { spId: f.spId, name: f.file.name, supplierName: f.supplierName, url: f.url });
}
});
return Array.from(bySpId.values());
}
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { CtFormData } from '../types/domain';
import { KpsCtFormService } from '../../services/KpsCtFormService';
import { KpFile } from './kpTypes';
import { processKpFile } from './kpPipeline';
// Хук обработки КП на Шаге 2: держит стейт файлов и запускает конвейер (заливка → Syntex → поток).
// Логика конвейера — в kpPipeline.ts; здесь только React-стейт и колбэки (стайл-гайд: логика вне view).
let localIdCounter = 0;
export interface UseKpProcessingResult {
kpFiles: KpFile[];
isProcessing: boolean;
duplicateWarning?: string;
addFiles: (files: File[]) => void;
removeFile: (localId: string) => void;
setCurrency: (localId: string, currency: string) => void;
}
export function useKpProcessing(service: KpsCtFormService, formData: CtFormData, disabled: boolean): UseKpProcessingResult {
const [kpFiles, setKpFiles] = useState<KpFile[]>([]);
const [duplicateWarning, setDuplicateWarning] = useState<string | undefined>(undefined);
const mountedRef = useRef(true);
const formDataRef = useRef(formData);
const kpFilesRef = useRef<KpFile[]>([]);
useEffect(() => {
formDataRef.current = formData;
}, [formData]);
useEffect(() => {
kpFilesRef.current = kpFiles;
}, [kpFiles]);
useEffect(() => () => {
mountedRef.current = false;
}, []);
const patch = useCallback((localId: string, part: Partial<KpFile>) => {
if (!mountedRef.current) {
return;
}
setKpFiles((prev) => prev.map((f) => (f.localId === localId ? { ...f, ...part } : f)));
}, []);
const incFlowDone = useCallback((localId: string) => {
if (!mountedRef.current) {
return;
}
setKpFiles((prev) => prev.map((f) => (f.localId === localId ? { ...f, flowDone: f.flowDone + 1 } : f)));
}, []);
const addFiles = useCallback(
(files: File[]) => {
if (disabled || files.length === 0) {
return;
}
const known = new Set([
...kpFilesRef.current.map((f) => f.file.name),
...(formDataRef.current.uploadedKpFiles ?? []).map((f) => f.name)
]);
const fresh = files.filter((f) => !known.has(f.name));
const skipped = files.filter((f) => known.has(f.name));
setDuplicateWarning(skipped.length ? `Файл(ы) уже загружены: ${skipped.map((f) => f.name).join(', ')}` : undefined);
if (fresh.length === 0) {
return;
}
const entries: KpFile[] = fresh.map((file) => ({
localId: `kp_${++localIdCounter}`,
file,
status: 'uploading',
currencyCode: 'KZT',
flowDone: 0,
flowTotal: 0,
flowResults: []
}));
setKpFiles((prev) => [...prev, ...entries]);
const deps = {
service,
getPositions: () => formDataRef.current.selectedPositions,
isMounted: () => mountedRef.current,
patch,
incFlowDone
};
entries.forEach((entry) => {
processKpFile(entry.file, entry.localId, deps).catch((err: Error) => {
patch(entry.localId, { status: 'error', error: err.message });
});
});
},
[service, disabled, patch, incFlowDone]
);
const removeFile = useCallback((localId: string) => {
setKpFiles((prev) => prev.filter((f) => f.localId !== localId));
}, []);
const setCurrency = useCallback((localId: string, currency: string) => {
setKpFiles((prev) => prev.map((f) => (f.localId === localId ? { ...f, currencyCode: currency } : f)));
}, []);
const isProcessing = kpFiles.some(
(f) => f.status === 'uploading' || f.status === 'extracting' || f.status === 'flowing'
);
return { kpFiles, isProcessing, duplicateWarning, addFiles, removeFile, setCurrency };
}
@@ -0,0 +1,83 @@
import * as React from 'react';
import { useState } from 'react';
import { Button, TextInput, Icon } from '@tnsu/ui-kit';
import styles from './Step3BuildCt.module.scss';
// Строка добавления в конце таблицы (по эталону): ссылки «+ Добавить позицию/доставку» → инлайн-форма.
// Держит только UI-стейт формы; запись — через колбэки матрицы (логика в useStep3Matrix).
interface AddExtraRowProps {
colSpan: number;
onAddPosition: (name: string, unit: string) => void;
onAddDelivery: () => void;
}
export function AddExtraRow({ colSpan, onAddPosition, onAddDelivery }: AddExtraRowProps): JSX.Element {
const [showForm, setShowForm] = useState(false);
const [name, setName] = useState('');
const [unit, setUnit] = useState('');
const reset = (): void => {
setShowForm(false);
setName('');
setUnit('');
};
const submit = (): void => {
if (!name.trim()) {
return;
}
onAddPosition(name, unit);
reset();
};
if (showForm) {
return (
<tr className={styles.addPositionRow}>
<td colSpan={colSpan}>
<div className={styles.addPosInlineForm}>
<div className={styles.addInput}>
<TextInput
placeholder="Наименование позиции"
ariaLabel="Наименование позиции"
value={name}
onChange={setName}
onKeyDown={(e) => {
if (e.key === 'Enter') {
submit();
}
}}
autoFocus
/>
</div>
<div className={styles.addInputSmall}>
<TextInput placeholder="Ед." ariaLabel="Единица измерения" value={unit} onChange={setUnit} />
</div>
<Button variant="primary" onClick={submit} disabled={!name.trim()}>
<Icon name="plus" size="sm" />
Добавить
</Button>
<Button variant="secondary" onClick={reset}>
<Icon name="close" size="sm" />
Отмена
</Button>
</div>
</td>
</tr>
);
}
return (
<tr className={styles.addPositionRow}>
<td colSpan={colSpan}>
<div className={styles.addPositionLinks}>
<button type="button" className={styles.addPositionBtn} onClick={() => setShowForm(true)}>
+ Добавить позицию (Доп. расходы)
</button>
<button type="button" className={styles.addPositionBtn} onClick={onAddDelivery}>
+ Добавить доставку
</button>
</div>
</td>
</tr>
);
}
@@ -0,0 +1,79 @@
import * as React from 'react';
import { IconButton, Spinner, Alert } from '@tnsu/ui-kit';
import styles from './Step3BuildCt.module.scss';
// Блок «Комментарий ИИ» (Шаг 3): показывается только если есть комментарий, идёт загрузка или ошибка.
// Кнопка удаления — лишь когда onDelete задан (в read-only её не передаём).
interface AiCommentBlockProps {
comment?: string;
isLoading: boolean;
error?: string;
onDelete?: () => void;
}
// Комментарий ИИ приходит в Markdown (**жирный**, маркированные списки «- »). Рендерим лёгкую разметку
// в React-узлы (без dangerouslySetInnerHTML): жирный/списки/абзацы — сами звёздочки-маркеры не видны.
function renderInline(text: string, keyPrefix: string): React.ReactNode[] {
return text.split(/(\*\*[^*]+\*\*)/g).map((part, i) => {
const bold = /^\*\*([^*]+)\*\*$/.exec(part);
return bold ? <strong key={`${keyPrefix}-${i}`}>{bold[1]}</strong> : part;
});
}
function renderComment(text: string): JSX.Element[] {
const blocks: JSX.Element[] = [];
let bullets: string[] = [];
const flushBullets = (): void => {
if (bullets.length === 0) return;
const at = blocks.length;
blocks.push(
<ul key={`ul-${at}`}>
{bullets.map((b, i) => (
<li key={i}>{renderInline(b, `li-${at}-${i}`)}</li>
))}
</ul>
);
bullets = [];
};
text.split(/\r?\n/).forEach((raw) => {
const line = raw.trim();
if (!line) {
flushBullets();
return;
}
const bullet = /^[-*]\s+(.*)$/.exec(line); // «- » или «* » (но не «**жирный**» — там нет пробела)
if (bullet) {
bullets.push(bullet[1]);
return;
}
flushBullets();
blocks.push(<p key={`p-${blocks.length}`}>{renderInline(line, `p-${blocks.length}`)}</p>);
});
flushBullets();
return blocks;
}
export function AiCommentBlock(props: AiCommentBlockProps): JSX.Element {
const { comment, isLoading, error, onDelete } = props;
return (
<div className={styles.aiCommentBlock}>
<div className={styles.aiCommentTitle}>
Комментарий ИИ
{comment && !isLoading && onDelete ? (
<IconButton icon="trash" danger title="Удалить комментарий" className={styles.aiCommentDeleteBtn} onClick={onDelete} />
) : undefined}
</div>
{isLoading ? (
<div className={styles.aiCommentBody}>
<Spinner size="small" label="Генерация комментария…" />
</div>
) : undefined}
{error ? (
<div className={styles.aiCommentBody}>
<Alert variant="err">{error}</Alert>
</div>
) : undefined}
{comment ? <div className={styles.aiCommentText}>{renderComment(comment)}</div> : undefined}
</div>
);
}
@@ -0,0 +1,140 @@
import * as React from 'react';
import { Supplier } from '../types/domain';
import { formatKzt } from '../utils/format';
import { Step3Matrix, subColsOf } from './useStep3Matrix';
import { MatrixRowView } from './MatrixRowView';
import { AddExtraRow } from './AddExtraRow';
import styles from './Step3BuildCt.module.scss';
const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во
interface ComparisonMatrixProps {
suppliers: Supplier[];
matrix: Step3Matrix;
isReadOnly: boolean;
}
// Таблица сравнения: слева фикс-колонки, справа по колонке(ам) на поставщика. Только вёрстка —
// вся логика в useStep3Matrix (§стайл-гайд: логика отделена от представления).
export function ComparisonMatrix({ suppliers, matrix, isReadOnly }: ComparisonMatrixProps): JSX.Element {
const { recommendedId, selectedId, supplierTotals, regularTotals, extraTotals } = matrix;
const regularRows = matrix.rows.filter((r) => !r.isExtra);
const extraRows = matrix.rows.filter((r) => r.isExtra);
// Полная ширина таблицы (фикс-колонки + суб-колонки всех поставщиков) — для строки добавления.
const totalColSpan = FIXED_COLS + suppliers.reduce((sum, s) => sum + subColsOf(s.currency ?? 'KZT'), 0);
// Строка итога: подпись + по каждому поставщику пустые под-колонки и ячейка суммы.
const totalsRow = (label: string, totals: Record<string, number>, highlightRecommended: boolean, rowCls: string): JSX.Element => (
<tr className={rowCls}>
<td colSpan={FIXED_COLS} className={styles.totalLabel}>
<strong>{label}</strong>
</td>
{suppliers.map((s) => {
const isSel = s.id === selectedId;
const total = totals[s.id] ?? 0;
return (
<React.Fragment key={s.id}>
<td colSpan={subColsOf(s.currency ?? 'KZT') - 1} className={isSel ? styles.colSelected : undefined} />
<td
className={[styles.totalCell, highlightRecommended && s.id === recommendedId ? styles.totalRecommended : '', isSel ? styles.colSelected : '']
.filter(Boolean)
.join(' ')}
>
<strong>{total > 0 ? formatKzt(total) : '—'}</strong>
</td>
</React.Fragment>
);
})}
</tr>
);
return (
<div className={styles.tableScroll}>
<table className={styles.ctTable}>
<thead>
<tr className={styles.headerRow1}>
<th rowSpan={2} className={styles.colPosition}>Наименование</th>
<th rowSpan={2} className={styles.colUnit}>Ед. изм.</th>
<th rowSpan={2} className={styles.colQty}>Кол-во</th>
{suppliers.map((s) => {
const currency = s.currency ?? 'KZT';
const isSel = s.id === selectedId;
return (
<th
key={s.id}
colSpan={subColsOf(currency)}
className={[styles.supplierGroupHeader, isSel ? styles.colSelected : ''].filter(Boolean).join(' ')}
>
<div className={styles.supplierHeader}>
<span className={styles.supplierNameText}>{s.name}</span>
{s.id === recommendedId ? <span className={styles.recommendedBadge}>Рекомендуется</span> : undefined}
{s.hasOffer === false ? <span className={styles.noOfferTag}>Без КП</span> : undefined}
{currency !== 'KZT' ? <span className={styles.currencyTag}>{currency}</span> : undefined}
<div className={styles.supplierActions}>
{!isReadOnly ? (
<>
<button
className={[styles.selectBtn, isSel ? styles.selectBtnActive : ''].filter(Boolean).join(' ')}
onClick={() => matrix.selectSupplier(s.id)}
>
{isSel ? 'Выбран' : 'Выбрать'}
</button>
<button className={styles.deleteBtn} title="Удалить поставщика" onClick={() => matrix.removeSupplier(s.id)}>
×
</button>
</>
) : (
isSel ? <span className={styles.selectedBadge}>Выбран</span> : undefined
)}
</div>
</div>
</th>
);
})}
</tr>
<tr className={styles.headerRow2}>
{suppliers.map((s) => {
const isForeign = (s.currency ?? 'KZT') !== 'KZT';
const selCls = s.id === selectedId ? styles.colSelected : '';
const sub = (label: string, name = false): JSX.Element => (
<th className={[styles.subHeader, name ? styles.subHeaderName : '', selCls].filter(Boolean).join(' ')}>{label}</th>
);
return (
<React.Fragment key={s.id}>
{sub('Наименование ТМЦ', true)}
{sub('Ед. изм.')}
{sub('Кол-во')}
{isForeign ? (
<>
{sub(`Цена, ${s.currency}`)}
{sub(`Сумма, ${s.currency}`)}
</>
) : undefined}
{sub('Цена, ₸')}
{sub('Сумма, ₸')}
</React.Fragment>
);
})}
</tr>
</thead>
<tbody>
{regularRows.map((row, index) => (
<MatrixRowView key={row.id} row={row} index={index} suppliers={suppliers} matrix={matrix} isReadOnly={isReadOnly} />
))}
{totalsRow('Итого, ₸', regularTotals, false, styles.subtotalRow)}
{extraRows.map((row, index) => (
<MatrixRowView key={row.id} row={row} index={regularRows.length + index} suppliers={suppliers} matrix={matrix} isReadOnly={isReadOnly} />
))}
{extraRows.length > 0 ? totalsRow('Сумма доп. расходов, ₸', extraTotals, false, styles.subtotalRow) : undefined}
{totalsRow('Общая сумма, ₸', supplierTotals, true, styles.totalRow)}
{!isReadOnly ? (
<AddExtraRow colSpan={totalColSpan} onAddPosition={matrix.addExtraPosition} onAddDelivery={matrix.addDelivery} />
) : undefined}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,118 @@
@import '../common/theme';
.root {
border: 1px solid $border;
border-radius: 4px;
overflow: hidden;
}
.title {
margin: 0;
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
color: $text;
background: $accent-bg; // тон шапки как в эталоне (на шаг светлее $accent-bg)
border-bottom: 1px solid $accent-border;
}
.scroll {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.labelHead {
min-width: 180px;
padding: 8px 12px;
background: $head-bg;
border-right: 1px solid $border;
}
.supplierHead {
padding: 8px 14px;
text-align: left;
font-weight: 600;
color: $text;
background: $accent-bg;
border-left: 1px solid $border;
border-bottom: 1px solid $accent-border;
min-width: 200px;
}
.label {
padding: 10px 12px;
font-weight: 600;
font-size: 12px;
color: $muted;
background: $head-bg;
border-right: 1px solid $border;
border-bottom: 1px solid $hover; // мягкая горизонтальная линия строк, как в эталоне
white-space: nowrap;
vertical-align: top;
}
.cell {
padding: 10px 14px;
font-size: 13px;
color: $text;
border-left: 1px solid $border;
border-bottom: 1px solid $hover;
vertical-align: top;
line-height: 1.5;
background: $accent-bg;
}
// Свободный ввод (paymentType/deadline/comment) — аффорданс редактирования: пунктирное
// подчёркивание, подсветка акцентом на hover/focus, без рамки/ресайза. Цель — <textarea>
// из ui-kit TextArea (borderless); полностью переопределяем, чтобы не конфликтовать с ним.
.input {
display: block;
textarea {
border: 0;
border-bottom: 1px dashed $border-strong;
border-radius: 2px;
background: transparent;
padding: 2px 4px;
min-height: 26px;
line-height: 1.5;
font-size: 13px;
resize: none;
transition: border-color 0.15s, background 0.15s;
&:hover {
border-bottom-color: $accent;
background: rgba($accent, 0.04);
}
&:focus {
border-bottom-color: $accent;
border-bottom-width: 2px;
background: rgba($accent, 0.06);
outline: none;
box-shadow: none;
}
}
}
.select {
height: 30px;
padding: 2px 6px;
border: 1px solid $border-strong;
border-radius: 3px;
background: $surface;
color: $text;
font-family: inherit;
font-size: 13px;
cursor: pointer;
&:focus {
border-color: $accent;
outline: none;
}
}
@@ -0,0 +1,101 @@
import * as React from 'react';
import { TextArea } from '@tnsu/ui-kit';
import { Supplier, SupplierCriteria } from '../types/domain';
import styles from './CriteriaTable.module.scss';
type CriteriaField = 'paymentType' | 'deliveryType' | 'deadline' | 'sertificates' | 'comment';
// Строки блока «Дополнительная информация». Для Choice-полей задаём варианты (options).
const ROWS: Array<{ key: CriteriaField; label: string; options?: string[] }> = [
{ key: 'paymentType', label: 'Условия оплаты' },
{ key: 'deliveryType', label: 'Условия поставки', options: ['DAP', 'FOB', 'DDP', 'EXW'] },
{ key: 'deadline', label: 'Сроки изготовления и доставки' },
{ key: 'sertificates', label: 'Сертификаты', options: ['Да', 'Нет'] },
{ key: 'comment', label: 'Примечание' }
];
interface CriteriaTableProps {
suppliers: Supplier[];
criteria: SupplierCriteria[];
onChange: (criteria: SupplierCriteria[]) => void;
isReadOnly: boolean;
}
// Блок «Дополнительная информация»: строки-критерии × колонки-поставщики. Логика правок — локальная
// (иммутабельно через onChange в formData.criteria); сохранение в «Критерии» — на «Далее» (step3Persist).
export function CriteriaTable({ suppliers, criteria, onChange, isReadOnly }: CriteriaTableProps): JSX.Element {
const valueOf = (supplierId: string, field: CriteriaField): string => criteria.find((c) => c.supplierId === supplierId)?.[field] ?? '';
const update = (supplierId: string, field: CriteriaField, value: string): void => {
const exists = criteria.some((c) => c.supplierId === supplierId);
const next = exists
? criteria.map((c) => (c.supplierId === supplierId ? { ...c, [field]: value } : c))
: [...criteria, { supplierId, [field]: value }];
onChange(next);
};
const renderInput = (supplier: Supplier, row: { key: CriteriaField; options?: string[] }): JSX.Element => {
const value = valueOf(supplier.id, row.key);
if (isReadOnly) {
return <span>{value || '—'}</span>;
}
if (row.options) {
// Значение из потока может не совпасть с вариантами Choice — добавляем его в список, чтобы показать.
const options = value && row.options.indexOf(value) === -1 ? [value, ...row.options] : row.options;
return (
<select className={styles.select} value={value} onChange={(e) => update(supplier.id, row.key, e.target.value)} aria-label={`${row.key} ${supplier.name}`}>
<option value="">—</option>
{options.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
);
}
return (
<span className={styles.input}>
<TextArea
borderless
rows={1}
value={value}
onChange={(v) => update(supplier.id, row.key, v)}
placeholder="—"
ariaLabel={`${row.key} ${supplier.name}`}
/>
</span>
);
};
return (
<div className={styles.root}>
<h3 className={styles.title}>Дополнительная информация</h3>
<div className={styles.scroll}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.labelHead} />
{suppliers.map((s) => (
<th key={s.id} className={styles.supplierHead}>
{s.name}
</th>
))}
</tr>
</thead>
<tbody>
{ROWS.map((row) => (
<tr key={row.key}>
<td className={styles.label}>{row.label}</td>
{suppliers.map((s) => (
<td key={s.id} className={styles.cell}>
{renderInput(s, row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,68 @@
import * as React from 'react';
import { Supplier } from '../types/domain';
import { MatrixRow, Step3Matrix, subColsOf } from './useStep3Matrix';
import { OfferCells } from './OfferCells';
import styles from './Step3BuildCt.module.scss';
interface MatrixRowViewProps {
row: MatrixRow;
index: number;
suppliers: Supplier[];
matrix: Step3Matrix;
isReadOnly: boolean;
}
// Одна позиция матрицы: несколько <tr> при комплекте (bundle). Фикс-колонки (наименование/ед./кол-во)
// объединены rowSpan на первой под-строке; у поставщика с меньшим числом строк — пустые ячейки.
export function MatrixRowView({ row, index, suppliers, matrix, isReadOnly }: MatrixRowViewProps): JSX.Element {
const perSupplierItems = suppliers.map((s) => matrix.itemsFor(s.id, row.id));
const maxSubRows = Math.max(1, ...perSupplierItems.map((items) => items.length));
const rowClass = row.isExtra ? styles.extraRow : styles.row;
const subRows: JSX.Element[] = [];
for (let rowIdx = 0; rowIdx < maxSubRows; rowIdx++) {
subRows.push(
<tr key={rowIdx} className={rowClass}>
{rowIdx === 0 ? (
<>
<td className={styles.nameCell} rowSpan={maxSubRows}>
<span className={styles.idx}>{index + 1}.</span> {row.name}
{row.isExtra ? <span className={styles.extraBadge}>{row.isDelivery ? 'Доставка' : 'Доп. расходы'}</span> : undefined}
{row.isExtra && !isReadOnly ? (
<button className={styles.deleteExtraBtn} title="Удалить" onClick={() => matrix.removeExtraPosition(row.id)}>
×
</button>
) : undefined}
</td>
<td className={styles.unitCell} rowSpan={maxSubRows}>{row.unit}</td>
<td className={styles.qtyCell} rowSpan={maxSubRows}>{row.isExtra ? '—' : row.quantity}</td>
</>
) : undefined}
{suppliers.map((s, si) => {
const items = perSupplierItems[si];
const item = items[rowIdx];
const selected = s.id === matrix.selectedId;
if (rowIdx > 0 && item === undefined) {
// у этого поставщика меньше строк комплекта — пустая ячейка на всю ширину группы
return <td key={s.id} colSpan={subColsOf(s.currency ?? 'KZT')} className={[styles.cell, selected ? styles.colSelected : ''].filter(Boolean).join(' ')} />;
}
return (
<OfferCells
key={s.id}
supplier={s}
row={row}
matrix={matrix}
isReadOnly={isReadOnly}
selected={selected}
item={item}
itemIndex={rowIdx}
itemsCount={items.length}
/>
);
})}
</tr>
);
}
return <>{subRows}</>;
}
@@ -0,0 +1,135 @@
import * as React from 'react';
import { TextInput } from '@tnsu/ui-kit';
import { Supplier, SupplierOffer } from '../types/domain';
import { formatKzt, formatMoney } from '../utils/format';
import { MatrixRow, Step3Matrix } from './useStep3Matrix';
import styles from './Step3BuildCt.module.scss';
interface OfferCellsProps {
supplier: Supplier;
row: MatrixRow;
matrix: Step3Matrix;
isReadOnly: boolean;
selected: boolean;
item?: SupplierOffer; // строка комплекта (может отсутствовать → значения по умолчанию)
itemIndex: number;
itemsCount: number; // сколько строк комплекта у этой ячейки (0 = одна пустая по умолчанию)
}
// Ячейки одного поставщика по одной строке комплекта: 5 колонок для ₸, 7 для валюты.
// Кнопки комплекта (+/×) — в колонке наименования (кроме доставки).
export function OfferCells(props: OfferCellsProps): JSX.Element {
const { supplier, row, matrix, isReadOnly, selected, item, itemIndex, itemsCount } = props;
const currency = supplier.currency ?? 'KZT';
const isForeign = currency !== 'KZT';
const rate = matrix.rateOf(currency);
const qty = item?.quantity ?? (itemIndex === 0 ? row.quantity : 0);
const price = item?.pricePerUnit ?? 0;
const km = item?.deliveryDistanceKm;
const sumCur = qty * price;
const priceKzt = price * rate;
const sumKzt = qty * priceKzt;
const nameVal = item?.supplierPosition ?? row.name;
const unitVal = item?.unit ?? row.unit;
const selCls = selected ? styles.colSelected : '';
const key = (field: string): string => `${supplier.id}::${row.id}::${itemIndex}::${field}`;
const cell = (content: React.ReactNode, extraCls = ''): JSX.Element => (
<td className={[styles.cell, extraCls, selCls].filter(Boolean).join(' ')}>{content}</td>
);
const numInput = (field: 'quantity' | 'pricePerUnit' | 'deliveryDistanceKm', value: string, placeholder: string): JSX.Element => (
<span className={styles.input}>
<TextInput
borderless
placeholder={placeholder}
value={value}
onChange={(v) => matrix.editNumber(supplier.id, row.id, itemIndex, field, v)}
/>
</span>
);
const showBundle = !isReadOnly && !row.isDelivery;
const isLast = itemIndex === Math.max(0, itemsCount - 1);
// Доставка: «км» под названием «Доставка» в широком столбце «Наименование ТМЦ» — там есть место,
// поэтому узкая колонка «Кол-во» больше не режет цифры (в неё кладём только рейсы).
const kmContent = (
<div className={styles.kmLine}>
{isReadOnly ? (
<span className={styles.kmReadonly}>{km !== undefined ? `${km} км` : '—'}</span>
) : (
<span className={styles.kmControl}>
{numInput('deliveryDistanceKm', matrix.raw[key('deliveryDistanceKm')] ?? (km !== undefined ? String(km) : ''), '0')}
<span className={styles.kmUnit}>км</span>
</span>
)}
</div>
);
const nameContent = (
<>
<div className={styles.bundleName}>
{isReadOnly ? (
<span>{nameVal}</span>
) : (
<span className={styles.input}>
<TextInput borderless value={nameVal} onChange={(v) => matrix.editText(supplier.id, row.id, itemIndex, 'supplierPosition', v)} />
</span>
)}
{showBundle ? (
<span className={styles.bundleBtns}>
{itemsCount > 1 ? (
<button className={styles.bundleRemoveBtn} title="Удалить строку" onClick={() => matrix.removeBundleItem(supplier.id, row.id, itemIndex)}>
×
</button>
) : undefined}
{isLast ? (
<button className={styles.bundleAddBtn} title="Добавить строку комплекта" onClick={() => matrix.addBundleItem(supplier.id, row.id)}>
+
</button>
) : undefined}
</span>
) : undefined}
</div>
{row.isDelivery ? kmContent : undefined}
</>
);
const qtyContent = isReadOnly ? (
<span>{qty || '—'}</span>
) : (
numInput('quantity', matrix.raw[key('quantity')] ?? (qty ? String(qty) : ''), row.isDelivery ? 'рейсы' : '—')
);
const priceInput = numInput('pricePerUnit', matrix.raw[key('pricePerUnit')] ?? (price ? String(price) : ''), '0');
return (
<>
{cell(nameContent, styles.cellName)}
{cell(
isReadOnly ? (
<span>{unitVal}</span>
) : (
<span className={styles.input}>
<TextInput borderless value={unitVal} onChange={(v) => matrix.editText(supplier.id, row.id, itemIndex, 'unit', v)} />
</span>
)
)}
{cell(qtyContent)}
{isForeign ? (
<>
{cell(isReadOnly ? <span>{formatMoney(price)}</span> : priceInput)}
{cell(<span>{formatMoney(sumCur)}</span>)}
{cell(<span>{priceKzt > 0 ? formatKzt(priceKzt) : '—'}</span>)}
</>
) : (
cell(isReadOnly ? <span>{price ? formatKzt(price) : '—'}</span> : priceInput)
)}
{cell(<span>{sumKzt > 0 ? formatKzt(sumKzt) : '—'}</span>, styles.sumCell)}
</>
);
}
@@ -0,0 +1,599 @@
@import '../common/theme';
.state {
margin: 0;
color: $muted;
}
.body {
display: flex;
flex-direction: column;
gap: 16px;
}
.error {
margin: 4px 0 0;
color: $danger;
font-size: 13px;
}
// --- Комментарий ИИ ---
.aiCommentBlock {
border: 1px solid $accent-border;
border-radius: 4px;
background: $accent-bg;
overflow: hidden;
}
.aiCommentTitle {
padding: 6px 16px;
background: $accent-bg;
border-bottom: 1px solid $accent-border;
font-size: 13px;
font-weight: 600;
color: $accent-dark;
display: flex;
align-items: center;
justify-content: space-between;
}
.aiCommentDeleteBtn {
width: 24px;
height: 24px;
min-width: 24px;
color: $danger;
opacity: 0.5;
transition: opacity 0.15s;
&:hover {
opacity: 1;
}
i {
font-size: 13px;
}
}
.aiCommentBody {
padding: 14px 16px;
}
.aiCommentText {
padding: 14px 16px;
font-size: 13px;
color: $text;
line-height: 1.6;
p {
margin: 0 0 10px;
}
ul {
margin: 0 0 10px;
padding-left: 20px;
}
li {
margin: 2px 0;
}
strong {
font-weight: 600;
}
> :last-child {
margin-bottom: 0;
}
}
// --- Тулбар добавления поставщика ---
.supplierToolbar {
display: flex;
align-items: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.addInput {
flex: 0 1 280px;
}
.currencySelect {
height: 32px;
padding: 2px 6px;
border: 1px solid $border-strong;
border-radius: 3px;
background: $surface;
color: $text;
font-family: inherit;
font-size: 13px;
cursor: pointer;
&:focus {
border-color: $accent;
outline: none;
}
}
.currencyTag {
font-size: 11px;
background: $accent-bg;
color: $accent-dark;
padding: 1px 7px;
border-radius: 10px;
white-space: nowrap;
}
.noOfferTag {
font-size: 11px;
font-weight: 600;
background: $danger-bg;
color: $danger;
padding: 1px 7px;
border-radius: 10px;
white-space: nowrap;
}
// --- Таблица сравнения ---
.tableScroll {
overflow: auto;
border: 1px solid $border;
border-radius: 4px;
max-height: 70vh;
}
.ctTable {
border-collapse: collapse;
width: 100%;
font-size: 13px;
}
.headerRow1 th {
background: $accent-bg;
padding: 8px 12px;
text-align: center;
font-weight: 600;
color: $text;
border-bottom: 1px solid $accent-border;
border-right: 1px solid $border;
position: sticky;
top: 0;
z-index: 2;
}
.headerRow2 th {
background: $accent-bg;
padding: 5px 10px;
text-align: center;
font-weight: 500;
font-size: 11px;
color: $muted;
border-bottom: 2px solid $accent-border;
border-right: 1px solid $border;
position: sticky;
top: 38px; // высота headerRow1 (приблизительно)
z-index: 1;
white-space: nowrap;
min-width: 90px;
}
.colPosition {
min-width: 220px;
text-align: left !important;
position: sticky !important;
left: 0;
z-index: 4 !important;
background: $accent-bg !important;
}
.colUnit {
min-width: 70px;
}
.colQty {
min-width: 80px;
}
.supplierGroupHeader {
border-left: 2px solid $accent-border !important;
padding: 8px 12px !important;
}
.colSelected {
background: $supplier-bg !important;
}
.subHeader {
border-left: 1px solid $border;
&:first-of-type {
border-left: 2px solid $accent-border;
}
}
.subHeaderName {
min-width: 150px;
text-align: left !important;
}
.supplierHeader {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.supplierNameText {
font-size: 13px;
font-weight: 600;
color: $text;
}
.recommendedBadge {
font-size: 10px;
font-weight: 600;
background: $accent;
color: $surface;
padding: 1px 6px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.supplierActions {
display: flex;
align-items: center;
gap: 6px;
justify-content: center;
}
.selectBtn {
font-size: 11px;
padding: 2px 10px;
border: 1px solid $accent;
border-radius: 3px;
background: $surface;
color: $accent;
cursor: pointer;
font-weight: 500;
font-family: inherit;
&:hover {
background: $accent-bg;
}
}
.selectBtnActive {
background: $accent;
color: $surface;
}
.deleteBtn {
width: 20px;
height: 20px;
border: none;
border-radius: 50%;
background: $border;
color: $muted;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0;
&:hover {
background: $danger-bg;
color: $danger;
}
}
.selectedBadge {
font-size: 11px;
padding: 2px 10px;
border-radius: 3px;
background: $accent;
color: $surface;
font-weight: 500;
}
// --- Тело ---
.row {
&:hover td {
background: $hover;
}
}
.row td {
padding: 4px 8px;
border-bottom: 1px solid $border;
vertical-align: middle;
}
.nameCell {
position: sticky;
left: 0;
background: $surface;
z-index: 1;
min-width: 220px;
color: $text;
}
.idx {
color: $muted;
margin-right: 4px;
}
.unitCell {
text-align: center;
color: $muted;
font-size: 12px;
white-space: nowrap;
}
.qtyCell {
text-align: right;
font-weight: 600;
white-space: nowrap;
padding-right: 12px !important;
}
.cell {
text-align: right;
border-left: 1px solid $border;
min-width: 90px;
span {
white-space: nowrap;
}
}
.cellName {
text-align: left;
min-width: 150px;
}
.sumCell {
font-weight: 600;
white-space: nowrap;
}
// Обёртка инлайн-ввода в ячейке: цель — вложенный <input> из ui-kit TextInput (borderless).
.input {
display: block;
input {
text-align: right;
font-size: 13px;
padding: 2px 6px;
min-height: 26px;
}
}
.cellName .input input {
text-align: left;
}
// --- Итого ---
.subtotalRow td {
padding: 6px 12px;
background: $accent-bg; // светло-синий подытог
border-top: 1px solid $accent-border;
border-bottom: 1px solid $accent-border;
text-align: right;
color: $accent-dark;
font-weight: 600;
}
.totalRow td {
padding: 8px 12px;
background: $accent-bg;
border-top: 2px solid $accent;
text-align: right;
color: $accent-dark;
}
.totalLabel {
text-align: left !important;
color: $text !important;
position: sticky;
left: 0;
background: $accent-bg;
z-index: 1;
}
.totalCell {
white-space: nowrap;
font-weight: 700;
border-left: 1px solid $accent-border;
}
.totalRecommended {
background: $accent-border !important;
color: $accent-dark !important;
}
// --- Доп. расходы / доставка ---
// Строка добавления доп. позиции/доставки в конце таблицы (ссылки + инлайн-форма).
.addPositionRow td {
padding: 8px 12px;
border-top: 1px solid $border;
background: $surface;
text-align: left;
}
// Ссылки добавления — вертикально (одна под другой).
.addPositionLinks {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.addPositionBtn {
background: none;
border: none;
color: $accent;
cursor: pointer;
font-size: 13px;
font-weight: 500;
font-family: inherit;
padding: 2px 0;
&:hover {
text-decoration: underline;
}
}
.addPosInlineForm {
display: flex;
align-items: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.addInputSmall {
flex: 0 0 90px;
}
.extraRow {
td {
padding: 4px 8px;
border-bottom: 1px solid $border;
vertical-align: middle;
background: $supplier-bg; // кремовый фон доп.строки
}
&:hover td {
background: $supplier-bg;
}
.nameCell {
background: $supplier-bg;
}
}
.extraBadge {
display: inline-block;
margin-left: 8px;
font-size: 10px;
font-weight: 600;
padding: 1px 7px;
border-radius: 10px;
background: $warn-bg;
color: $warn-fg;
vertical-align: middle;
white-space: nowrap;
}
.deleteExtraBtn {
margin-left: 6px;
width: 18px;
height: 18px;
border: none;
border-radius: 50%;
background: $border;
color: $muted;
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 0;
vertical-align: middle;
&:hover {
background: $danger-bg;
color: $danger;
}
}
// Доставка: «км» выводится под названием «Доставка» в широком столбце «Наименование ТМЦ»
// (не в узкой колонке «Кол-во») — места достаточно, цифры не режутся. Подпись не нужна:
// строка и так «Доставка», хватает суффикса «км».
.kmLine {
display: flex;
align-items: center;
margin-top: 3px;
}
.kmControl {
display: inline-flex;
align-items: center;
gap: 3px;
width: 84px; // компактно, как аннотация под «Доставка» (не второе название)
:global(.ms-TextField) {
flex: 1 1 auto;
min-width: 0;
}
}
.kmUnit {
flex: 0 0 auto;
font-size: 11px;
color: $muted;
}
.kmReadonly {
font-size: 12px;
color: $muted;
white-space: nowrap;
}
// --- Комплекты (bundle) ---
.bundleName {
display: flex;
align-items: center;
gap: 4px;
> *:first-child {
flex: 1;
min-width: 0;
}
}
.bundleBtns {
display: flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
}
.bundleAddBtn {
width: 18px;
height: 18px;
border: none;
border-radius: 50%;
background: $accent;
color: $surface;
cursor: pointer;
font-size: 13px;
line-height: 1;
padding: 0;
&:hover {
background: $accent-hover;
}
}
.bundleRemoveBtn {
width: 16px;
height: 16px;
border: none;
border-radius: 50%;
background: $border;
color: $muted;
cursor: pointer;
font-size: 11px;
line-height: 1;
padding: 0;
&:hover {
background: $danger-bg;
color: $danger;
}
}
@@ -0,0 +1,169 @@
import * as React from 'react';
import { Button, TextInput, Dialog, Icon } from '@tnsu/ui-kit';
import { StepShell } from '../common/StepShell';
import { StepProps } from '../types/domain';
import { useStep3Matrix } from './useStep3Matrix';
import { useAiComment, AiCommentPayload } from './useAiComment';
import { ComparisonMatrix } from './ComparisonMatrix';
import { CriteriaTable } from './CriteriaTable';
import { AiCommentBlock } from './AiCommentBlock';
import { InlineApprovalChain } from '../Step4Approval/InlineApprovalChain';
import styles from './Step3BuildCt.module.scss';
// Шаг 3 «Формирование СТ» (§12): контейнер — тулбары (поставщики, доп. расходы) + матрица + комментарий ИИ.
// Логика — в useStep3Matrix/useAiComment, таблица — в ComparisonMatrix (§стайл-гайд: логика вне вёрстки).
export function Step3BuildCt(props: StepProps): JSX.Element {
const { formData, onPrev, isReadOnly } = props;
const positions = formData.selectedPositions;
const suppliers = formData.suppliers;
const matrix = useStep3Matrix({
service: props.service,
formData,
onSave: props.onSave,
onStepComplete: props.onStepComplete,
onNext: props.onNext,
isReadOnly
});
// Собираем payload для потока ИИ из текущей матрицы (номер/проект добавит сам хук из шапки СТ).
const buildAiPayload = (): AiCommentPayload => ({
positions: [
...positions.map((p) => ({ name: p.name, unit: p.unit, quantity: p.quantity })),
...(formData.step3ExtraPositions ?? []).map((e) => ({ name: e.materialName, unit: e.unit || '—', quantity: 0 }))
],
suppliers: suppliers
.map((s) => ({ name: s.name, grandTotal: matrix.supplierTotals[s.id] ?? 0 }))
.filter((s) => s.grandTotal > 0),
recommendedSupplier: suppliers.find((s) => s.id === matrix.selectedId)?.name
});
const ai = useAiComment({ service: props.service, ctId: formData.ctId, buildPayload: buildAiPayload });
// «Далее»: на редактируемой СТ перед переходом предлагаем получить комментарий ИИ (как эталон).
// Диалог показываем один раз (нет комментария и не идёт загрузка); дальше кнопка сохраняет.
const handleNextClick = (): void => {
if (!isReadOnly && formData.ctId && !ai.comment && !ai.isLoading) {
ai.openPrompt();
return;
}
matrix.handleNext();
};
return (
<>
<StepShell
title="Формирование СТ"
onPrev={onPrev}
onNext={handleNextClick}
nextLabel={matrix.saving ? 'Сохранение…' : 'Далее'}
nextDisabled={matrix.saving || ai.isLoading}
>
{positions.length === 0 ? (
<p className={styles.state}>Позиции не выбраны — вернитесь на шаг «Выбор позиций».</p>
) : (
<div className={styles.body}>
{!isReadOnly ? (
<div className={styles.supplierToolbar}>
<div className={styles.addInput}>
<TextInput
placeholder="Наименование поставщика"
ariaLabel="Наименование поставщика"
value={matrix.supplierName}
onChange={matrix.setSupplierName}
onKeyDown={(e) => {
if (e.key === 'Enter') {
matrix.addSupplier();
}
}}
/>
</div>
<select
className={styles.currencySelect}
value={matrix.supplierCurrency}
onChange={(e) => matrix.setSupplierCurrency(e.target.value)}
aria-label="Валюта поставщика"
>
{matrix.currencyOptions.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<Button variant="primary" onClick={matrix.addSupplier} disabled={!matrix.supplierName.trim()}>
<Icon name="plus" size="sm" />
Добавить поставщика
</Button>
</div>
) : undefined}
{suppliers.length === 0 ? (
<p className={styles.state}>Добавьте поставщиков, чтобы заполнить цены.</p>
) : (
<>
<ComparisonMatrix suppliers={suppliers} matrix={matrix} isReadOnly={!!isReadOnly} />
<CriteriaTable
suppliers={suppliers}
criteria={formData.criteria ?? []}
onChange={(c) => props.onSave({ criteria: c })}
isReadOnly={!!isReadOnly}
/>
</>
)}
{ai.comment || ai.isLoading || ai.error ? (
<AiCommentBlock
comment={ai.comment}
isLoading={ai.isLoading}
error={ai.error}
onDelete={isReadOnly ? undefined : ai.deleteComment}
/>
) : undefined}
{props.isNotificationLink && isReadOnly ? (
<InlineApprovalChain
service={props.service}
formData={formData}
onSave={props.onSave}
onStepComplete={props.onStepComplete}
userDisplayName={props.userDisplayName}
/>
) : undefined}
{matrix.saveError ? <p className={styles.error}>Не удалось сохранить: {matrix.saveError}</p> : undefined}
</div>
)}
</StepShell>
<Dialog
open={ai.showPrompt}
title="Комментарий от ИИ"
subText="Получить комментарий от ИИ перед переходом на следующий шаг?"
onDismiss={ai.closePrompt}
footer={
<>
<Button
variant="secondary"
onClick={() => {
ai.closePrompt();
matrix.handleNext();
}}
>
Пропустить
</Button>
<Button
variant="primary"
onClick={() => {
ai.closePrompt();
ai.requestComment().catch(() => undefined);
}}
>
Получить
</Button>
</>
}
/>
</>
);
}
@@ -0,0 +1,153 @@
import { CtFormData, CurrencyRate, ExtraPosition, SelectedPosition, Supplier, SupplierCriteria, SupplierOffer } from '../types/domain';
import { KpsCtFormService } from '../../services/KpsCtFormService';
// Чистая логика сохранения Шага 3 в SharePoint (без React) — вынесено из хука ради читаемости.
// Порядок: поставщики → доп. позиции → офферы → winSupplier. Возвращает патч formData с новыми spId.
export interface PersistStep3Input {
service: KpsCtFormService;
ctId: number;
suppliers: Supplier[];
offers: SupplierOffer[];
positions: SelectedPosition[];
extras: ExtraPosition[];
rates: CurrencyRate[];
criteria: SupplierCriteria[];
selectedId?: string;
}
interface RowMeta {
spId: number;
name: string;
unit: string;
isDelivery: boolean;
}
export async function persistStep3(input: PersistStep3Input): Promise<Partial<CtFormData>> {
const { service, ctId, suppliers, offers, positions, extras, rates, criteria, selectedId } = input;
// 1) Поставщики → «Поставщики» (проставляем spId; push без индексного присваивания после await).
// Без-КП (hasOffer:false) заводим в справочник только если по ним введены цены — иначе они остаются
// лишь в «Не получены КП» (как в эталоне). Поставщики с КП заводим всегда.
const hasPrices = (supplierId: string): boolean =>
offers.some((o) => o.supplierId === supplierId && (o.quantity || 0) * (o.pricePerUnit || 0) > 0);
const savedSuppliers: Supplier[] = [];
for (const s of suppliers) {
const promote = s.hasOffer !== false || hasPrices(s.id);
savedSuppliers.push(promote && s.spId === undefined ? { ...s, spId: await service.saveSupplier(s.name) } : s);
}
// 2) Доп. позиции → «Позиции СТ» (проставляем spId).
const savedExtras: ExtraPosition[] = [];
for (const e of extras) {
savedExtras.push(e.spId === undefined ? { ...e, spId: await service.createExtraPosition(ctId, e.materialName, e.unit) } : e);
}
// Метаданные строк по локальному id (обычные позиции + доп.) — для маппинга офферов.
const rowMeta: Record<string, RowMeta> = {};
positions.forEach((p) => {
if (p.spId !== undefined) {
rowMeta[p.lineId] = { spId: p.spId, name: p.name, unit: p.unit, isDelivery: false };
}
});
savedExtras.forEach((e) => {
if (e.spId !== undefined) {
rowMeta[e.id] = { spId: e.spId, name: e.materialName, unit: e.unit, isDelivery: !!e.isDelivery };
}
});
// 3) Офферы → «Оценка поставщиков».
const posSpIds = Object.keys(rowMeta).map((id) => rowMeta[id].spId);
const toSave = offers.map((o) => buildOfferRow(o, savedSuppliers, rowMeta, rates)).filter((r): r is OfferRow => r !== undefined);
await service.saveOffers(posSpIds, toSave);
// 4) Критерии → «Критерии» (только по поставщикам с spId и хотя бы одним заполненным полем).
const criteriaRows = criteria
.map((c) => {
const sup = savedSuppliers.find((s) => s.id === c.supplierId);
if (sup?.spId === undefined || !(c.paymentType || c.deliveryType || c.deadline || c.sertificates || c.comment)) {
return undefined;
}
return {
supplierSpId: sup.spId,
paymentType: c.paymentType ?? '',
deliveryType: c.deliveryType ?? '',
deadline: c.deadline ?? '',
sertificates: c.sertificates ?? '',
comment: c.comment ?? ''
};
})
.filter((r): r is CriteriaRow => r !== undefined);
await service.saveCriteria(ctId, criteriaRows);
// 5) Поставщик-победитель → winSupplier в шапке.
const winSup = savedSuppliers.find((s) => s.id === selectedId);
let winSupplierId: number | undefined;
if (winSup?.spId !== undefined) {
await service.setWinSupplier(ctId, winSup.spId);
winSupplierId = winSup.spId;
}
return { suppliers: savedSuppliers, step3ExtraPositions: savedExtras, recommendedSupplierId: selectedId, winSupplierId };
}
interface OfferRow {
positionSpId: number;
supplierSpId: number;
itemIndex: number;
supplierPosition: string;
unit: string;
quantity: number;
price: number;
summa: number;
priceTenge: number;
summaTenge: number;
valutaSpId?: number;
deliveryDistanceKm?: number;
offerSpId?: number;
}
interface CriteriaRow {
supplierSpId: number;
paymentType: string;
deliveryType: string;
deadline: string;
sertificates: string;
comment: string;
}
function buildOfferRow(
o: SupplierOffer,
savedSuppliers: Supplier[],
rowMeta: Record<string, { spId: number; name: string; unit: string; isDelivery: boolean }>,
rates: CurrencyRate[]
): OfferRow | undefined {
const sup = savedSuppliers.find((s) => s.id === o.supplierId);
const row = rowMeta[o.positionId];
if (sup?.spId === undefined || row === undefined) {
return undefined;
}
const currency = sup.currency ?? 'KZT';
const rateObj = currency !== 'KZT' ? rates.find((r) => r.currency === currency) : undefined;
const rate = rateObj?.rate ?? 1;
const qty = o.quantity || 0;
const price = o.pricePerUnit || 0;
const summaTenge = qty * price * rate;
if (summaTenge <= 0) {
return undefined;
}
return {
positionSpId: row.spId,
supplierSpId: sup.spId,
itemIndex: o.itemIndex,
supplierPosition: o.supplierPosition ?? row.name,
unit: o.unit ?? row.unit,
quantity: qty,
price,
summa: qty * price,
priceTenge: price * rate,
summaTenge,
valutaSpId: rateObj?.spId,
deliveryDistanceKm: row.isDelivery ? o.deliveryDistanceKm : undefined,
offerSpId: o.offerId ? parseInt(o.offerId, 10) || undefined : undefined
};
}
@@ -0,0 +1,127 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { KpsCtFormService } from '../../services/KpsCtFormService';
// Логика блока «Комментарий ИИ» на Шаге 3: стейт комментария + вызов потока (по образцу эталона).
// Комментарий хранится в SP (колонка `comment` шапки), поэтому переживает переходы шагов —
// в formData его не держим (стайл-гайд: логика вне вёрстки, состояние ближе к источнику).
// Полезная нагрузка, которую собирает Шаг 3 (номер/проект добавляет сам хук из шапки СТ).
export interface AiCommentPayload {
positions: Array<{ name: string; unit: string; quantity: number }>;
suppliers: Array<{ name: string; grandTotal: number }>;
recommendedSupplier?: string;
}
interface UseAiCommentArgs {
service: KpsCtFormService;
ctId?: number;
buildPayload: () => AiCommentPayload;
}
export interface UseAiCommentResult {
comment?: string;
isLoading: boolean;
error?: string;
showPrompt: boolean;
openPrompt: () => void;
closePrompt: () => void;
requestComment: () => Promise<void>;
deleteComment: () => void;
}
export function useAiComment(args: UseAiCommentArgs): UseAiCommentResult {
const { service, ctId } = args;
const [comment, setComment] = useState<string | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const [showPrompt, setShowPrompt] = useState(false);
const mountedRef = useRef(true);
const headerRef = useRef<{ number?: number; project?: string }>({});
const buildRef = useRef(args.buildPayload);
buildRef.current = args.buildPayload; // всегда актуальная сборка payload (без пересоздания колбэков)
useEffect(() => () => {
mountedRef.current = false;
}, []);
// На маунте подтягиваем шапку: номер/проект — в payload; сохранённый ранее комментарий — показать.
useEffect(() => {
if (!ctId) {
return;
}
service
.loadCtHeader(ctId)
.then((h) => {
headerRef.current = { number: h.number, project: h.project };
if (h.comment && mountedRef.current) {
setComment(h.comment);
}
})
.catch(() => undefined);
}, [ctId, service]);
const requestComment = useCallback(async () => {
if (!ctId) {
return;
}
setIsLoading(true);
setError(undefined);
try {
const response = await service.triggerAiComment({
ctId,
ctNumber: headerRef.current.number,
projectName: headerRef.current.project,
...buildRef.current()
});
if (response) {
if (mountedRef.current) {
setComment(response);
}
// Персист в шапку СТ ЖДЁМ и НЕ глотаем: раньше сбой MERGE проглатывался, коммент был виден в
// сессии, но не сохранялся → при перезагрузке пропадал («не сохраняется/не читается»).
try {
await service.updateCtComment(ctId, response);
} catch (persistErr) {
// eslint-disable-next-line no-console
console.error('[AI] не удалось сохранить комментарий в шапку СТ', persistErr);
if (mountedRef.current) {
setError('Комментарий получен, но не сохранён в СТ — при перезагрузке он пропадёт. Попробуйте ещё раз.');
}
}
} else {
// Поток не вернул тело — подождём и перечитаем: он мог записать комментарий в SP сам.
await new Promise<void>((resolve) => setTimeout(resolve, 5000));
const saved = await service.loadCtHeader(ctId).then((h) => h.comment).catch(() => undefined);
if (saved && mountedRef.current) {
setComment(saved);
} else if (mountedRef.current) {
setError('Поток не вернул комментарий. Попробуйте ещё раз через несколько секунд.');
}
}
} catch (e) {
if (mountedRef.current) {
setError((e as Error).message ?? 'Ошибка получения комментария ИИ');
}
} finally {
if (mountedRef.current) {
setIsLoading(false);
}
}
}, [ctId, service]);
const deleteComment = useCallback(() => {
setComment(undefined);
if (ctId) {
service.updateCtComment(ctId, '').catch((e) => {
// eslint-disable-next-line no-console
console.error('[AI] не удалось очистить комментарий в шапке СТ', e);
});
}
}, [ctId, service]);
const openPrompt = useCallback(() => setShowPrompt(true), []);
const closePrompt = useCallback(() => setShowPrompt(false), []);
return { comment, isLoading, error, showPrompt, openPrompt, closePrompt, requestComment, deleteComment };
}
@@ -0,0 +1,294 @@
import { useEffect, useMemo, useState } from 'react';
import { CtFormData, CurrencyRate, FormStep, SupplierOffer } from '../types/domain';
import { KpsCtFormService } from '../../services/KpsCtFormService';
import { persistStep3 } from './step3Persist';
// Вся логика матрицы Шага 3 (офферы, курсы, доп. позиции, комплекты, итоги, сохранение) — отдельно от вёрстки.
// Единая строка матрицы: обычная позиция или доп. расход/доставка.
export interface MatrixRow {
id: string; // lineId (обычная) или extra id (доп.)
name: string;
unit: string;
quantity: number; // кол-во заявки (для доп./доставки = 0)
isExtra: boolean;
isDelivery: boolean;
spId?: number;
}
// Число под-колонок поставщика: KZT = 5, валюта = 7 (+ Цена/Сумма в валюте).
export function subColsOf(currency: string): number {
return currency !== 'KZT' ? 7 : 5;
}
type NumberField = 'quantity' | 'pricePerUnit' | 'deliveryDistanceKm';
interface UseStep3MatrixArgs {
service: KpsCtFormService;
formData: CtFormData;
onSave: (partial: Partial<CtFormData>) => void;
onStepComplete: (step: FormStep) => void;
onNext?: () => void;
isReadOnly?: boolean;
}
export interface Step3Matrix {
rows: MatrixRow[];
currencyOptions: string[];
supplierName: string;
setSupplierName: (v: string) => void;
supplierCurrency: string;
setSupplierCurrency: (v: string) => void;
raw: Record<string, string>;
saving: boolean;
saveError?: string;
rateOf: (currency: string) => number;
itemsFor: (supplierId: string, rowId: string) => SupplierOffer[];
supplierTotals: Record<string, number>; // общий итог по поставщику (₸)
regularTotals: Record<string, number>; // итог по обычным позициям
extraTotals: Record<string, number>; // итог по доп. расходам
recommendedId?: string;
selectedId?: string;
addSupplier: () => void;
removeSupplier: (id: string) => void;
selectSupplier: (id: string) => void;
addExtraPosition: (name: string, unit: string) => void;
addDelivery: () => void;
removeExtraPosition: (id: string) => void;
addBundleItem: (supplierId: string, rowId: string) => void;
removeBundleItem: (supplierId: string, rowId: string, itemIndex: number) => void;
editNumber: (supplierId: string, rowId: string, itemIndex: number, field: NumberField, v: string) => void;
editText: (supplierId: string, rowId: string, itemIndex: number, field: 'supplierPosition' | 'unit', v: string) => void;
handleNext: () => void;
}
export function useStep3Matrix(args: UseStep3MatrixArgs): Step3Matrix {
const { service, formData, onSave, onStepComplete, onNext, isReadOnly } = args;
const positions = formData.selectedPositions;
const suppliers = formData.suppliers;
const offers = formData.offers;
const extras = formData.step3ExtraPositions ?? [];
const [supplierName, setSupplierName] = useState('');
const [supplierCurrency, setSupplierCurrency] = useState('KZT');
const [raw, setRaw] = useState<Record<string, string>>({}); // сырой ввод чисел (чтобы не терять «10.»)
const [rates, setRates] = useState<CurrencyRate[]>(formData.currencyRates);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined);
// Курсы валют грузим один раз, если ещё не в formData.
useEffect(() => {
if (formData.currencyRates.length > 0) {
return;
}
service
.getCurrencyRates()
.then((r) => {
setRates(r);
onSave({ currencyRates: r });
})
.catch(() => undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const rows = useMemo<MatrixRow[]>(
() => [
...positions.map((p) => ({ id: p.lineId, name: p.name, unit: p.unit, quantity: p.quantity, isExtra: false, isDelivery: false, spId: p.spId })),
...extras.map((e) => ({ id: e.id, name: e.materialName, unit: e.unit, quantity: 0, isExtra: true, isDelivery: !!e.isDelivery, spId: e.spId }))
],
[positions, extras]
);
const rateOf = (currency: string): number => (currency === 'KZT' ? 1 : rates.find((r) => r.currency === currency)?.rate ?? 1);
// Строки комплекта одной ячейки (по возрастанию itemIndex).
const itemsFor = (supplierId: string, rowId: string): SupplierOffer[] =>
offers.filter((o) => o.supplierId === supplierId && o.positionId === rowId).sort((a, b) => a.itemIndex - b.itemIndex);
// Итоги по поставщику (₸): отдельно обычные позиции, отдельно доп. расходы, и общий.
const totals = useMemo(() => {
const extraIds = new Set(rows.filter((r) => r.isExtra).map((r) => r.id));
const regular: Record<string, number> = {};
const extra: Record<string, number> = {};
const grand: Record<string, number> = {};
suppliers.forEach((s) => {
const rate = rateOf(s.currency ?? 'KZT');
let r = 0;
let e = 0;
offers.forEach((o) => {
if (o.supplierId !== s.id) {
return;
}
const value = (o.quantity || 0) * (o.pricePerUnit || 0) * rate;
if (extraIds.has(o.positionId)) {
e += value;
} else {
r += value;
}
});
regular[s.id] = r;
extra[s.id] = e;
grand[s.id] = r + e;
});
return { regular, extra, grand };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [suppliers, offers, rates, rows]);
const supplierTotals = totals.grand;
const regularTotals = totals.regular;
const extraTotals = totals.extra;
const recommendedId = useMemo<string | undefined>(() => {
const withTotal = suppliers.map((s) => ({ id: s.id, total: supplierTotals[s.id] ?? 0 })).filter((t) => t.total > 0);
if (withTotal.length === 0) {
return undefined;
}
const min = Math.min(...withTotal.map((t) => t.total));
return withTotal.find((t) => t.total === min)?.id;
}, [suppliers, supplierTotals]);
const selectedId = formData.recommendedSupplierId ?? recommendedId;
const currencyOptions = useMemo(() => ['KZT', ...rates.map((r) => r.currency)], [rates]);
const makeOffer = (supplierId: string, rowId: string, itemIndex: number, patch?: Partial<SupplierOffer>): SupplierOffer => {
const row = rows.find((r) => r.id === rowId);
return {
supplierId,
positionId: rowId,
itemIndex,
supplierPosition: row?.name,
unit: row?.unit,
quantity: itemIndex === 0 ? row?.quantity ?? 0 : 0, // доп. строки комплекта — кол-во с нуля
pricePerUnit: 0,
...patch
};
};
const upsertOffer = (supplierId: string, rowId: string, itemIndex: number, patch: Partial<SupplierOffer>): void => {
const exists = offers.some((o) => o.supplierId === supplierId && o.positionId === rowId && o.itemIndex === itemIndex);
const next = exists
? offers.map((o) => (o.supplierId === supplierId && o.positionId === rowId && o.itemIndex === itemIndex ? { ...o, ...patch } : o))
: [...offers, makeOffer(supplierId, rowId, itemIndex, patch)];
onSave({ offers: next });
};
const parseNum = (v: string): number => {
const n = parseFloat(v.replace(',', '.'));
return isNaN(n) ? 0 : n;
};
const addSupplier = (): void => {
const trimmed = supplierName.trim();
if (!trimmed || suppliers.some((s) => s.name.toLowerCase() === trimmed.toLowerCase())) {
setSupplierName('');
return;
}
onSave({ suppliers: [...suppliers, { id: `sup_${trimmed}`, name: trimmed, currency: supplierCurrency, hasOffer: true }] });
setSupplierName('');
setSupplierCurrency('KZT');
};
const removeSupplier = (id: string): void => {
onSave({
suppliers: suppliers.filter((s) => s.id !== id),
offers: offers.filter((o) => o.supplierId !== id),
recommendedSupplierId: formData.recommendedSupplierId === id ? undefined : formData.recommendedSupplierId
});
};
const selectSupplier = (id: string): void => onSave({ recommendedSupplierId: id });
const addExtraPosition = (name: string, unit: string): void => {
const trimmed = name.trim();
if (!trimmed) {
return;
}
onSave({ step3ExtraPositions: [...extras, { id: `extra_pos_${Date.now()}`, materialName: trimmed, unit: unit.trim() }] });
};
const addDelivery = (): void => {
onSave({ step3ExtraPositions: [...extras, { id: `extra_pos_${Date.now()}`, materialName: 'Доставка', unit: 'рейс', isDelivery: true }] });
};
const removeExtraPosition = (id: string): void => {
onSave({ step3ExtraPositions: extras.filter((e) => e.id !== id), offers: offers.filter((o) => o.positionId !== id) });
};
// Добавить строку комплекта. Если ячейка пуста — материализуем базовую (0) и добавляем следующую.
const addBundleItem = (supplierId: string, rowId: string): void => {
const items = itemsFor(supplierId, rowId);
const base = items.length === 0 ? [makeOffer(supplierId, rowId, 0)] : [];
const nextIndex = items.length === 0 ? 1 : items.length;
onSave({ offers: [...offers, ...base, makeOffer(supplierId, rowId, nextIndex)] });
};
const removeBundleItem = (supplierId: string, rowId: string, itemIndex: number): void => {
const remaining = itemsFor(supplierId, rowId)
.filter((o) => o.itemIndex !== itemIndex)
.map((o, i) => ({ ...o, itemIndex: i })); // переиндексация, чтобы не было дыр
const others = offers.filter((o) => !(o.supplierId === supplierId && o.positionId === rowId));
onSave({ offers: [...others, ...remaining] });
};
const editNumber = (supplierId: string, rowId: string, itemIndex: number, field: NumberField, v: string): void => {
setRaw((prev) => ({ ...prev, [`${supplierId}::${rowId}::${itemIndex}::${field}`]: v }));
upsertOffer(supplierId, rowId, itemIndex, { [field]: parseNum(v) });
};
const editText = (supplierId: string, rowId: string, itemIndex: number, field: 'supplierPosition' | 'unit', v: string): void =>
upsertOffer(supplierId, rowId, itemIndex, { [field]: v });
const handleNext = (): void => {
if (isReadOnly) {
onStepComplete(3);
onNext?.();
return;
}
if (!formData.ctId) {
setSaveError('Сначала создайте СТ на Шаге 1 (кнопка «Создать и далее»).');
return;
}
setSaving(true);
setSaveError(undefined);
persistStep3({ service, ctId: formData.ctId, suppliers, offers, positions, extras, rates, criteria: formData.criteria ?? [], selectedId })
.then((patch) => {
onSave(patch);
onStepComplete(3);
onNext?.();
})
.catch((e: Error) => {
setSaveError(e.message);
setSaving(false);
});
};
return {
rows,
currencyOptions,
supplierName,
setSupplierName,
supplierCurrency,
setSupplierCurrency,
raw,
saving,
saveError,
rateOf,
itemsFor,
supplierTotals,
regularTotals,
extraTotals,
recommendedId,
selectedId,
addSupplier,
removeSupplier,
selectSupplier,
addExtraPosition,
addDelivery,
removeExtraPosition,
addBundleItem,
removeBundleItem,
editNumber,
editText,
handleNext
};
}
@@ -0,0 +1,94 @@
import * as React from 'react';
import { Button, Dialog, Alert, TextArea, Icon } from '@tnsu/ui-kit';
import { Pill } from '../common/Pill';
import { APPROVER_STATUS_META, CtFormData, FormStep } from '../types/domain';
import { formatDateTime } from '../utils/format';
import { KpsCtFormService } from '../../services/KpsCtFormService';
import { useStep4Approval } from './useStep4Approval';
import styles from './Step4Approval.module.scss';
interface InlineApprovalChainProps {
service: KpsCtFormService;
formData: CtFormData;
onSave: (partial: Partial<CtFormData>) => void;
onStepComplete: (step: FormStep) => void;
userDisplayName: string;
}
// Инлайн-цепочка согласования на Шаге 3 — для согласующего, открывшего СТ по ссылке-уведомлению (read-only,
// как в эталоне). Переиспользует useStep4Approval (сам грузит цепочку из SP): только просмотр статусов +
// «Согласовать/Отклонить» своей строки + диалог. Правка цепочки/отправка — это Шаг 4, здесь их нет.
export function InlineApprovalChain(props: InlineApprovalChainProps): JSX.Element | null {
// Инлайн-блок только читает цепочку из SP и даёт проголосовать — сид новой цепочки здесь запрещён.
const a = useStep4Approval({ ...props, canSeed: false });
if (a.chain.length === 0) {
return null; // цепочка ещё грузится из SP или её нет — блок не показываем
}
return (
<section className={styles.section}>
<h3 className={styles.title}>Цепочка согласования</h3>
<ol className={styles.list}>
{a.chain.map((member) => {
const meta = APPROVER_STATUS_META[member.status];
return (
<li key={member.rowId} className={styles.row}>
<span className={styles.num}>{member.order}</span>
<span className={styles.name}>{member.displayName}</span>
{member.isInitiator ? <Pill kind="neutral">Инициатор</Pill> : undefined}
<span className={styles.spacer} />
{a.showStatuses ? <Pill kind={meta.kind}>{meta.label}</Pill> : undefined}
{member.comment ? <span className={styles.comment}>«{member.comment}»</span> : undefined}
{member.decidedAt ? <span className={styles.date}>{formatDateTime(member.decidedAt)}</span> : undefined}
{a.isOnApproval && a.currentUserRowId === member.rowId ? (
<div className={styles.actions}>
<Button variant="primary" size="sm" onClick={() => a.openAction(member, 'approve')}>
<Icon name="check" size="sm" />
Согласовать
</Button>
<Button variant="danger" size="sm" onClick={() => a.openAction(member, 'reject')}>
<Icon name="close" size="sm" />
Отклонить
</Button>
</div>
) : undefined}
</li>
);
})}
</ol>
{a.error ? <Alert variant="err">{a.error}</Alert> : undefined}
{a.notifyWarning ? <Alert variant="warn">{a.notifyWarning}</Alert> : undefined}
{a.action ? (
<Dialog
open
title={a.action.kind === 'approve' ? 'Согласование' : 'Отклонение'}
subText={a.action.kind === 'approve' ? 'Подтвердите согласование СТ. Комментарий необязателен.' : 'Укажите причину отклонения (обязательно).'}
onDismiss={a.closeAction}
footer={
<>
<Button variant="secondary" onClick={a.closeAction} disabled={a.sending}>Отмена</Button>
<Button
variant={a.action.kind === 'approve' ? 'primary' : 'danger'}
onClick={a.submitAction}
disabled={a.sending || (a.action.kind === 'reject' && !a.action.comment.trim())}
>
{a.action.kind === 'approve' ? 'Согласовать' : 'Отклонить'}
</Button>
</>
}
>
<TextArea
value={a.action.comment}
onChange={a.setActionComment}
rows={3}
ariaLabel="Комментарий"
placeholder={a.action.kind === 'reject' ? 'Причина отклонения…' : 'Комментарий (необязательно)…'}
/>
</Dialog>
) : undefined}
</section>
);
}
@@ -0,0 +1,151 @@
@import '../common/theme';
.body {
display: flex;
flex-direction: column;
gap: 16px;
}
.section {
display: flex;
flex-direction: column;
gap: 10px;
}
.title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: $text;
}
.empty {
margin: 0;
color: $muted;
font-size: 13px;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
max-width: 860px; // цепочка не на всю ширину — как в эталоне (.chainList)
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border: 1px solid $border;
border-radius: 4px;
font-size: 13px;
}
.num {
width: 24px;
height: 24px;
flex-shrink: 0;
border-radius: 50%;
background: $head-bg;
color: $muted;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.name {
font-weight: 600;
color: $text;
white-space: nowrap;
}
.spacer {
flex: 1;
}
.comment {
color: $muted;
font-style: italic;
}
.date {
color: $muted;
font-size: 11px;
white-space: nowrap;
}
.actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.rejectBtn {
color: $danger !important;
border-color: $danger !important;
&:hover {
background: $danger-bg !important;
}
}
// --- Поиск согласующего ---
.searchBox {
position: relative;
max-width: 420px;
}
.suggestions {
list-style: none;
margin: 2px 0 0;
padding: 4px 0;
position: absolute;
z-index: 5;
left: 0;
right: 0;
background: $surface;
border: 1px solid $border-strong;
border-radius: 4px;
box-shadow: $sh-2;
max-height: 240px;
overflow-y: auto;
}
.suggestion {
display: flex;
flex-direction: column;
padding: 6px 12px;
cursor: pointer;
&:hover {
background: $hover;
}
}
.suggestionName {
font-size: 13px;
color: $text;
}
.suggestionEmail {
font-size: 11px;
color: $muted;
}
// --- Футер ---
.footer {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding-top: 12px;
border-top: 1px solid $border;
}
@@ -0,0 +1,191 @@
import * as React from 'react';
import { Button, Dialog, IconButton, Alert, Spinner, TextInput, TextArea, Icon } from '@tnsu/ui-kit';
import { StepShell } from '../common/StepShell';
import { Pill } from '../common/Pill';
import { APPROVER_STATUS_META, StepProps } from '../types/domain';
import { formatDateTime } from '../utils/format';
import { useStep4Approval } from './useStep4Approval';
import styles from './Step4Approval.module.scss';
// Шаг 4 «Согласование» (§12): цепочка согласования. Логика — в useStep4Approval.
// Оповещение согласующих в Teams (4c) — через notify; замена участника — через диалог openReplace.
export function Step4Approval(props: StepProps): JSX.Element {
const { service, formData, onSave, onPrev, onStepComplete, userDisplayName } = props;
const a = useStep4Approval({ service, formData, onSave, onStepComplete, userDisplayName });
const status = formData.status;
return (
<StepShell title="Согласование" onPrev={onPrev}>
<div className={styles.body}>
{status === 'approved' ? <Alert variant="ok">СТ согласована всеми участниками.</Alert> : undefined}
{status === 'rejected' ? <Alert variant="err">СТ отклонена. Требуется корректировка и повторная отправка.</Alert> : undefined}
{status === 'on_approval' ? <Alert variant="info">СТ находится на согласовании.</Alert> : undefined}
<section className={styles.section}>
<h3 className={styles.title}>Цепочка согласования</h3>
{a.chain.length === 0 ? (
<p className={styles.empty}>Добавьте участников для согласования.</p>
) : (
<ol className={styles.list}>
{a.chain.map((member, index) => {
const meta = APPROVER_STATUS_META[member.status];
// Заменить можно не-инициатора со статусом pending, кроме того, чья очередь идёт сейчас
// (до отправки и во время согласования) — как в эталоне.
const canReplace =
!member.isInitiator &&
member.status === 'pending' &&
!(a.isOnApproval && a.currentUserRowId === member.rowId);
return (
<li key={member.rowId} className={styles.row}>
<span className={styles.num}>{member.order}</span>
<span className={styles.name}>{member.displayName}</span>
{member.isInitiator ? <Pill kind="neutral">Инициатор</Pill> : undefined}
<span className={styles.spacer} />
{a.showStatuses ? <Pill kind={meta.kind}>{meta.label}</Pill> : undefined}
{member.comment ? <span className={styles.comment}>«{member.comment}»</span> : undefined}
{member.decidedAt ? <span className={styles.date}>{formatDateTime(member.decidedAt)}</span> : undefined}
{a.canEdit && !member.isInitiator ? (
<div className={styles.actions}>
<IconButton icon="arrow-up" size="sm" title="Выше" disabled={index === 0} onClick={() => a.moveApprover(member.rowId, 'up')} />
<IconButton icon="arrow-down" size="sm" title="Ниже" disabled={index === a.chain.length - 1} onClick={() => a.moveApprover(member.rowId, 'down')} />
<IconButton icon="trash" size="sm" danger title="Удалить" onClick={() => a.removeApprover(member.rowId)} />
</div>
) : undefined}
{a.isOnApproval && a.currentUserRowId === member.rowId ? (
<div className={styles.actions}>
<Button variant="primary" size="sm" onClick={() => a.openAction(member, 'approve')}>
<Icon name="check" size="sm" />
Согласовать
</Button>
<Button variant="danger" size="sm" onClick={() => a.openAction(member, 'reject')}>
<Icon name="close" size="sm" />
Отклонить
</Button>
</div>
) : undefined}
{canReplace ? (
<div className={styles.actions}>
<IconButton icon="swap" size="sm" title="Заменить согласующего" onClick={() => a.openReplace(member)} />
</div>
) : undefined}
</li>
);
})}
</ol>
)}
{a.canEdit ? (
<div className={styles.searchBox}>
<TextInput
placeholder="Поиск согласующего (имя)…"
ariaLabel="Поиск согласующего"
value={a.query}
onChange={a.setQuery}
/>
{a.suggestions.length > 0 ? (
<ul className={styles.suggestions}>
{a.suggestions.map((s) => (
<li key={s.spId} className={styles.suggestion} onClick={() => a.addApprover(s)}>
<span className={styles.suggestionName}>{s.displayName}</span>
<span className={styles.suggestionEmail}>{s.email}</span>
</li>
))}
</ul>
) : undefined}
</div>
) : undefined}
</section>
<div className={styles.footer}>
{a.error ? <Alert variant="err">{a.error}</Alert> : undefined}
{a.notifyWarning ? <Alert variant="warn">{a.notifyWarning}</Alert> : undefined}
{a.canEdit ? (
<Button variant="primary" onClick={a.sendForApproval} disabled={a.chain.length === 0 || a.sending}>
{a.sending ? (
<Spinner size="small" />
) : (
<>
<Icon name="send" size="sm" />
Отправить на согласование
</>
)}
</Button>
) : undefined}
{(status === 'on_approval' || status === 'rejected') && a.currentUserIsInitiator ? (
<Button variant="secondary" onClick={a.restart} disabled={a.sending}>
<Icon name="refresh" size="sm" />
Перезапустить согласование
</Button>
) : undefined}
</div>
{a.action ? (
<Dialog
open
title={a.action.kind === 'approve' ? 'Согласование' : 'Отклонение'}
subText={a.action.kind === 'approve' ? 'Подтвердите согласование СТ. Комментарий необязателен.' : 'Укажите причину отклонения (обязательно).'}
onDismiss={a.closeAction}
footer={
<>
<Button variant="secondary" onClick={a.closeAction} disabled={a.sending}>Отмена</Button>
<Button
variant={a.action.kind === 'approve' ? 'primary' : 'danger'}
onClick={a.submitAction}
disabled={a.sending || (a.action.kind === 'reject' && !a.action.comment.trim())}
>
{a.action.kind === 'approve' ? 'Согласовать' : 'Отклонить'}
</Button>
</>
}
>
<TextArea
value={a.action.comment}
onChange={a.setActionComment}
rows={3}
ariaLabel="Комментарий"
placeholder={a.action.kind === 'reject' ? 'Причина отклонения…' : 'Комментарий (необязательно)…'}
/>
</Dialog>
) : undefined}
{a.replace ? (
<Dialog
open
title="Заменить согласующего"
subText={`Текущий: ${a.replace.name}. Найдите нового участника.`}
onDismiss={a.closeReplace}
footer={
<>
<Button variant="secondary" onClick={a.closeReplace} disabled={a.sending}>Отмена</Button>
<Button variant="primary" onClick={a.submitReplace} disabled={!a.replace.selected || a.sending}>Заменить</Button>
</>
}
>
<div className={styles.searchBox}>
<TextInput
placeholder="Поиск участника (имя)…"
ariaLabel="Поиск участника"
value={a.replace.query}
onChange={a.setReplaceQuery}
/>
{a.replace.suggestions.length > 0 ? (
<ul className={styles.suggestions}>
{a.replace.suggestions.map((s) => (
<li key={s.spId} className={styles.suggestion} onClick={() => a.selectReplace(s)}>
<span className={styles.suggestionName}>{s.displayName}</span>
<span className={styles.suggestionEmail}>{s.email}</span>
</li>
))}
</ul>
) : undefined}
</div>
</Dialog>
) : undefined}
</div>
</StepShell>
);
}
@@ -0,0 +1,396 @@
import { useEffect, useRef, useState } from 'react';
import { Approver, CtFormData, FormStep } from '../types/domain';
import { KpsCtFormService } from '../../services/KpsCtFormService';
// Логика Шага 4 (цепочка согласования) — отдельно от вёрстки. Оповещение согласующих — через notify (4c).
// Уникальный ключ СТРОКИ цепочки (счётчик на время жизни страницы) — один человек может быть в цепочке
// несколько раз. Загруженные из SP строки получают `sp_<id>` в сервисе; локальные — `row_<n>` отсюда.
let rowIdCounter = 0;
function makeRowId(): string {
rowIdCounter += 1;
return `row_${rowIdCounter}`;
}
export interface UserSuggestion {
spId: number;
displayName: string;
email: string;
}
export interface ActionDialog {
rowId: string; // строка цепочки, по которой голосуем (не userId — человек может быть в цепочке дважды)
name: string;
kind: 'approve' | 'reject';
comment: string;
}
export interface ReplaceDialog {
rowId: string; // строка цепочки, которую меняем
spApprovalId?: number; // запись в SP (есть в фазе согласования; до отправки — нет)
name: string; // текущее имя — для заголовка диалога
query: string;
suggestions: UserSuggestion[];
selected?: UserSuggestion;
}
interface UseStep4ApprovalArgs {
service: KpsCtFormService;
formData: CtFormData;
onSave: (partial: Partial<CtFormData>) => void;
onStepComplete: (step: FormStep) => void;
userDisplayName: string;
// Разрешено ли засевать новую цепочку (инициатор + матрица) при пустой цепочке. Инлайн-блок на Шаге 3
// (согласующий по ссылке) сид НЕ запускает — только читает из SP. По умолчанию — да (Шаг 4).
canSeed?: boolean;
}
export interface Step4Approval {
chain: Approver[];
canEdit: boolean; // до отправки — цепочку можно менять
isOnApproval: boolean; // активная фаза голосования
showStatuses: boolean;
currentUserIsInitiator: boolean;
currentUserRowId?: string; // rowId строки, чья очередь = текущий пользователь
query: string;
setQuery: (q: string) => void;
suggestions: UserSuggestion[];
addApprover: (u: UserSuggestion) => void;
removeApprover: (rowId: string) => void;
moveApprover: (rowId: string, dir: 'up' | 'down') => void;
sending: boolean;
error?: string;
notifyWarning?: string; // голос/отправка прошли, но флоу-оповещение не удалось
sendForApproval: () => void;
action?: ActionDialog;
openAction: (approver: Approver, kind: 'approve' | 'reject') => void;
closeAction: () => void;
setActionComment: (c: string) => void;
submitAction: () => void;
replace?: ReplaceDialog;
openReplace: (approver: Approver) => void;
closeReplace: () => void;
setReplaceQuery: (q: string) => void;
selectReplace: (u: UserSuggestion) => void;
submitReplace: () => void;
restart: () => void;
}
export function useStep4Approval(args: UseStep4ApprovalArgs): Step4Approval {
const { service, formData, onSave, onStepComplete, userDisplayName, canSeed = true } = args;
const chain = formData.approvers;
const status = formData.status;
const isOnApprovalPhase = status === 'on_approval' || status === 'approved' || status === 'rejected';
const canEdit = !isOnApprovalPhase;
const isOnApproval = status === 'on_approval';
const [query, setQueryState] = useState('');
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const [action, setAction] = useState<ActionDialog | undefined>(undefined);
const [replace, setReplace] = useState<ReplaceDialog | undefined>(undefined);
const [notifyWarning, setNotifyWarning] = useState<string | undefined>(undefined);
const searchTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const replaceTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Некритичное оповещение согласующего: сбой флоу НЕ должен валить голос/отправку (§7).
const notify = async (spApprovalId: number): Promise<void> => {
try {
await service.triggerApprovalNotification(spApprovalId);
} catch (e) {
// eslint-disable-next-line no-console
console.error('[Step4] triggerApprovalNotification failed for spId', spApprovalId, e);
setNotifyWarning('Действие выполнено, но уведомление в Teams не удалось отправить. Проверьте Power Automate.');
}
};
// Восстановление/инициализация цепочки один раз при монтировании.
useEffect(() => {
if (chain.length > 0) {
return;
}
if (isOnApprovalPhase && formData.ctId) {
service
.loadApprovalChain(formData.ctId)
.then((loaded) => {
if (loaded.length > 0) {
onSave({ approvers: loaded });
}
})
.catch(() => undefined);
} else if (canSeed && canEdit && service.currentUserSpId > 0) {
const me = service.currentUserSpId;
const initiator: Approver = { rowId: makeRowId(), userId: me, displayName: userDisplayName, order: 1, status: 'pending', isInitiator: true };
// Автозаполнение из «Матрица согласования КПС» (инициатор + участники по порядку); при ошибке — только инициатор.
service
.loadApprovalMatrix()
.then((matrix) => {
const rest: Approver[] = matrix
.filter((m) => m.userId !== me)
.map((m, i): Approver => ({ rowId: makeRowId(), userId: m.userId, displayName: m.displayName, order: i + 2, status: 'pending' }));
onSave({ approvers: [initiator, ...rest] });
})
.catch(() => onSave({ approvers: [initiator] }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const setQuery = (q: string): void => {
setQueryState(q);
setSuggestions([]);
if (searchTimer.current) {
clearTimeout(searchTimer.current);
}
if (q.trim().length < 2) {
return;
}
searchTimer.current = setTimeout(() => {
// Показываем всех найденных — фильтра «уже в цепочке» нет: один человек может быть добавлен
// в цепочку несколько раз (строки различаются по rowId).
service
.searchUsers(q.trim())
.then((res) => setSuggestions(res))
.catch(() => undefined);
}, 300);
};
const reindex = (list: Approver[]): Approver[] => list.map((m, i) => ({ ...m, order: i + 1 }));
const addApprover = (u: UserSuggestion): void => {
onSave({ approvers: reindex([...chain, { rowId: makeRowId(), userId: u.spId, displayName: u.displayName, email: u.email, order: chain.length + 1, status: 'pending' }]) });
setQueryState('');
setSuggestions([]);
};
const removeApprover = (rowId: string): void => {
onSave({ approvers: reindex(chain.filter((m) => m.rowId !== rowId || m.isInitiator)) });
};
const moveApprover = (rowId: string, dir: 'up' | 'down'): void => {
const idx = chain.findIndex((m) => m.rowId === rowId);
const swap = dir === 'up' ? idx - 1 : idx + 1;
if (idx === -1 || swap < 0 || swap >= chain.length || chain[swap].isInitiator) {
return;
}
const next = [...chain];
[next[idx], next[swap]] = [next[swap], next[idx]];
onSave({ approvers: reindex(next) });
};
const sendForApproval = (): void => {
if (!formData.ctId || chain.length === 0) {
return;
}
setSending(true);
setError(undefined);
setNotifyWarning(undefined);
const ctId = formData.ctId;
const activated: Approver[] = chain.map((m) => ({ ...m, status: m.isInitiator ? 'recommends' : 'pending' }));
const winSpId = formData.suppliers.find((s) => s.id === formData.recommendedSupplierId)?.spId;
(async () => {
const saved = await service.saveApprovalChain(ctId, activated);
await service.updateCtStatus(ctId, 'on_approval', winSpId);
onSave({ approvers: saved, status: 'on_approval' });
const firstApprover = saved.find((m) => !m.isInitiator && m.spApprovalId);
if (firstApprover?.spApprovalId) {
await notify(firstApprover.spApprovalId);
}
onStepComplete(4);
})()
.then(() => setSending(false))
.catch((e: Error) => {
setError(e.message);
setSending(false);
});
};
const openAction = (approver: Approver, kind: 'approve' | 'reject'): void =>
setAction({ rowId: approver.rowId, name: approver.displayName, kind, comment: '' });
const closeAction = (): void => setAction(undefined);
const setActionComment = (c: string): void => setAction((prev) => (prev ? { ...prev, comment: c } : prev));
const submitAction = (): void => {
if (!action || !formData.ctId) {
return;
}
const member = chain.find((m) => m.rowId === action.rowId);
if (!member?.spApprovalId) {
setError('Не найден ID записи согласования');
return;
}
if (action.kind === 'reject' && !action.comment.trim()) {
return;
}
setSending(true);
setError(undefined);
setNotifyWarning(undefined);
const ctId = formData.ctId;
const decision = action.kind === 'approve' ? 'approved' : 'rejected';
const comment = action.comment.trim();
const spId = member.spApprovalId;
(async () => {
await service.submitApprovalStep(spId, decision, comment);
const updated: Approver[] = chain.map((m) =>
m.rowId === action.rowId ? { ...m, status: decision, comment, decidedAt: new Date().toISOString() } : m
);
if (decision === 'approved') {
// Уведомить проголосовавшего (поток видит «Согласовано») и следующего по очереди.
await notify(spId);
const nextPending = updated.find((m) => m.status === 'pending' && !m.isInitiator);
if (nextPending?.spApprovalId) {
await notify(nextPending.spApprovalId);
}
const allApproved = updated.every((m) => m.status === 'approved' || m.status === 'recommends');
if (allApproved) {
await service.updateCtStatus(ctId, 'approved');
onSave({ approvers: updated, status: 'approved' });
} else {
onSave({ approvers: updated });
}
} else {
// Отклонение — процесс остановлен: удалить оставшихся pending из SP и цепочки, уведомить инициатора.
// Уведомляем через spId ОТКЛОНИВШЕГО (его статус уже "Отклонено" — поток определяет ветку по этой строке),
// а не через строку инициатора: там статус всегда "Рекомендую" и ни под одно условие потока не подходит.
const others = updated.filter((m) => m.status === 'pending' && !m.isInitiator && m.spApprovalId);
if (others.length > 0) {
await service.deleteApprovalMembers(others.map((m) => m.spApprovalId as number));
}
const finalChain = updated.filter((m) => m.status !== 'pending' || m.isInitiator);
await service.updateCtStatus(ctId, 'rejected');
onSave({ approvers: finalChain, status: 'rejected' });
await notify(spId);
}
})()
.then(() => {
setSending(false);
setAction(undefined);
})
.catch((e: Error) => {
setError(e.message);
setSending(false);
});
};
const openReplace = (approver: Approver): void =>
setReplace({
rowId: approver.rowId,
spApprovalId: approver.spApprovalId,
name: approver.displayName,
query: '',
suggestions: [],
selected: undefined
});
const closeReplace = (): void => setReplace(undefined);
const setReplaceQuery = (q: string): void => {
setReplace((prev) => (prev ? { ...prev, query: q, suggestions: [], selected: undefined } : prev));
if (replaceTimer.current) {
clearTimeout(replaceTimer.current);
}
if (q.trim().length < 2) {
return;
}
replaceTimer.current = setTimeout(() => {
service
.searchUsers(q.trim())
.then((res) => {
// Без фильтра «уже в цепочке» — заменить можно на кого угодно (в т.ч. уже присутствующего).
setReplace((prev) => (prev ? { ...prev, suggestions: res } : prev));
})
.catch(() => undefined);
}, 300);
};
const selectReplace = (u: UserSuggestion): void =>
setReplace((prev) => (prev ? { ...prev, selected: u, query: u.displayName, suggestions: [] } : prev));
// Замена персоны на записи: до отправки (нет spApprovalId) — только локально, при отправке цепочка пишется
// заново; в фазе согласования — MERGE aprooverId на записи «Согласование КПС».
const submitReplace = (): void => {
if (!replace?.selected) {
return;
}
const targetRowId = replace.rowId;
const spApprovalId = replace.spApprovalId;
const picked = replace.selected;
setSending(true);
setError(undefined);
(async () => {
if (spApprovalId) {
await service.replaceApprovalMember(spApprovalId, picked.spId);
}
const updated: Approver[] = chain.map((m) =>
m.rowId === targetRowId ? { ...m, userId: picked.spId, displayName: picked.displayName, email: picked.email } : m
);
onSave({ approvers: updated });
})()
.then(() => {
setSending(false);
setReplace(undefined);
})
.catch((e: Error) => {
setError(e.message);
setSending(false);
});
};
const restart = (): void => {
if (!formData.ctId) {
return;
}
setSending(true);
setError(undefined);
const ctId = formData.ctId;
const reset: Approver[] = chain.map((m) => ({ ...m, status: 'pending', comment: undefined, decidedAt: undefined }));
(async () => {
await service.updateCtStatus(ctId, 'formed');
onSave({ approvers: reset, status: 'formed' });
})()
.then(() => setSending(false))
.catch((e: Error) => {
setError(e.message);
setSending(false);
});
};
const currentUserIsInitiator = chain.some((m) => m.displayName === userDisplayName && m.isInitiator);
const currentUserMember = chain.find(
(m) =>
m.displayName === userDisplayName &&
m.status === 'pending' &&
!m.isInitiator &&
chain.filter((p) => p.order < m.order).every((p) => p.status !== 'pending')
);
return {
chain,
canEdit,
isOnApproval,
showStatuses: isOnApprovalPhase,
currentUserIsInitiator,
currentUserRowId: currentUserMember?.rowId,
query,
setQuery,
suggestions,
addApprover,
removeApprover,
moveApprover,
sending,
error,
notifyWarning,
sendForApproval,
action,
openAction,
closeAction,
setActionComment,
submitAction,
replace,
openReplace,
closeReplace,
setReplaceQuery,
selectReplace,
submitReplace,
restart
};
}
@@ -0,0 +1,71 @@
@import './theme';
.chain {
display: flex;
flex-direction: column;
gap: 8px;
}
.title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: $text;
}
.empty {
margin: 0;
color: $muted;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid $border;
border-radius: 4px;
}
.num {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: $hover;
color: $muted;
font-size: 12px;
font-weight: 600;
}
.name {
font-weight: 600;
color: $text;
}
.spacer {
flex: 1 1 auto;
}
.comment {
color: $muted;
font-style: italic;
}
.date {
color: $muted;
font-size: 12px;
white-space: nowrap;
}
@@ -0,0 +1,39 @@
import * as React from 'react';
import styles from './ApprovalChain.module.scss';
import { Pill } from './Pill';
import { Approver, APPROVER_STATUS_META } from '../types/domain';
import { formatDateTime } from '../utils/format';
interface ApprovalChainProps {
approvers: Approver[];
}
// Цепочка согласования (эталон): номер, имя, роль, статус-пилюля, комментарий, дата.
// Общая для Шага 3 (внизу таблицы) и Шага 4.
export function ApprovalChain({ approvers }: ApprovalChainProps): JSX.Element {
return (
<div className={styles.chain}>
<h3 className={styles.title}>Цепочка согласования</h3>
{approvers.length === 0 ? (
<p className={styles.empty}>Цепочка согласования ещё не сформирована.</p>
) : (
<ol className={styles.list}>
{approvers.map((approver, index) => {
const meta = APPROVER_STATUS_META[approver.status];
return (
<li key={approver.rowId} className={styles.row}>
<span className={styles.num}>{approver.order || index + 1}</span>
<span className={styles.name}>{approver.displayName}</span>
{approver.isInitiator ? <Pill kind="neutral">Инициатор</Pill> : undefined}
<span className={styles.spacer} />
<Pill kind={meta.kind}>{meta.label}</Pill>
{approver.comment ? <span className={styles.comment}>«{approver.comment}»</span> : undefined}
<span className={styles.date}>{formatDateTime(approver.decidedAt)}</span>
</li>
);
})}
</ol>
)}
</div>
);
}
@@ -0,0 +1,16 @@
import * as React from 'react';
import { FormHeader as UiFormHeader } from '@tnsu/ui-kit';
import { Pill } from './Pill';
import { PillKind } from '../types/domain';
interface FormHeaderProps {
title: string;
statusLabel: string;
statusKind: PillKind;
}
// Адаптер над FormHeader из @tnsu/ui-kit: золотой акцент + иконка + статус-пилюля (через Pill→Badge).
// Вызовы формы (KpsCtForm) не меняются — контракт props сохранён.
export function FormHeader({ title, statusLabel, statusKind }: FormHeaderProps): JSX.Element {
return <UiFormHeader title={title} icon="document" right={<Pill kind={statusKind}>{statusLabel}</Pill>} />;
}
@@ -0,0 +1,23 @@
import * as React from 'react';
import { Badge, BadgeVariant } from '@tnsu/ui-kit';
import { PillKind } from '../types/domain';
interface PillProps {
kind: PillKind;
solid?: boolean; // сохранён для совместимости вызовов; Badge всегда «мягкий»
children: React.ReactNode;
}
// Семантика формы (PillKind) → вариант бейджа ui-kit.
const KIND_TO_VARIANT: Record<PillKind, BadgeVariant> = {
neutral: 'neutral',
accent: 'info',
ok: 'success',
danger: 'danger'
};
// Тонкий адаптер над Badge из @tnsu/ui-kit: единая статус-пилюля формы теперь
// красится брендовыми токенами. Цвет по kind (смысл назначает форма).
export function Pill({ kind, children }: PillProps): JSX.Element {
return <Badge variant={KIND_TO_VARIANT[kind]}>{children}</Badge>;
}
@@ -0,0 +1,29 @@
.stepShell {
display: flex;
flex-direction: column;
gap: 16px;
}
.header {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #323130;
}
.content {
flex: 1 1 auto;
}
.actions {
display: flex;
justify-content: space-between;
gap: 12px;
padding-top: 12px;
border-top: 1px solid #edebe9;
}
// Кнопка «Далее» прижата вправо даже когда «Назад» отсутствует.
.spacer {
flex: 1 1 auto;
}
@@ -0,0 +1,33 @@
import * as React from 'react';
import { Button } from '@tnsu/ui-kit';
import styles from './StepShell.module.scss';
export interface StepShellProps {
title: string;
children: React.ReactNode;
onPrev?: () => void;
onNext?: () => void;
nextLabel?: string;
nextDisabled?: boolean;
}
// Общий каркас шага: заголовок, контент и панель навигации (§4 — блок повторяется на всех шагах).
export function StepShell(props: StepShellProps): JSX.Element {
const { title, children, onPrev, onNext, nextLabel = 'Далее', nextDisabled } = props;
return (
<div className={styles.stepShell}>
<h2 className={styles.header}>{title}</h2>
<div className={styles.content}>{children}</div>
<div className={styles.actions}>
{onPrev ? <Button variant="secondary" onClick={onPrev}>Назад</Button> : <span className={styles.spacer} />}
{onNext ? (
<Button variant="primary" disabled={nextDisabled} onClick={onNext}>
{nextLabel}
</Button>
) : undefined}
</div>
</div>
);
}
@@ -0,0 +1,34 @@
// МОСТ семантики формы СТ → бренд-токены TNSU (@tnsu/ui-kit, зеркало в src/styles).
// Имена переменных сохранены (был Fluent-синий) — все *.module.scss шагов, которые
// делают `@import '../common/theme'`, перекрашиваются в navy/gold БЕЗ своих правок.
// Источник цвета — только бренд-токены ниже; хардкоды в module.scss убираем в Фазе 2.
@import '../../../../../../styles/design-tokens';
// ---- Акцент (титул, текущий шаг, ссылки) → navy ----
$accent: $navy-800;
$accent-bg: $navy-50;
$accent-border: $navy-100; // рамки акцентных таблиц (был #c7e0f4)
$accent-dark: $navy-950; // тёмный текст/акцент (был #004578)
$accent-hover: $navy-700; // ховер акцентных кнопок (был #106ebe)
// ---- Статусы ----
$ok: $green-600;
$ok-bg: $green-50;
$danger: $red-500;
$danger-bg: $red-50;
$warn-fg: $amber-700; // тёмно-жёлтый текст (был #7d5900)
$warn-bg: $amber-50; // жёлтый фон предупреждений
// ---- Текст/границы/фоны ----
$text: $gray-900;
$muted: $gray-500;
$muted-2: $gray-400; // приглушённый серый (был #a19f9d)
$border: $gray-200;
$border-strong: $gray-300;
$head-bg: $gray-50; // фон шапок таблиц
$hover: $gray-100; // ховер строк/кнопок
$surface: $white;
// ---- Кремовая панель поставщика (Шаг 3) → gold ----
$supplier-bg: $gold-50;
$supplier-border: $gold-100;
@@ -0,0 +1,113 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
CtFormData,
EMPTY_FORM_DATA,
FIRST_STEP,
FormStep,
LAST_STEP
} from '../types/domain';
import { loadFormState, saveFormState, sessionKey, writeCtIdToUrl } from '../utils/session';
// То, что сериализуем в sessionStorage. highestStep не храним — он производный от completedSteps.
interface PersistedState {
formData: CtFormData;
currentStep: FormStep;
completedSteps: FormStep[];
}
export interface UseKpsCtFormResult {
formData: CtFormData;
currentStep: FormStep;
completedSteps: FormStep[];
highestStep: FormStep; // максимально доступный шаг (§5.5)
updateFormData: (partial: Partial<CtFormData>) => void; // точечное иммутабельное обновление (§5.1)
replaceFormData: (data: CtFormData) => void; // полная замена (загрузка из SharePoint)
goToStep: (step: FormStep) => void;
next: () => void;
prev: () => void;
markStepComplete: (step: FormStep) => void;
}
// Весь стейт многошаговой формы живёт здесь (§5.1). Шаги получают срез данных и колбэки.
export function useKpsCtForm(initialCtId?: number): UseKpsCtFormResult {
const [state, setState] = useState<PersistedState>(() => readInitialState(initialCtId));
const { formData, currentStep, completedSteps } = state;
// Производный: доступен следующий шаг после максимального завершённого (§5.5).
const highestStep = useMemo<FormStep>(() => {
const maxCompleted = completedSteps.reduce((max, step) => Math.max(max, step), 0);
return clampStep(Math.max(FIRST_STEP, maxCompleted + 1));
}, [completedSteps]);
// Персист при каждом изменении (§5.4). Ключ зависит от ctId (он часть state).
useEffect(() => {
saveFormState<PersistedState>(sessionKey(state.formData.ctId), state);
}, [state]);
// Как только сущность создана — фиксируем id в URL, чтобы перезагрузка продолжила форму (§5.4).
useEffect(() => {
if (formData.ctId) {
writeCtIdToUrl(formData.ctId);
}
}, [formData.ctId]);
const updateFormData = useCallback((partial: Partial<CtFormData>) => {
setState(prev => ({ ...prev, formData: { ...prev.formData, ...partial } }));
}, []);
const replaceFormData = useCallback((data: CtFormData) => {
setState(prev => ({ ...prev, formData: data }));
}, []);
const goToStep = useCallback((step: FormStep) => {
setState(prev => ({ ...prev, currentStep: clampStep(step) }));
}, []);
const next = useCallback(() => {
setState(prev => ({ ...prev, currentStep: clampStep(prev.currentStep + 1) }));
}, []);
const prev = useCallback(() => {
setState(prevState => ({ ...prevState, currentStep: clampStep(prevState.currentStep - 1) }));
}, []);
const markStepComplete = useCallback((step: FormStep) => {
setState(prev => {
if (prev.completedSteps.indexOf(step) !== -1) {
return prev;
}
return { ...prev, completedSteps: [...prev.completedSteps, step] };
});
}, []);
return {
formData,
currentStep,
completedSteps,
highestStep,
updateFormData,
replaceFormData,
goToStep,
next,
prev,
markStepComplete
};
}
// Стартовое состояние: из сессии, если есть; иначе пустая форма (с ctId из URL, если задан).
function readInitialState(initialCtId?: number): PersistedState {
const persisted = loadFormState<PersistedState>(sessionKey(initialCtId));
if (persisted) {
return persisted;
}
return {
formData: initialCtId ? { ...EMPTY_FORM_DATA, ctId: initialCtId } : EMPTY_FORM_DATA,
currentStep: FIRST_STEP,
completedSteps: []
};
}
function clampStep(step: number): FormStep {
const bounded = Math.min(LAST_STEP, Math.max(FIRST_STEP, step));
return bounded as FormStep;
}

Some files were not shown because too many files have changed in this diff Show More