объединение CT, KPS, OID и дашборда в один портал
Перенос, декомпозиция и перекраска форм CT/KPS/OID на @tnsu/ui-kit, config-driven дашборд-реестр по трём типам документов. Из отслеживания исключены артефакты сборки (lib-commonjs/jest-output/release) — забыты в .gitignore ранее.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
+166
@@ -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;
|
||||
}
|
||||
+179
@@ -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>
|
||||
);
|
||||
};
|
||||
+76
@@ -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;
|
||||
}
|
||||
+287
@@ -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>
|
||||
);
|
||||
};
|
||||
+102
@@ -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;
|
||||
}
|
||||
+175
@@ -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" /> Загрузка…</>
|
||||
: 'Получить актуальный курс'}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
+186
@@ -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;
|
||||
}
|
||||
}
|
||||
+143
@@ -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>
|
||||
);
|
||||
};
|
||||
+184
@@ -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;
|
||||
}
|
||||
+131
@@ -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>
|
||||
);
|
||||
};
|
||||
+72
@@ -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>
|
||||
);
|
||||
};
|
||||
+40
@@ -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,
|
||||
};
|
||||
}
|
||||
+252
@@ -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>
|
||||
);
|
||||
}
|
||||
+43
@@ -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>
|
||||
);
|
||||
};
|
||||
+230
@@ -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>
|
||||
);
|
||||
}
|
||||
+141
@@ -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>
|
||||
);
|
||||
}
|
||||
+218
@@ -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>
|
||||
);
|
||||
}
|
||||
+169
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+240
@@ -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>;
|
||||
}
|
||||
+1109
File diff suppressed because it is too large
Load Diff
+207
@@ -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-сущностями (напр. : вместо ":") — декодируем перед показом.
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
+221
@@ -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,
|
||||
};
|
||||
}
|
||||
+860
@@ -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>;
|
||||
+267
@@ -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>
|
||||
);
|
||||
}
|
||||
+146
@@ -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>
|
||||
);
|
||||
}
|
||||
+148
@@ -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;
|
||||
}
|
||||
+175
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
+135
@@ -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
|
||||
};
|
||||
}
|
||||
+93
@@ -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>
|
||||
);
|
||||
}
|
||||
+52
@@ -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>
|
||||
);
|
||||
}
|
||||
+118
@@ -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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+599
@@ -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>
|
||||
);
|
||||
}
|
||||
+151
@@ -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
Reference in New Issue
Block a user