Compare commits
6 Commits
34da9ae165
...
e40fc402d9
| Author | SHA1 | Date | |
|---|---|---|---|
| e40fc402d9 | |||
| c1e8792174 | |||
| 5192311fff | |||
| 4079bddc71 | |||
| 045a4f655b | |||
| 2f00a2dcc9 |
@@ -20,7 +20,7 @@ import {
|
|||||||
Alert
|
Alert
|
||||||
} from '@tnsu/ui-kit';
|
} from '@tnsu/ui-kit';
|
||||||
import { DOCUMENT_TYPES } from './config';
|
import { DOCUMENT_TYPES } from './config';
|
||||||
import { buildSidebarItems, CONTRAGENTS_ID } from './sidebarNav';
|
import { buildSidebarItems, CONTRAGENTS_ID, SIDEBAR_BRAND } from './sidebarNav';
|
||||||
import { useDocumentDashboard } from './registry/useDocumentDashboard';
|
import { useDocumentDashboard } from './registry/useDocumentDashboard';
|
||||||
import { DashboardRow } from './registry/documentRegistry';
|
import { DashboardRow } from './registry/documentRegistry';
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ export const Dashboard: React.FC<IDashboardProps> = ({ context, onNavigateForm }
|
|||||||
items={sidebarItems}
|
items={sidebarItems}
|
||||||
activeId={activeType}
|
activeId={activeType}
|
||||||
onChange={onSidebarChange}
|
onChange={onSidebarChange}
|
||||||
brand="Договора"
|
brand={SIDEBAR_BRAND}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapsed={() => setCollapsed((c) => !c)}
|
onToggleCollapsed={() => setCollapsed((c) => !c)}
|
||||||
mobileOpen={mobileOpen}
|
mobileOpen={mobileOpen}
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ export function createCtDocumentConfig(): DocumentTypeConfig {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
createNewLabel: 'Создать новую форму СТ',
|
createNewLabel: 'Создать новую форму СТ',
|
||||||
// Форма CT встроена в портал (маршрут). Открытие существующей: ctId (черновик) / ctid (иначе)
|
// Форма CT встроена в портал (маршрут). ctId / ctid форма читает одинаково — режим (правка/просмотр)
|
||||||
// — как читает сама форма (см. forms/ct/.../hooks/useCtForm.ts).
|
// она считает сама по автору и статусу (forms/ct/.../utils/ctAccess.ts); регистр — для старых ссылок.
|
||||||
inPortal: true,
|
inPortal: true,
|
||||||
buildInPortalParams: (row) => ({ [row.status === 'Черновик' ? 'ctId' : 'ctid']: row.id }),
|
buildInPortalParams: (row) => ({ [row.status === 'Черновик' ? 'ctId' : 'ctid']: row.id }),
|
||||||
mapItem: (raw): DashboardRow => {
|
mapItem: (raw): DashboardRow => {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { ISidebarItem, IconName } from '@tnsu/ui-kit';
|
import { ISidebarItem, IconName } from '@tnsu/ui-kit';
|
||||||
import { DOCUMENT_TYPES } from './config';
|
import { DOCUMENT_TYPES } from './config';
|
||||||
|
|
||||||
|
// Заголовок сайдбара — одинаковый на всех экранах портала.
|
||||||
|
export const SIDEBAR_BRAND = 'Снабжение';
|
||||||
|
|
||||||
// Общий раздел «База контрагентов» (отдельный маршрут ?form=contragents).
|
// Общий раздел «База контрагентов» (отдельный маршрут ?form=contragents).
|
||||||
export const CONTRAGENTS_ID = 'contragents';
|
export const CONTRAGENTS_ID = 'contragents';
|
||||||
export const CONTRAGENTS_TITLE = 'База контрагентов';
|
export const CONTRAGENTS_TITLE = 'База контрагентов';
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildSidebarItems,
|
buildSidebarItems,
|
||||||
CONTRAGENTS_ID,
|
CONTRAGENTS_ID,
|
||||||
|
SIDEBAR_BRAND,
|
||||||
} from "../../../components/sidebarNav";
|
} from "../../../components/sidebarNav";
|
||||||
import { ContragentsService } from "../services/ContragentsService";
|
import { ContragentsService } from "../services/ContragentsService";
|
||||||
import { SuppliersService } from "../services/SuppliersService";
|
import { SuppliersService } from "../services/SuppliersService";
|
||||||
@@ -164,7 +165,7 @@ export const ContragentsRegistry: React.FC<IContragentsRegistryProps> = ({
|
|||||||
items={buildSidebarItems()}
|
items={buildSidebarItems()}
|
||||||
activeId={CONTRAGENTS_ID}
|
activeId={CONTRAGENTS_ID}
|
||||||
onChange={onSidebarChange}
|
onChange={onSidebarChange}
|
||||||
brand="Договора"
|
brand={SIDEBAR_BRAND}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapsed={() => setCollapsed((c) => !c)}
|
onToggleCollapsed={() => setCollapsed((c) => !c)}
|
||||||
mobileOpen={mobileOpen}
|
mobileOpen={mobileOpen}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import * as React from 'react';
|
|||||||
import { useMemo, useEffect, useState } from 'react';
|
import { useMemo, useEffect, useState } from 'react';
|
||||||
import type { ICtCreateFormProps } from './ICtCreateFormProps';
|
import type { ICtCreateFormProps } from './ICtCreateFormProps';
|
||||||
import { CtService } from '../services/CtService';
|
import { CtService } from '../services/CtService';
|
||||||
import { useCtForm } from './hooks/useCtForm';
|
import { CTFormData, useCtForm } from './hooks/useCtForm';
|
||||||
|
import { IMatrixRow } from '../../../shared/approval';
|
||||||
import { Breadcrumbs } from './Breadcrumbs/Breadcrumbs';
|
import { Breadcrumbs } from './Breadcrumbs/Breadcrumbs';
|
||||||
import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSelection';
|
import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSelection';
|
||||||
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
|
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
|
||||||
@@ -11,6 +12,7 @@ import { Step4Approval } from './Step4Approval/Step4Approval';
|
|||||||
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns';
|
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns';
|
||||||
import { buildOffersFromSpRecords } from './utils/spOffers';
|
import { buildOffersFromSpRecords } from './utils/spOffers';
|
||||||
import { isUserInApprovalChain } from './utils/approvalAccess';
|
import { isUserInApprovalChain } from './utils/approvalAccess';
|
||||||
|
import { CtAccess, getCtAccess, isCtAuthor } from './utils/ctAccess';
|
||||||
import { noOfferRecordsToSuppliers, withNoOfferSuppliers } from './utils/noOfferSuppliers';
|
import { noOfferRecordsToSuppliers, withNoOfferSuppliers } from './utils/noOfferSuppliers';
|
||||||
import styles from './CtCreateForm.module.scss';
|
import styles from './CtCreateForm.module.scss';
|
||||||
import '@tnsu/ui-kit/styles.css';
|
import '@tnsu/ui-kit/styles.css';
|
||||||
@@ -23,7 +25,6 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
currentStep,
|
currentStep,
|
||||||
formData,
|
formData,
|
||||||
initialCtId,
|
initialCtId,
|
||||||
isNotificationLink,
|
|
||||||
goToStep,
|
goToStep,
|
||||||
goNext,
|
goNext,
|
||||||
goPrev,
|
goPrev,
|
||||||
@@ -34,15 +35,18 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
ensureHighestStep,
|
ensureHighestStep,
|
||||||
} = useCtForm();
|
} = useCtForm();
|
||||||
|
|
||||||
// Цены и КП показываем только участникам согласования: рекомендующему (Инициатор) и согласующим.
|
// Режим формы — по тому, кто открыл и в каком статусе СТ (getCtAccess), а не по ctid/ctId в ссылке.
|
||||||
// Автор/инициатор при создании и редактировании СТ (до отправки на согласование) видит цены всегда.
|
// Считается на каждом рендере из данных СТ — ничего не «прилипает» в сессии: после «Перезапустить
|
||||||
// astel-k (isRestrictedView) — независимое ограничение, складывается с этим: если его уберут,
|
// согласование» или возврата на доработку режим меняется сам. astel-k — цены не видит никогда.
|
||||||
// правило по цепочке согласования останется. Участника ищем по почте (ФИО — запасной вариант).
|
const accessFor = (ct: CTFormData['ct'], approvalMatrix: IMatrixRow[]): CtAccess => getCtAccess({
|
||||||
const userInApprovalChain = isUserInApprovalChain(
|
hasCt: !!ct,
|
||||||
formData.approvalMatrix ?? [],
|
status: ct?.status,
|
||||||
{ email: service.currentUserEmail, displayName: userDisplayName },
|
isAuthor: isCtAuthor(ct?.authorEmail, service.currentUserEmail),
|
||||||
);
|
isInChain: isUserInApprovalChain(approvalMatrix, { email: service.currentUserEmail, displayName: userDisplayName }),
|
||||||
const hidePrices = isRestrictedView || (formData.approvalSent === true && !userInApprovalChain);
|
isRestrictedCompany: isRestrictedView,
|
||||||
|
});
|
||||||
|
const access = accessFor(formData.ct, formData.approvalMatrix ?? []);
|
||||||
|
const hidePrices = !access.canSeePrices;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
service.getCurrentUserProfile()
|
service.getCurrentUserProfile()
|
||||||
@@ -58,8 +62,8 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load existing CT from SP when opened via notification link (?ctid=N)
|
// Открыта существующая СТ (?ctId=N или ?ctid=N — равнозначно): статус и маршрут — всегда свежие из SP.
|
||||||
// Always reloads from SP to get fresh approval statuses
|
// Остальные данные — из SP в режиме просмотра (у проверяющего нет своих правок) или когда сессии нет.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialCtId) return;
|
if (!initialCtId) return;
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -67,6 +71,8 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
service.getApprovalMatrixForRequest(initialCtId),
|
service.getApprovalMatrixForRequest(initialCtId),
|
||||||
service.loadPositionsForCT(initialCtId),
|
service.loadPositionsForCT(initialCtId),
|
||||||
]).then(async ([ct, approvalMatrix, positions]) => {
|
]).then(async ([ct, approvalMatrix, positions]) => {
|
||||||
|
const loadedAccess = accessFor(ct, approvalMatrix);
|
||||||
|
const preferSp = loadedAccess.isReview;
|
||||||
const positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined);
|
const positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined);
|
||||||
const rawOffers = await service.loadFullOffersForPositions(positionSpIds);
|
const rawOffers = await service.loadFullOffersForPositions(positionSpIds);
|
||||||
// Поставщики без КП этой СТ — для списка под таблицей сравнения (согласующий по ссылке).
|
// Поставщики без КП этой СТ — для списка под таблицей сравнения (согласующий по ссылке).
|
||||||
@@ -144,7 +150,7 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
|
|
||||||
// Reconstruct uploadedKpFiles for users without session (other users opening CT via link)
|
// Reconstruct uploadedKpFiles for users without session (other users opening CT via link)
|
||||||
let uploadedKpFiles = formData.uploadedKpFiles;
|
let uploadedKpFiles = formData.uploadedKpFiles;
|
||||||
if (isNotificationLink || uploadedKpFiles.length === 0) {
|
if (preferSp || uploadedKpFiles.length === 0) {
|
||||||
const kpFilesRaw = await service.loadKpFilesForOffers(positionSpIds);
|
const kpFilesRaw = await service.loadKpFilesForOffers(positionSpIds);
|
||||||
if (kpFilesRaw.length > 0) {
|
if (kpFilesRaw.length > 0) {
|
||||||
uploadedKpFiles = kpFilesRaw.map(f => ({
|
uploadedKpFiles = kpFilesRaw.map(f => ({
|
||||||
@@ -160,22 +166,25 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
approvalMatrix,
|
approvalMatrix,
|
||||||
// Preserve session selectedPositions — overwriting changes pos.id (Запросы ID → Позиции СТ ID),
|
// Preserve session selectedPositions — overwriting changes pos.id (Запросы ID → Позиции СТ ID),
|
||||||
// which breaks edits keys built from buildOfferKey(sup.id, pos.id) in Step 3.
|
// which breaks edits keys built from buildOfferKey(sup.id, pos.id) in Step 3.
|
||||||
...(isNotificationLink || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}),
|
...(preferSp || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}),
|
||||||
// Preserve session suppliers and offers — SP data may be incomplete or stale vs. unsaved edits.
|
// 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).
|
// Only use SP-derived data when session has none, or in review mode (always fresh).
|
||||||
suppliers: withNoOfferSuppliers(formData.suppliers.length === 0 ? spSuppliers : formData.suppliers, noOfferFromSp),
|
suppliers: withNoOfferSuppliers(formData.suppliers.length === 0 ? spSuppliers : formData.suppliers, noOfferFromSp),
|
||||||
...(isNotificationLink || formData.offers.length === 0 ? { offers } : {}),
|
...(preferSp || formData.offers.length === 0 ? { offers } : {}),
|
||||||
...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}),
|
...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}),
|
||||||
...(isNotificationLink ? { approvalSent: true } : {}),
|
|
||||||
...(uploadedKpFiles.length > 0 ? { uploadedKpFiles } : {}),
|
...(uploadedKpFiles.length > 0 ? { uploadedKpFiles } : {}),
|
||||||
...(Object.keys(step3DeliveryKm).length > 0 ? { step3DeliveryKm } : {}),
|
...(Object.keys(step3DeliveryKm).length > 0 ? { step3DeliveryKm } : {}),
|
||||||
});
|
});
|
||||||
if (isNotificationLink) {
|
if (loadedAccess.isReview) {
|
||||||
// Teams notification link — always show Step 3 (comparison table + approval chain)
|
// Просмотр (согласующий, автор СТ на согласовании, посторонний) — сразу шаг 3: таблица + маршрут.
|
||||||
goToStep(3);
|
goToStep(3);
|
||||||
ensureHighestStep(4);
|
ensureHighestStep(4);
|
||||||
markStepComplete(3);
|
markStepComplete(3);
|
||||||
markStepComplete(4);
|
markStepComplete(4);
|
||||||
|
} else if (ct.status === 'rework') {
|
||||||
|
// Автор, СТ возвращена на доработку — шаг 4: там видно, кто вернул и что исправить.
|
||||||
|
ensureHighestStep(4);
|
||||||
|
goToStep(4);
|
||||||
} else if (approvalMatrix.length > 0) {
|
} else if (approvalMatrix.length > 0) {
|
||||||
// Regular reload — make Step 4 accessible; only mark complete if CT is fully resolved
|
// Regular reload — make Step 4 accessible; only mark complete if CT is fully resolved
|
||||||
ensureHighestStep(4);
|
ensureHighestStep(4);
|
||||||
@@ -210,7 +219,7 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
<Step2CommercialOffers
|
<Step2CommercialOffers
|
||||||
service={service}
|
service={service}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
isNotificationLink={isNotificationLink}
|
canEdit={access.canEdit}
|
||||||
isReadOnly={formData.ct?.status === 'on_approval' || formData.ct?.status === 'approved' || formData.ct?.status === 'rejected'}
|
isReadOnly={formData.ct?.status === 'on_approval' || formData.ct?.status === 'approved' || formData.ct?.status === 'rejected'}
|
||||||
isRestrictedView={hidePrices}
|
isRestrictedView={hidePrices}
|
||||||
onSave={updateFormData}
|
onSave={updateFormData}
|
||||||
@@ -224,7 +233,8 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
<Step3ComparisonTable
|
<Step3ComparisonTable
|
||||||
service={service}
|
service={service}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
isNotificationLink={isNotificationLink}
|
canEdit={access.canEdit}
|
||||||
|
isReview={access.isReview}
|
||||||
isRestrictedView={hidePrices}
|
isRestrictedView={hidePrices}
|
||||||
onSave={updateFormData}
|
onSave={updateFormData}
|
||||||
onNext={goNext}
|
onNext={goNext}
|
||||||
|
|||||||
+6
-5
@@ -18,7 +18,8 @@ import styles from './Step2CommercialOffers.module.scss';
|
|||||||
interface Props {
|
interface Props {
|
||||||
service: CtService;
|
service: CtService;
|
||||||
formData: CTFormData;
|
formData: CTFormData;
|
||||||
isNotificationLink?: boolean;
|
// Правка СТ (getCtAccess): без неё — только просмотр, загрузка КП и сохранение недоступны.
|
||||||
|
canEdit?: boolean;
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
isRestrictedView?: boolean;
|
isRestrictedView?: boolean;
|
||||||
onSave: (partial: Partial<CTFormData>) => void;
|
onSave: (partial: Partial<CTFormData>) => void;
|
||||||
@@ -32,7 +33,7 @@ interface Props {
|
|||||||
export const Step2CommercialOffers: React.FC<Props> = ({
|
export const Step2CommercialOffers: React.FC<Props> = ({
|
||||||
service,
|
service,
|
||||||
formData,
|
formData,
|
||||||
isNotificationLink,
|
canEdit = true,
|
||||||
onSave,
|
onSave,
|
||||||
onNext,
|
onNext,
|
||||||
onPrev,
|
onPrev,
|
||||||
@@ -176,7 +177,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
onFileRemove={handleQueueFileRemove}
|
onFileRemove={handleQueueFileRemove}
|
||||||
currencyOptions={currencyOptions}
|
currencyOptions={currencyOptions}
|
||||||
onCurrencyChange={kp.handleCurrencyChange}
|
onCurrencyChange={kp.handleCurrencyChange}
|
||||||
disabled={isNotificationLink || isReadOnly}
|
disabled={!canEdit || isReadOnly}
|
||||||
/>
|
/>
|
||||||
{kp.duplicateWarning && <Alert variant="warn">{kp.duplicateWarning}</Alert>}
|
{kp.duplicateWarning && <Alert variant="warn">{kp.duplicateWarning}</Alert>}
|
||||||
{kp.isProcessing && (
|
{kp.isProcessing && (
|
||||||
@@ -200,7 +201,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)}
|
getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)}
|
||||||
onCurrencyChange={handleUploadedCurrencyChange}
|
onCurrencyChange={handleUploadedCurrencyChange}
|
||||||
onRemove={spId => { handleRemoveKpFile(spId).catch(() => undefined); }}
|
onRemove={spId => { handleRemoveKpFile(spId).catch(() => undefined); }}
|
||||||
disabled={isNotificationLink || isReadOnly}
|
disabled={!canEdit || isReadOnly}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -210,7 +211,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
<div className={styles.footerRight}>
|
<div className={styles.footerRight}>
|
||||||
{saveError && <Alert variant="err">{saveError}</Alert>}
|
{saveError && <Alert variant="err">{saveError}</Alert>}
|
||||||
{!isNotificationLink && !isReadOnly && (
|
{canEdit && !isReadOnly && (
|
||||||
<Button variant="accent" onClick={handleSave} disabled={!canProceed}>
|
<Button variant="accent" onClick={handleSave} disabled={!canProceed}>
|
||||||
{isSaving ? <Spinner size="small" /> : 'Сохранить и продолжить'}
|
{isSaving ? <Spinner size="small" /> : 'Сохранить и продолжить'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+7
-7
@@ -9,8 +9,8 @@ interface Props {
|
|||||||
service: CtService;
|
service: CtService;
|
||||||
formData: CTFormData;
|
formData: CTFormData;
|
||||||
onSave: (partial: Partial<CTFormData>) => void;
|
onSave: (partial: Partial<CTFormData>) => void;
|
||||||
isNotificationLink: boolean;
|
// Режим просмотра (getCtAccess): согласующий, автор СТ на согласовании, посторонний.
|
||||||
isReadOnly: boolean;
|
isReview: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RU_STATUS_TO_CT_STATUS: Record<string, CTStatus> = {
|
const RU_STATUS_TO_CT_STATUS: Record<string, CTStatus> = {
|
||||||
@@ -20,9 +20,9 @@ const RU_STATUS_TO_CT_STATUS: Record<string, CTStatus> = {
|
|||||||
'На доработке': 'rework',
|
'На доработке': 'rework',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Инлайн-цепочка согласования Шага 3 — для согласующего, открывшего СТ по Teams-ссылке (?ctid=),
|
// Инлайн-цепочка согласования Шага 3 в режиме просмотра: read-only маршрут + DecisionPanel своей строки
|
||||||
// read-only маршрут + DecisionPanel своей строки. Правка состава/отправка — это Шаг 4, здесь их нет.
|
// (панель сама проверяет, чья сейчас очередь). Правка состава/отправка — это Шаг 4, здесь их нет.
|
||||||
export const InlineApprovalChain: React.FC<Props> = ({ service, formData, onSave, isNotificationLink, isReadOnly }) => {
|
export const InlineApprovalChain: React.FC<Props> = ({ service, formData, onSave, isReview }) => {
|
||||||
const { ct } = formData;
|
const { ct } = formData;
|
||||||
const ctId = ct?.id;
|
const ctId = ct?.id;
|
||||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
@@ -31,12 +31,12 @@ export const InlineApprovalChain: React.FC<Props> = ({ service, formData, onSave
|
|||||||
() => (): Promise<IMatrixRow[]> => (ctId ? service.getApprovalMatrixForRequest(ctId) : Promise.resolve([])),
|
() => (): Promise<IMatrixRow[]> => (ctId ? service.getApprovalMatrixForRequest(ctId) : Promise.resolve([])),
|
||||||
[service, ctId]
|
[service, ctId]
|
||||||
);
|
);
|
||||||
const editor = useMatrixEditor(loadMatrix, `${ctId ?? ''}-${decisionSeq}`, false, !!ctId && isNotificationLink && isReadOnly, (loginName) =>
|
const editor = useMatrixEditor(loadMatrix, `${ctId ?? ''}-${decisionSeq}`, false, !!ctId && isReview, (loginName) =>
|
||||||
service.getJobTitle(loginName)
|
service.getJobTitle(loginName)
|
||||||
);
|
);
|
||||||
const approverService = useMemo(() => (ctId ? service.getApproverService(ctId) : undefined), [service, ctId]);
|
const approverService = useMemo(() => (ctId ? service.getApproverService(ctId) : undefined), [service, ctId]);
|
||||||
|
|
||||||
if (!isNotificationLink || !isReadOnly || !ctId || editor.loading || editor.rows.length === 0) {
|
if (!isReview || !ctId || editor.loading || editor.rows.length === 0) {
|
||||||
return null; // цепочка ещё грузится из SP или её нет — блок не показываем
|
return null; // цепочка ещё грузится из SP или её нет — блок не показываем
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-2
@@ -341,7 +341,14 @@
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deliveryQtyCell {
|
// Минимальные ширины половинок ячейки «рейсы | км» строки логистики: рейсы — 3 цифры,
|
||||||
|
// км — 4 цифры плюс подпись «км». Без нижней границы при 3+ поставщиках таблица ужимала
|
||||||
|
// колонки, и поле км схлопывалось до невидимого — теперь вместо этого таблица шире (есть скролл).
|
||||||
|
$delivery-trips-min-width: 40px;
|
||||||
|
$delivery-km-min-width: 76px;
|
||||||
|
|
||||||
|
// Двойной класс — иначе min-width: 90px из .dataCell (объявлен ниже, та же специфичность) перебивает.
|
||||||
|
.dataCell.deliveryQtyCell {
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,17 +362,38 @@
|
|||||||
.deliveryQtyBox,
|
.deliveryQtyBox,
|
||||||
.deliveryKmBox {
|
.deliveryKmBox {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
|
// Обёртка инпута должна занимать всё место половинки: без flex-grow она ужималась
|
||||||
|
// по содержимому, и инпут получал лишь остаток рядом с подписью «км».
|
||||||
|
.offerInput {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deliveryQtyBox {
|
||||||
|
min-width: $delivery-trips-min-width;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deliveryKmBox {
|
.deliveryKmBox {
|
||||||
|
min-width: $delivery-km-min-width;
|
||||||
border-left: 1px solid $border;
|
border-left: 1px solid $border;
|
||||||
padding-left: 2px;
|
padding-left: 2px;
|
||||||
|
|
||||||
|
// Стрелки type="number" в Chrome резервируют место даже невидимыми и обрезают число
|
||||||
|
// («200» → «20(»). Км вводят с клавиатуры, стрелки не нужны.
|
||||||
input {
|
input {
|
||||||
color: $muted;
|
color: $muted;
|
||||||
|
appearance: textfield;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
|
||||||
|
&::-webkit-inner-spin-button,
|
||||||
|
&::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-5
@@ -22,7 +22,9 @@ import { VendorPickerDialog } from './VendorPickerDialog';
|
|||||||
interface Props {
|
interface Props {
|
||||||
service: CtService;
|
service: CtService;
|
||||||
formData: CTFormData;
|
formData: CTFormData;
|
||||||
isNotificationLink: boolean;
|
// Режим формы (getCtAccess): canEdit — правка таблицы; isReview — просмотр с маршрутом и кнопками решения.
|
||||||
|
canEdit: boolean;
|
||||||
|
isReview: boolean;
|
||||||
isRestrictedView?: boolean;
|
isRestrictedView?: boolean;
|
||||||
onSave: (partial: Partial<CTFormData>) => void;
|
onSave: (partial: Partial<CTFormData>) => void;
|
||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
@@ -33,7 +35,8 @@ interface Props {
|
|||||||
export const Step3ComparisonTable: React.FC<Props> = ({
|
export const Step3ComparisonTable: React.FC<Props> = ({
|
||||||
service,
|
service,
|
||||||
formData,
|
formData,
|
||||||
isNotificationLink,
|
canEdit,
|
||||||
|
isReview,
|
||||||
isRestrictedView = false,
|
isRestrictedView = false,
|
||||||
onSave,
|
onSave,
|
||||||
onNext,
|
onNext,
|
||||||
@@ -45,7 +48,7 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
// Ядро матрицы (стейт/рефы/эффекты/мемо/хендлеры/handleSave) вынесено в хук; результат передаём
|
// Ядро матрицы (стейт/рефы/эффекты/мемо/хендлеры/handleSave) вынесено в хук; результат передаём
|
||||||
// как единый проп `matrix` в под-компоненты рендера (ComparisonMatrix/CriteriaTable/AddExtraRow),
|
// как единый проп `matrix` в под-компоненты рендера (ComparisonMatrix/CriteriaTable/AddExtraRow),
|
||||||
// здесь дополнительно достаём то, что нужно контейнеру напрямую (тулбары, AI-payload, футер).
|
// здесь дополнительно достаём то, что нужно контейнеру напрямую (тулбары, AI-payload, футер).
|
||||||
const matrix = useStep3Matrix({ service, formData, onSave, onNext, onStepComplete });
|
const matrix = useStep3Matrix({ service, formData, isReadOnly: !canEdit, onSave, onNext, onStepComplete });
|
||||||
const {
|
const {
|
||||||
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
|
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
|
||||||
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave,
|
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave,
|
||||||
@@ -194,8 +197,7 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
service={service}
|
service={service}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
isNotificationLink={isNotificationLink}
|
isReview={isReview}
|
||||||
isReadOnly={isReadOnly}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
|
|||||||
+24
-3
@@ -15,6 +15,8 @@ let extraIdCounter = 0;
|
|||||||
export interface UseStep3MatrixArgs {
|
export interface UseStep3MatrixArgs {
|
||||||
service: CtService;
|
service: CtService;
|
||||||
formData: CTFormData;
|
formData: CTFormData;
|
||||||
|
// Таблица только на просмотр — режим формы (getCtAccess), а не признак из сессии.
|
||||||
|
isReadOnly: boolean;
|
||||||
onSave: (partial: Partial<CTFormData>) => void;
|
onSave: (partial: Partial<CTFormData>) => void;
|
||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
onStepComplete: (step: FormStep) => void;
|
onStepComplete: (step: FormStep) => void;
|
||||||
@@ -24,11 +26,9 @@ export interface UseStep3MatrixArgs {
|
|||||||
// ВЕРБАТИМ из Step3ComparisonTable (§стайл-гайд: логика вне вёрстки). Клубок рефов сохраняет
|
// ВЕРБАТИМ из Step3ComparisonTable (§стайл-гайд: логика вне вёрстки). Клубок рефов сохраняет
|
||||||
// несохранённые правки при переходах шагов и F5 (unmount/beforeunload-persist).
|
// несохранённые правки при переходах шагов и F5 (unmount/beforeunload-persist).
|
||||||
export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||||
const { service, formData, onSave, onNext, onStepComplete } = args;
|
const { service, formData, isReadOnly, onSave, onNext, onStepComplete } = args;
|
||||||
const { selectedPositions, suppliers, offers, currencyRates, ct } = formData;
|
const { selectedPositions, suppliers, offers, currencyRates, ct } = formData;
|
||||||
|
|
||||||
const isReadOnly = formData.approvalSent;
|
|
||||||
|
|
||||||
const [extraSuppliers, setExtraSuppliers] = useState<ExtraSupplier[]>(() => {
|
const [extraSuppliers, setExtraSuppliers] = useState<ExtraSupplier[]>(() => {
|
||||||
const persisted = formData.step3ExtraSuppliers ?? [];
|
const persisted = formData.step3ExtraSuppliers ?? [];
|
||||||
persisted.forEach(es => {
|
persisted.forEach(es => {
|
||||||
@@ -800,6 +800,27 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await service.saveOffers(offersToSave, currencyRates);
|
await service.saveOffers(offersToSave, currencyRates);
|
||||||
|
// «Не получены КП» — к этой СТ, до согласования: шаг 2 могли пройти крошками, минуя его «Сохранить».
|
||||||
|
await service.syncNoOfferSuppliers(ct.id, getNoOfferSuppliers(suppliers));
|
||||||
|
// «Дополнительная информация» — в SP: иначе согласующий и PDF видят только то, что распознал ИИ.
|
||||||
|
await service.replaceCriteria(ct.id, buildCriteriaRecords(
|
||||||
|
suppliersWithOffer,
|
||||||
|
criteriaMap,
|
||||||
|
customCriteria,
|
||||||
|
column => column.spId ?? extraSupSpIds[column.id],
|
||||||
|
));
|
||||||
|
|
||||||
|
// Поставщик-победитель → winSupplier в шапке СТ (лукап на «Поставщики»). Пишем сразу по
|
||||||
|
// «Сохранить и продолжить», а не на Шаге 4 — там меняется только статус, а поставщик мог
|
||||||
|
// прийти как обычной колонкой (spId уже есть), так и добавленным на этом шаге (spId
|
||||||
|
// появляется только что, в extraSupSpIds).
|
||||||
|
if (selectedSupplierId) {
|
||||||
|
const winSupplierSpId = suppliers.find(s => s.id === selectedSupplierId)?.spId
|
||||||
|
?? extraSupSpIds[selectedSupplierId];
|
||||||
|
if (winSupplierSpId !== undefined) {
|
||||||
|
await service.saveWinSupplier(ct.id, winSupplierSpId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Persist offers with React IDs (sup.id / pos.id) so they survive F5 / step navigation —
|
// 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.
|
// render keys use React IDs, so SP IDs in session cause empty cells on remount.
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export interface CTFormData {
|
|||||||
step3ExtraPositions: Array<{ id: string; materialName: string; unit: string; spId?: number; isDelivery?: boolean }>;
|
step3ExtraPositions: Array<{ id: string; materialName: string; unit: string; spId?: number; isDelivery?: boolean }>;
|
||||||
step3DeliveryKm?: Record<string, number>; // key = `${posId}::${supId}`
|
step3DeliveryKm?: Record<string, number>; // key = `${posId}::${supId}`
|
||||||
uploadedKpFiles: Array<{ name: string; spId: number; supplierName?: string }>;
|
uploadedKpFiles: Array<{ name: string; spId: number; supplierName?: string }>;
|
||||||
approvalSent: boolean;
|
|
||||||
step3PositionQtyOverrides?: Record<string, number>;
|
step3PositionQtyOverrides?: Record<string, number>;
|
||||||
step3CriteriaEdits?: Record<string, { paymentType: string; deliveryType: string; deadline: string; sertificates: string }>;
|
step3CriteriaEdits?: Record<string, { paymentType: string; deliveryType: string; deadline: string; sertificates: string }>;
|
||||||
step3CustomCriteria?: Array<{ id: string; label: string; values: Record<string, string> }>;
|
step3CustomCriteria?: Array<{ id: string; label: string; values: Record<string, string> }>;
|
||||||
@@ -40,7 +39,6 @@ const INITIAL_FORM_DATA: CTFormData = {
|
|||||||
step3ExtraSuppliers: [],
|
step3ExtraSuppliers: [],
|
||||||
step3ExtraPositions: [],
|
step3ExtraPositions: [],
|
||||||
uploadedKpFiles: [],
|
uploadedKpFiles: [],
|
||||||
approvalSent: false,
|
|
||||||
step3PositionQtyOverrides: {},
|
step3PositionQtyOverrides: {},
|
||||||
step3CriteriaEdits: {},
|
step3CriteriaEdits: {},
|
||||||
step3CustomCriteria: [],
|
step3CustomCriteria: [],
|
||||||
@@ -57,7 +55,8 @@ interface PersistedFormState {
|
|||||||
|
|
||||||
function getUrlCtId(): string | undefined {
|
function getUrlCtId(): string | undefined {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
// Accept 'ctId' (our format) and 'ctid' (Power Automate notification link)
|
// ctId (форма, реестр) и ctid (ссылки потока, реестр) равнозначны — это только id СТ. Режим формы
|
||||||
|
// (правка / просмотр) определяет getCtAccess по автору и статусу, а не регистр параметра.
|
||||||
return params.get('ctId') ?? params.get('ctid') ?? undefined;
|
return params.get('ctId') ?? params.get('ctid') ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +85,6 @@ export interface UseCtFormResult {
|
|||||||
currentStep: FormStep;
|
currentStep: FormStep;
|
||||||
formData: CTFormData;
|
formData: CTFormData;
|
||||||
initialCtId: string | undefined;
|
initialCtId: string | undefined;
|
||||||
isNotificationLink: boolean;
|
|
||||||
goToStep: (step: FormStep) => void;
|
goToStep: (step: FormStep) => void;
|
||||||
goNext: () => void;
|
goNext: () => void;
|
||||||
goPrev: () => void;
|
goPrev: () => void;
|
||||||
@@ -99,7 +97,6 @@ export interface UseCtFormResult {
|
|||||||
|
|
||||||
export function useCtForm(): UseCtFormResult {
|
export function useCtForm(): UseCtFormResult {
|
||||||
const [initialCtId] = useState<string | undefined>(getUrlCtId);
|
const [initialCtId] = useState<string | undefined>(getUrlCtId);
|
||||||
const isNotificationLink = new URLSearchParams(window.location.search).has('ctid');
|
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState<FormStep>(() => {
|
const [currentStep, setCurrentStep] = useState<FormStep>(() => {
|
||||||
const saved = initialCtId ? readSession(initialCtId) : null;
|
const saved = initialCtId ? readSession(initialCtId) : null;
|
||||||
@@ -167,7 +164,6 @@ export function useCtForm(): UseCtFormResult {
|
|||||||
currentStep,
|
currentStep,
|
||||||
formData,
|
formData,
|
||||||
initialCtId,
|
initialCtId,
|
||||||
isNotificationLink,
|
|
||||||
goToStep,
|
goToStep,
|
||||||
goNext,
|
goNext,
|
||||||
goPrev,
|
goPrev,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { getCtAccess, isCtAuthor } from './ctAccess';
|
||||||
|
|
||||||
|
const base = { hasCt: true, isAuthor: false, isInChain: false, isRestrictedCompany: false };
|
||||||
|
|
||||||
|
describe('getCtAccess — кто открыл СТ и в каком она статусе (а не регистр ctid/ctId в ссылке)', () => {
|
||||||
|
it('новая СТ — правка', () => {
|
||||||
|
expect(getCtAccess({ ...base, hasCt: false })).toEqual({ canEdit: true, canSeePrices: true, isReview: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('автор: черновик — правка', () => {
|
||||||
|
expect(getCtAccess({ ...base, isAuthor: true, status: 'formed' }).canEdit).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// СТ №… «На доработке» по ссылке из письма (ctid) открывалась автору только на просмотр — править было нельзя.
|
||||||
|
it('автор: «На доработке» — правка, даже если открыл по ссылке-уведомлению', () => {
|
||||||
|
expect(getCtAccess({ ...base, isAuthor: true, status: 'rework' })).toEqual({ canEdit: true, canSeePrices: true, isReview: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('автор: на согласовании / согласовано — просмотр с ценами', () => {
|
||||||
|
expect(getCtAccess({ ...base, isAuthor: true, status: 'on_approval' })).toEqual({ canEdit: false, canSeePrices: true, isReview: true });
|
||||||
|
expect(getCtAccess({ ...base, isAuthor: true, status: 'approved' }).canEdit).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// «Отклонить» оставлено с перезапуском: править — после «Перезапустить согласование» на шаге 4.
|
||||||
|
it('автор: отклонено — просмотр до перезапуска', () => {
|
||||||
|
expect(getCtAccess({ ...base, isAuthor: true, status: 'rejected' }).canEdit).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('согласующий из цепочки — просмотр с ценами', () => {
|
||||||
|
expect(getCtAccess({ ...base, isInChain: true, status: 'on_approval' })).toEqual({ canEdit: false, canSeePrices: true, isReview: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('посторонний — просмотр без цен, даже черновик', () => {
|
||||||
|
expect(getCtAccess({ ...base, status: 'formed' })).toEqual({ canEdit: false, canSeePrices: false, isReview: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('компания с ограничением (astel-k) — без цен даже автору', () => {
|
||||||
|
expect(getCtAccess({ ...base, isAuthor: true, status: 'formed', isRestrictedCompany: true }).canSeePrices).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isCtAuthor', () => {
|
||||||
|
it('сверяет почту без учёта регистра', () => {
|
||||||
|
expect(isCtAuthor('Ivanov@tnsu.kz', 'ivanov@tnsu.kz')).toBe(true);
|
||||||
|
expect(isCtAuthor('ivanov@tnsu.kz', 'petrov@tnsu.kz')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// У старых данных автор может быть не загружен — не блокируем никого на своих черновиках, как раньше.
|
||||||
|
it('автор неизвестен — считаем автором', () => {
|
||||||
|
expect(isCtAuthor(undefined, 'ivanov@tnsu.kz')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { CTStatus } from '../types/domain';
|
||||||
|
|
||||||
|
// Режим формы СТ определяется тем, КТО открыл и В КАКОМ статусе СТ, — а не регистром параметра в ссылке.
|
||||||
|
// Раньше `?ctid=` (ссылки потока, реестр для не-черновиков) включал «режим согласующего» любому, кто открыл,
|
||||||
|
// а `?ctId=` — правку; режим ещё и «прилипал» в сессии. Автор СТ «На доработке» по ссылке из письма
|
||||||
|
// получал только просмотр. Теперь ctid/ctId равнозначны — они лишь говорят, какую СТ открыть.
|
||||||
|
|
||||||
|
export interface CtAccessInput {
|
||||||
|
hasCt: boolean;
|
||||||
|
status?: CTStatus;
|
||||||
|
isAuthor: boolean;
|
||||||
|
isInChain: boolean; // участник цепочки согласования (см. isUserInApprovalChain)
|
||||||
|
isRestrictedCompany: boolean; // ограниченная компания (astel-k) — цены не видит никогда
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CtAccess {
|
||||||
|
canEdit: boolean; // правка шагов; статусные блокировки шагов 1–2 остаются поверх
|
||||||
|
canSeePrices: boolean; // цены и ссылки на КП
|
||||||
|
isReview: boolean; // просмотр: на шаге 3, данные свежие из SP, маршрут и кнопки решения
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статусы, в которых автор правит СТ. «Отклонено» — нет: правка после «Перезапустить согласование».
|
||||||
|
const AUTHOR_EDIT_STATUSES: ReadonlySet<CTStatus> = new Set<CTStatus>([
|
||||||
|
'draft', 'kp_loading', 'kp_loaded', 'formed', 'rework',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function getCtAccess(input: CtAccessInput): CtAccess {
|
||||||
|
const canEdit = !input.hasCt || (input.isAuthor && AUTHOR_EDIT_STATUSES.has(input.status ?? 'formed'));
|
||||||
|
return {
|
||||||
|
canEdit,
|
||||||
|
canSeePrices: !input.isRestrictedCompany && (canEdit || input.isAuthor || input.isInChain),
|
||||||
|
isReview: input.hasCt && !canEdit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автор СТ — по почте без учёта регистра. Автор неизвестен (старые данные, сессия до появления
|
||||||
|
// authorEmail) — считаем автором: раньше любой мог править СТ, не блокируем никого на своих черновиках.
|
||||||
|
export function isCtAuthor(authorEmail: string | undefined, userEmail: string): boolean {
|
||||||
|
if (!authorEmail) return true;
|
||||||
|
return authorEmail.trim().toLowerCase() === userEmail.trim().toLowerCase();
|
||||||
|
}
|
||||||
@@ -1434,6 +1434,25 @@ export class CtService {
|
|||||||
throw new Error(`Ошибка сохранения комментария ИИ: ${res.status}`);
|
throw new Error(`Ошибка сохранения комментария ИИ: ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Запись поставщика-победителя в шапку СТ (лукап winSupplier → «Поставщики»), Шаг 3.
|
||||||
|
async saveWinSupplier(ctId: string, supplierSpId: number): Promise<void> {
|
||||||
|
const res = await this.http.fetch(
|
||||||
|
this._liById(LISTS.CT, ctId),
|
||||||
|
SPHttpClient.configurations.v1,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json;odata=nometadata",
|
||||||
|
"IF-MATCH": "*",
|
||||||
|
"X-HTTP-Method": "MERGE",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ winSupplierId: supplierSpId }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error(`Ошибка сохранения поставщика-победителя: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Сформировать отчёт СТ и вернуть его как blob (без скачивания на диск).
|
// Сформировать отчёт СТ и вернуть его как blob (без скачивания на диск).
|
||||||
// Нужен для подписания через NCALayer и сборки карточки SIGEX.
|
// Нужен для подписания через NCALayer и сборки карточки SIGEX.
|
||||||
async getCtReportBlob(ctId: string, format: string = "xlsx"): Promise<Blob> {
|
async getCtReportBlob(ctId: string, format: string = "xlsx"): Promise<Blob> {
|
||||||
|
|||||||
@@ -256,8 +256,14 @@ export class KpsCtFormService {
|
|||||||
// Мягкое удаление (то же, что кнопка «Удалить» в UI). Работает на «защищённых» списках,
|
// Мягкое удаление (то же, что кнопка «Удалить» в UI). Работает на «защищённых» списках,
|
||||||
// где DELETE/MERGE падают с E_FAIL (§6). Для «обновления» такой записи: recycle() + повторный POST.
|
// где DELETE/MERGE падают с E_FAIL (§6). Для «обновления» такой записи: recycle() + повторный POST.
|
||||||
public async recycleListItem(listName: string, id: number): Promise<void> {
|
public async recycleListItem(listName: string, id: number): Promise<void> {
|
||||||
|
// Content-Type обязателен даже без тела: у POST с пустым odata-version SPFx иначе сам бросает
|
||||||
|
// «jsonRequest is enabled, which requires the OData-Version header…».
|
||||||
const options: ISPHttpClientOptions = {
|
const options: ISPHttpClientOptions = {
|
||||||
headers: { Accept: 'application/json;odata=nometadata', 'odata-version': '' }
|
headers: {
|
||||||
|
Accept: 'application/json;odata=nometadata',
|
||||||
|
'Content-Type': 'application/json;odata=nometadata',
|
||||||
|
'odata-version': ''
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const res = await this.spHttpClient.post(
|
const res = await this.spHttpClient.post(
|
||||||
`${this._liById(listName, id)}/recycle()`,
|
`${this._liById(listName, id)}/recycle()`,
|
||||||
|
|||||||
@@ -108,8 +108,14 @@ export class ApprovalListService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async recycleListItem(id: number): Promise<void> {
|
private async recycleListItem(id: number): Promise<void> {
|
||||||
|
// Content-Type обязателен даже без тела: у POST с пустым odata-version SPFx иначе сам бросает
|
||||||
|
// «jsonRequest is enabled, which requires the OData-Version header…» — повторная отправка падала.
|
||||||
const options: ISPHttpClientOptions = {
|
const options: ISPHttpClientOptions = {
|
||||||
headers: { Accept: 'application/json;odata=nometadata', 'odata-version': '' }
|
headers: {
|
||||||
|
Accept: 'application/json;odata=nometadata',
|
||||||
|
'Content-Type': 'application/json;odata=nometadata',
|
||||||
|
'odata-version': ''
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const res = await this.spHttpClient.post(
|
const res = await this.spHttpClient.post(
|
||||||
`${this._liById(this.listTitle, id)}/recycle()`,
|
`${this._liById(this.listTitle, id)}/recycle()`,
|
||||||
|
|||||||
Reference in New Issue
Block a user