diff --git a/src/webparts/ctFormsPortal/components/config/ctDocumentConfig.ts b/src/webparts/ctFormsPortal/components/config/ctDocumentConfig.ts index 37d2d23..34851df 100644 --- a/src/webparts/ctFormsPortal/components/config/ctDocumentConfig.ts +++ b/src/webparts/ctFormsPortal/components/config/ctDocumentConfig.ts @@ -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 => { diff --git a/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx b/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx index 6591caf..89e90c4 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx @@ -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 = ({ context, userDisplayName, currentStep, formData, initialCtId, - isNotificationLink, goToStep, goNext, goPrev, @@ -34,15 +35,18 @@ const CtCreateForm: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ context, userDisplayName, = ({ context, userDisplayName, ) => void; @@ -32,7 +33,7 @@ interface Props { export const Step2CommercialOffers: React.FC = ({ service, formData, - isNotificationLink, + canEdit = true, onSave, onNext, onPrev, @@ -176,7 +177,7 @@ export const Step2CommercialOffers: React.FC = ({ onFileRemove={handleQueueFileRemove} currencyOptions={currencyOptions} onCurrencyChange={kp.handleCurrencyChange} - disabled={isNotificationLink || isReadOnly} + disabled={!canEdit || isReadOnly} /> {kp.duplicateWarning && {kp.duplicateWarning}} {kp.isProcessing && ( @@ -200,7 +201,7 @@ export const Step2CommercialOffers: React.FC = ({ 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 = ({
{saveError && {saveError}} - {!isNotificationLink && !isReadOnly && ( + {canEdit && !isReadOnly && ( diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/InlineApprovalChain.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/InlineApprovalChain.tsx index 3e8914d..7bafc01 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/InlineApprovalChain.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/InlineApprovalChain.tsx @@ -9,8 +9,8 @@ interface Props { service: CtService; formData: CTFormData; onSave: (partial: Partial) => void; - isNotificationLink: boolean; - isReadOnly: boolean; + // Режим просмотра (getCtAccess): согласующий, автор СТ на согласовании, посторонний. + isReview: boolean; } const RU_STATUS_TO_CT_STATUS: Record = { @@ -20,9 +20,9 @@ const RU_STATUS_TO_CT_STATUS: Record = { 'На доработке': 'rework', }; -// Инлайн-цепочка согласования Шага 3 — для согласующего, открывшего СТ по Teams-ссылке (?ctid=), -// read-only маршрут + DecisionPanel своей строки. Правка состава/отправка — это Шаг 4, здесь их нет. -export const InlineApprovalChain: React.FC = ({ service, formData, onSave, isNotificationLink, isReadOnly }) => { +// Инлайн-цепочка согласования Шага 3 в режиме просмотра: read-only маршрут + DecisionPanel своей строки +// (панель сама проверяет, чья сейчас очередь). Правка состава/отправка — это Шаг 4, здесь их нет. +export const InlineApprovalChain: React.FC = ({ service, formData, onSave, isReview }) => { const { ct } = formData; const ctId = ct?.id; const [decisionSeq, setDecisionSeq] = useState(0); @@ -31,12 +31,12 @@ export const InlineApprovalChain: React.FC = ({ service, formData, onSave () => (): Promise => (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 или её нет — блок не показываем } diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.tsx index 028dac1..9498aa6 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.tsx @@ -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) => void; onNext: () => void; @@ -33,7 +35,8 @@ interface Props { export const Step3ComparisonTable: React.FC = ({ service, formData, - isNotificationLink, + canEdit, + isReview, isRestrictedView = false, onSave, onNext, @@ -45,7 +48,7 @@ export const Step3ComparisonTable: React.FC = ({ // Ядро матрицы (стейт/рефы/эффекты/мемо/хендлеры/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 = ({ service={service} formData={formData} onSave={onSave} - isNotificationLink={isNotificationLink} - isReadOnly={isReadOnly} + isReview={isReview} />
diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts index b74d65d..09140f8 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts @@ -15,6 +15,8 @@ let extraIdCounter = 0; export interface UseStep3MatrixArgs { service: CtService; formData: CTFormData; + // Таблица только на просмотр — режим формы (getCtAccess), а не признак из сессии. + isReadOnly: boolean; onSave: (partial: Partial) => 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(() => { const persisted = formData.step3ExtraSuppliers ?? []; persisted.forEach(es => { @@ -810,6 +810,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) { column => column.spId ?? extraSupSpIds[column.id], )); + // 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). diff --git a/src/webparts/ctFormsPortal/forms/ct/components/hooks/useCtForm.ts b/src/webparts/ctFormsPortal/forms/ct/components/hooks/useCtForm.ts index d3d759c..0488cef 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/hooks/useCtForm.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/hooks/useCtForm.ts @@ -20,7 +20,6 @@ export interface CTFormData { step3ExtraPositions: Array<{ id: string; materialName: string; unit: string; spId?: number; isDelivery?: boolean }>; step3DeliveryKm?: Record; // key = `${posId}::${supId}` uploadedKpFiles: Array<{ name: string; spId: number; supplierName?: string }>; - approvalSent: boolean; step3PositionQtyOverrides?: Record; step3CriteriaEdits?: Record; step3CustomCriteria?: Array<{ id: string; label: string; values: Record }>; @@ -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(getUrlCtId); - const isNotificationLink = new URLSearchParams(window.location.search).has('ctid'); const [currentStep, setCurrentStep] = useState(() => { const saved = initialCtId ? readSession(initialCtId) : null; @@ -167,7 +164,6 @@ export function useCtForm(): UseCtFormResult { currentStep, formData, initialCtId, - isNotificationLink, goToStep, goNext, goPrev, diff --git a/src/webparts/ctFormsPortal/forms/ct/components/utils/ctAccess.test.ts b/src/webparts/ctFormsPortal/forms/ct/components/utils/ctAccess.test.ts new file mode 100644 index 0000000..57eaed1 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/utils/ctAccess.test.ts @@ -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); + }); +}); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/utils/ctAccess.ts b/src/webparts/ctFormsPortal/forms/ct/components/utils/ctAccess.ts new file mode 100644 index 0000000..e4f9d3a --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/utils/ctAccess.ts @@ -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 = new Set([ + '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(); +}