Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e40fc402d9 | |||
| c1e8792174 | |||
| 5192311fff | |||
| 4079bddc71 | |||
| 045a4f655b | |||
| 2f00a2dcc9 |
@@ -20,7 +20,7 @@ import {
|
||||
Alert
|
||||
} from '@tnsu/ui-kit';
|
||||
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 { DashboardRow } from './registry/documentRegistry';
|
||||
|
||||
@@ -133,7 +133,7 @@ export const Dashboard: React.FC<IDashboardProps> = ({ context, onNavigateForm }
|
||||
items={sidebarItems}
|
||||
activeId={activeType}
|
||||
onChange={onSidebarChange}
|
||||
brand="Договора"
|
||||
brand={SIDEBAR_BRAND}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((c) => !c)}
|
||||
mobileOpen={mobileOpen}
|
||||
|
||||
@@ -52,8 +52,8 @@ export function createCtDocumentConfig(): DocumentTypeConfig {
|
||||
})
|
||||
},
|
||||
createNewLabel: 'Создать новую форму СТ',
|
||||
// Форма CT встроена в портал (маршрут). Открытие существующей: ctId (черновик) / ctid (иначе)
|
||||
// — как читает сама форма (см. forms/ct/.../hooks/useCtForm.ts).
|
||||
// Форма CT встроена в портал (маршрут). ctId / ctid форма читает одинаково — режим (правка/просмотр)
|
||||
// она считает сама по автору и статусу (forms/ct/.../utils/ctAccess.ts); регистр — для старых ссылок.
|
||||
inPortal: true,
|
||||
buildInPortalParams: (row) => ({ [row.status === 'Черновик' ? 'ctId' : 'ctid']: row.id }),
|
||||
mapItem: (raw): DashboardRow => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { ISidebarItem, IconName } from '@tnsu/ui-kit';
|
||||
import { DOCUMENT_TYPES } from './config';
|
||||
|
||||
// Заголовок сайдбара — одинаковый на всех экранах портала.
|
||||
export const SIDEBAR_BRAND = 'Снабжение';
|
||||
|
||||
// Общий раздел «База контрагентов» (отдельный маршрут ?form=contragents).
|
||||
export const CONTRAGENTS_ID = 'contragents';
|
||||
export const CONTRAGENTS_TITLE = 'База контрагентов';
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import {
|
||||
buildSidebarItems,
|
||||
CONTRAGENTS_ID,
|
||||
SIDEBAR_BRAND,
|
||||
} from "../../../components/sidebarNav";
|
||||
import { ContragentsService } from "../services/ContragentsService";
|
||||
import { SuppliersService } from "../services/SuppliersService";
|
||||
@@ -164,7 +165,7 @@ export const ContragentsRegistry: React.FC<IContragentsRegistryProps> = ({
|
||||
items={buildSidebarItems()}
|
||||
activeId={CONTRAGENTS_ID}
|
||||
onChange={onSidebarChange}
|
||||
brand="Договора"
|
||||
brand={SIDEBAR_BRAND}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((c) => !c)}
|
||||
mobileOpen={mobileOpen}
|
||||
|
||||
@@ -2,7 +2,8 @@ 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 { CTFormData, useCtForm } from './hooks/useCtForm';
|
||||
import { IMatrixRow } from '../../../shared/approval';
|
||||
import { Breadcrumbs } from './Breadcrumbs/Breadcrumbs';
|
||||
import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSelection';
|
||||
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
|
||||
@@ -11,6 +12,7 @@ import { Step4Approval } from './Step4Approval/Step4Approval';
|
||||
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns';
|
||||
import { buildOffersFromSpRecords } from './utils/spOffers';
|
||||
import { isUserInApprovalChain } from './utils/approvalAccess';
|
||||
import { CtAccess, getCtAccess, isCtAuthor } from './utils/ctAccess';
|
||||
import { noOfferRecordsToSuppliers, withNoOfferSuppliers } from './utils/noOfferSuppliers';
|
||||
import styles from './CtCreateForm.module.scss';
|
||||
import '@tnsu/ui-kit/styles.css';
|
||||
@@ -23,7 +25,6 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
currentStep,
|
||||
formData,
|
||||
initialCtId,
|
||||
isNotificationLink,
|
||||
goToStep,
|
||||
goNext,
|
||||
goPrev,
|
||||
@@ -34,15 +35,18 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
ensureHighestStep,
|
||||
} = useCtForm();
|
||||
|
||||
// Цены и КП показываем только участникам согласования: рекомендующему (Инициатор) и согласующим.
|
||||
// Автор/инициатор при создании и редактировании СТ (до отправки на согласование) видит цены всегда.
|
||||
// astel-k (isRestrictedView) — независимое ограничение, складывается с этим: если его уберут,
|
||||
// правило по цепочке согласования останется. Участника ищем по почте (ФИО — запасной вариант).
|
||||
const userInApprovalChain = isUserInApprovalChain(
|
||||
formData.approvalMatrix ?? [],
|
||||
{ email: service.currentUserEmail, displayName: userDisplayName },
|
||||
);
|
||||
const hidePrices = isRestrictedView || (formData.approvalSent === true && !userInApprovalChain);
|
||||
// Режим формы — по тому, кто открыл и в каком статусе СТ (getCtAccess), а не по ctid/ctId в ссылке.
|
||||
// Считается на каждом рендере из данных СТ — ничего не «прилипает» в сессии: после «Перезапустить
|
||||
// согласование» или возврата на доработку режим меняется сам. astel-k — цены не видит никогда.
|
||||
const accessFor = (ct: CTFormData['ct'], approvalMatrix: IMatrixRow[]): CtAccess => getCtAccess({
|
||||
hasCt: !!ct,
|
||||
status: ct?.status,
|
||||
isAuthor: isCtAuthor(ct?.authorEmail, service.currentUserEmail),
|
||||
isInChain: isUserInApprovalChain(approvalMatrix, { email: service.currentUserEmail, displayName: userDisplayName }),
|
||||
isRestrictedCompany: isRestrictedView,
|
||||
});
|
||||
const access = accessFor(formData.ct, formData.approvalMatrix ?? []);
|
||||
const hidePrices = !access.canSeePrices;
|
||||
|
||||
useEffect(() => {
|
||||
service.getCurrentUserProfile()
|
||||
@@ -58,8 +62,8 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
// 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
|
||||
// Открыта существующая СТ (?ctId=N или ?ctid=N — равнозначно): статус и маршрут — всегда свежие из SP.
|
||||
// Остальные данные — из SP в режиме просмотра (у проверяющего нет своих правок) или когда сессии нет.
|
||||
useEffect(() => {
|
||||
if (!initialCtId) return;
|
||||
Promise.all([
|
||||
@@ -67,6 +71,8 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
service.getApprovalMatrixForRequest(initialCtId),
|
||||
service.loadPositionsForCT(initialCtId),
|
||||
]).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 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)
|
||||
let uploadedKpFiles = formData.uploadedKpFiles;
|
||||
if (isNotificationLink || uploadedKpFiles.length === 0) {
|
||||
if (preferSp || uploadedKpFiles.length === 0) {
|
||||
const kpFilesRaw = await service.loadKpFilesForOffers(positionSpIds);
|
||||
if (kpFilesRaw.length > 0) {
|
||||
uploadedKpFiles = kpFilesRaw.map(f => ({
|
||||
@@ -160,22 +166,25 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
approvalMatrix,
|
||||
// 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 } : {}),
|
||||
...(preferSp || 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).
|
||||
// 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),
|
||||
...(isNotificationLink || formData.offers.length === 0 ? { offers } : {}),
|
||||
...(preferSp || 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)
|
||||
if (loadedAccess.isReview) {
|
||||
// Просмотр (согласующий, автор СТ на согласовании, посторонний) — сразу шаг 3: таблица + маршрут.
|
||||
goToStep(3);
|
||||
ensureHighestStep(4);
|
||||
markStepComplete(3);
|
||||
markStepComplete(4);
|
||||
} else if (ct.status === 'rework') {
|
||||
// Автор, СТ возвращена на доработку — шаг 4: там видно, кто вернул и что исправить.
|
||||
ensureHighestStep(4);
|
||||
goToStep(4);
|
||||
} else if (approvalMatrix.length > 0) {
|
||||
// Regular reload — make Step 4 accessible; only mark complete if CT is fully resolved
|
||||
ensureHighestStep(4);
|
||||
@@ -210,7 +219,7 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
<Step2CommercialOffers
|
||||
service={service}
|
||||
formData={formData}
|
||||
isNotificationLink={isNotificationLink}
|
||||
canEdit={access.canEdit}
|
||||
isReadOnly={formData.ct?.status === 'on_approval' || formData.ct?.status === 'approved' || formData.ct?.status === 'rejected'}
|
||||
isRestrictedView={hidePrices}
|
||||
onSave={updateFormData}
|
||||
@@ -224,7 +233,8 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
<Step3ComparisonTable
|
||||
service={service}
|
||||
formData={formData}
|
||||
isNotificationLink={isNotificationLink}
|
||||
canEdit={access.canEdit}
|
||||
isReview={access.isReview}
|
||||
isRestrictedView={hidePrices}
|
||||
onSave={updateFormData}
|
||||
onNext={goNext}
|
||||
|
||||
+6
-5
@@ -18,7 +18,8 @@ import styles from './Step2CommercialOffers.module.scss';
|
||||
interface Props {
|
||||
service: CtService;
|
||||
formData: CTFormData;
|
||||
isNotificationLink?: boolean;
|
||||
// Правка СТ (getCtAccess): без неё — только просмотр, загрузка КП и сохранение недоступны.
|
||||
canEdit?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
isRestrictedView?: boolean;
|
||||
onSave: (partial: Partial<CTFormData>) => void;
|
||||
@@ -32,7 +33,7 @@ interface Props {
|
||||
export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
service,
|
||||
formData,
|
||||
isNotificationLink,
|
||||
canEdit = true,
|
||||
onSave,
|
||||
onNext,
|
||||
onPrev,
|
||||
@@ -176,7 +177,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
onFileRemove={handleQueueFileRemove}
|
||||
currencyOptions={currencyOptions}
|
||||
onCurrencyChange={kp.handleCurrencyChange}
|
||||
disabled={isNotificationLink || isReadOnly}
|
||||
disabled={!canEdit || isReadOnly}
|
||||
/>
|
||||
{kp.duplicateWarning && <Alert variant="warn">{kp.duplicateWarning}</Alert>}
|
||||
{kp.isProcessing && (
|
||||
@@ -200,7 +201,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)}
|
||||
onCurrencyChange={handleUploadedCurrencyChange}
|
||||
onRemove={spId => { handleRemoveKpFile(spId).catch(() => undefined); }}
|
||||
disabled={isNotificationLink || isReadOnly}
|
||||
disabled={!canEdit || isReadOnly}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -210,7 +211,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
</Button>
|
||||
<div className={styles.footerRight}>
|
||||
{saveError && <Alert variant="err">{saveError}</Alert>}
|
||||
{!isNotificationLink && !isReadOnly && (
|
||||
{canEdit && !isReadOnly && (
|
||||
<Button variant="accent" onClick={handleSave} disabled={!canProceed}>
|
||||
{isSaving ? <Spinner size="small" /> : 'Сохранить и продолжить'}
|
||||
</Button>
|
||||
|
||||
+7
-7
@@ -9,8 +9,8 @@ interface Props {
|
||||
service: CtService;
|
||||
formData: CTFormData;
|
||||
onSave: (partial: Partial<CTFormData>) => void;
|
||||
isNotificationLink: boolean;
|
||||
isReadOnly: boolean;
|
||||
// Режим просмотра (getCtAccess): согласующий, автор СТ на согласовании, посторонний.
|
||||
isReview: boolean;
|
||||
}
|
||||
|
||||
const RU_STATUS_TO_CT_STATUS: Record<string, CTStatus> = {
|
||||
@@ -20,9 +20,9 @@ const RU_STATUS_TO_CT_STATUS: Record<string, CTStatus> = {
|
||||
'На доработке': 'rework',
|
||||
};
|
||||
|
||||
// Инлайн-цепочка согласования Шага 3 — для согласующего, открывшего СТ по Teams-ссылке (?ctid=),
|
||||
// read-only маршрут + DecisionPanel своей строки. Правка состава/отправка — это Шаг 4, здесь их нет.
|
||||
export const InlineApprovalChain: React.FC<Props> = ({ service, formData, onSave, isNotificationLink, isReadOnly }) => {
|
||||
// Инлайн-цепочка согласования Шага 3 в режиме просмотра: read-only маршрут + DecisionPanel своей строки
|
||||
// (панель сама проверяет, чья сейчас очередь). Правка состава/отправка — это Шаг 4, здесь их нет.
|
||||
export const InlineApprovalChain: React.FC<Props> = ({ service, formData, onSave, isReview }) => {
|
||||
const { ct } = formData;
|
||||
const ctId = ct?.id;
|
||||
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([])),
|
||||
[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)
|
||||
);
|
||||
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 или её нет — блок не показываем
|
||||
}
|
||||
|
||||
|
||||
+30
-2
@@ -341,7 +341,14 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -355,17 +362,38 @@
|
||||
.deliveryQtyBox,
|
||||
.deliveryKmBox {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
// Обёртка инпута должна занимать всё место половинки: без flex-grow она ужималась
|
||||
// по содержимому, и инпут получал лишь остаток рядом с подписью «км».
|
||||
.offerInput {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.deliveryQtyBox {
|
||||
min-width: $delivery-trips-min-width;
|
||||
}
|
||||
|
||||
.deliveryKmBox {
|
||||
min-width: $delivery-km-min-width;
|
||||
border-left: 1px solid $border;
|
||||
padding-left: 2px;
|
||||
|
||||
// Стрелки type="number" в Chrome резервируют место даже невидимыми и обрезают число
|
||||
// («200» → «20(»). Км вводят с клавиатуры, стрелки не нужны.
|
||||
input {
|
||||
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 {
|
||||
service: CtService;
|
||||
formData: CTFormData;
|
||||
isNotificationLink: boolean;
|
||||
// Режим формы (getCtAccess): canEdit — правка таблицы; isReview — просмотр с маршрутом и кнопками решения.
|
||||
canEdit: boolean;
|
||||
isReview: boolean;
|
||||
isRestrictedView?: boolean;
|
||||
onSave: (partial: Partial<CTFormData>) => void;
|
||||
onNext: () => void;
|
||||
@@ -33,7 +35,8 @@ interface Props {
|
||||
export const Step3ComparisonTable: React.FC<Props> = ({
|
||||
service,
|
||||
formData,
|
||||
isNotificationLink,
|
||||
canEdit,
|
||||
isReview,
|
||||
isRestrictedView = false,
|
||||
onSave,
|
||||
onNext,
|
||||
@@ -45,7 +48,7 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
||||
// Ядро матрицы (стейт/рефы/эффекты/мемо/хендлеры/handleSave) вынесено в хук; результат передаём
|
||||
// как единый проп `matrix` в под-компоненты рендера (ComparisonMatrix/CriteriaTable/AddExtraRow),
|
||||
// здесь дополнительно достаём то, что нужно контейнеру напрямую (тулбары, AI-payload, футер).
|
||||
const matrix = useStep3Matrix({ service, formData, onSave, onNext, onStepComplete });
|
||||
const matrix = useStep3Matrix({ service, formData, isReadOnly: !canEdit, onSave, onNext, onStepComplete });
|
||||
const {
|
||||
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
|
||||
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave,
|
||||
@@ -194,8 +197,7 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
||||
service={service}
|
||||
formData={formData}
|
||||
onSave={onSave}
|
||||
isNotificationLink={isNotificationLink}
|
||||
isReadOnly={isReadOnly}
|
||||
isReview={isReview}
|
||||
/>
|
||||
|
||||
<div className={styles.footer}>
|
||||
|
||||
+24
-3
@@ -15,6 +15,8 @@ let extraIdCounter = 0;
|
||||
export interface UseStep3MatrixArgs {
|
||||
service: CtService;
|
||||
formData: CTFormData;
|
||||
// Таблица только на просмотр — режим формы (getCtAccess), а не признак из сессии.
|
||||
isReadOnly: boolean;
|
||||
onSave: (partial: Partial<CTFormData>) => void;
|
||||
onNext: () => void;
|
||||
onStepComplete: (step: FormStep) => void;
|
||||
@@ -24,11 +26,9 @@ export interface UseStep3MatrixArgs {
|
||||
// ВЕРБАТИМ из Step3ComparisonTable (§стайл-гайд: логика вне вёрстки). Клубок рефов сохраняет
|
||||
// несохранённые правки при переходах шагов и F5 (unmount/beforeunload-persist).
|
||||
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 isReadOnly = formData.approvalSent;
|
||||
|
||||
const [extraSuppliers, setExtraSuppliers] = useState<ExtraSupplier[]>(() => {
|
||||
const persisted = formData.step3ExtraSuppliers ?? [];
|
||||
persisted.forEach(es => {
|
||||
@@ -800,6 +800,27 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
});
|
||||
|
||||
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 —
|
||||
// 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 }>;
|
||||
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> }>;
|
||||
@@ -40,7 +39,6 @@ const INITIAL_FORM_DATA: CTFormData = {
|
||||
step3ExtraSuppliers: [],
|
||||
step3ExtraPositions: [],
|
||||
uploadedKpFiles: [],
|
||||
approvalSent: false,
|
||||
step3PositionQtyOverrides: {},
|
||||
step3CriteriaEdits: {},
|
||||
step3CustomCriteria: [],
|
||||
@@ -57,7 +55,8 @@ interface PersistedFormState {
|
||||
|
||||
function getUrlCtId(): string | undefined {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -86,7 +85,6 @@ export interface UseCtFormResult {
|
||||
currentStep: FormStep;
|
||||
formData: CTFormData;
|
||||
initialCtId: string | undefined;
|
||||
isNotificationLink: boolean;
|
||||
goToStep: (step: FormStep) => void;
|
||||
goNext: () => void;
|
||||
goPrev: () => void;
|
||||
@@ -99,7 +97,6 @@ export interface UseCtFormResult {
|
||||
|
||||
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;
|
||||
@@ -167,7 +164,6 @@ export function useCtForm(): UseCtFormResult {
|
||||
currentStep,
|
||||
formData,
|
||||
initialCtId,
|
||||
isNotificationLink,
|
||||
goToStep,
|
||||
goNext,
|
||||
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}`);
|
||||
}
|
||||
|
||||
// Запись поставщика-победителя в шапку СТ (лукап 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 (без скачивания на диск).
|
||||
// Нужен для подписания через NCALayer и сборки карточки SIGEX.
|
||||
async getCtReportBlob(ctId: string, format: string = "xlsx"): Promise<Blob> {
|
||||
|
||||
@@ -256,8 +256,14 @@ export class KpsCtFormService {
|
||||
// Мягкое удаление (то же, что кнопка «Удалить» в UI). Работает на «защищённых» списках,
|
||||
// где DELETE/MERGE падают с E_FAIL (§6). Для «обновления» такой записи: recycle() + повторный POST.
|
||||
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 = {
|
||||
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(
|
||||
`${this._liById(listName, id)}/recycle()`,
|
||||
|
||||
@@ -108,8 +108,14 @@ export class ApprovalListService {
|
||||
}
|
||||
|
||||
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 = {
|
||||
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(
|
||||
`${this._liById(this.listTitle, id)}/recycle()`,
|
||||
|
||||
Reference in New Issue
Block a user