fix(ct): выбор победителя снимается, без победителя СТ не сохраняется (#7)
Кнопку «Выбран» нельзя было снять. А если победитель не выбран, «Сохранить» не трогала winSupplier — в шапке СТ оставался прежний (так же при исключении выбранного поставщика, #1). Теперь «Выбрать» — переключатель, победитель обязателен: «Сохранить и продолжить» без него не пускает, проверка — до диалога комментария ИИ. При открытии СТ без сессии выбор восстанавливается из winSupplier шапки, иначе в другом браузере пришлось бы выбирать заново. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSe
|
|||||||
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
|
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
|
||||||
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
|
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
|
||||||
import { Step4Approval } from './Step4Approval/Step4Approval';
|
import { Step4Approval } from './Step4Approval/Step4Approval';
|
||||||
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns';
|
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findWinnerColumnId } from './utils/supplierColumns';
|
||||||
import { buildOffersFromSpRecords } from './utils/spOffers';
|
import { buildOffersFromSpRecords } from './utils/spOffers';
|
||||||
import { excludedItemsToFormData } from './utils/excludedColumns';
|
import { excludedItemsToFormData } from './utils/excludedColumns';
|
||||||
import { isUserInApprovalChain } from './utils/approvalAccess';
|
import { isUserInApprovalChain } from './utils/approvalAccess';
|
||||||
@@ -184,18 +184,26 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 in review mode (always fresh).
|
||||||
|
const suppliers = withNoOfferSuppliers(
|
||||||
|
formData.suppliers.length === 0 ? [...spSuppliers, ...excluded.suppliers] : formData.suppliers,
|
||||||
|
noOfferFromSp,
|
||||||
|
);
|
||||||
|
// Победитель обязателен для «Сохранить» на Шаге 3: без сессии берём его из шапки СТ, иначе
|
||||||
|
// в другом браузере пришлось бы выбирать заново.
|
||||||
|
const winnerColumnId = formData.recommendedSupplierId === undefined
|
||||||
|
? findWinnerColumnId(suppliers, ct.winSupplierSpId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
updateFormData({
|
updateFormData({
|
||||||
ct,
|
ct,
|
||||||
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.
|
||||||
...(preferSp || 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.
|
suppliers,
|
||||||
// Only use SP-derived data when session has none, or in review mode (always fresh).
|
...(winnerColumnId !== undefined ? { recommendedSupplierId: winnerColumnId } : {}),
|
||||||
suppliers: withNoOfferSuppliers(
|
|
||||||
formData.suppliers.length === 0 ? [...spSuppliers, ...excluded.suppliers] : formData.suppliers,
|
|
||||||
noOfferFromSp,
|
|
||||||
),
|
|
||||||
...(preferSp || formData.offers.length === 0 ? { offers: [...offers, ...excluded.offers] } : {}),
|
...(preferSp || formData.offers.length === 0 ? { offers: [...offers, ...excluded.offers] } : {}),
|
||||||
...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}),
|
...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}),
|
||||||
...(uploadedKpFiles.length > 0 ? { uploadedKpFiles } : {}),
|
...(uploadedKpFiles.length > 0 ? { uploadedKpFiles } : {}),
|
||||||
|
|||||||
+3
-1
@@ -8,6 +8,7 @@ import { SUPPLIER_STATUS_OPTIONS, getSubCols, formatKzt } from './matrixHelpers'
|
|||||||
import { UseAdataCheckResult } from './useAdataCheck';
|
import { UseAdataCheckResult } from './useAdataCheck';
|
||||||
import { AdataIndicator } from './AdataIndicator';
|
import { AdataIndicator } from './AdataIndicator';
|
||||||
import { ExcludeSupplierDialog } from './ExcludeSupplierDialog';
|
import { ExcludeSupplierDialog } from './ExcludeSupplierDialog';
|
||||||
|
import { toggleWinner } from './step3Validation';
|
||||||
import styles from './Step3ComparisonTable.module.scss';
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
|
|
||||||
const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во
|
const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во
|
||||||
@@ -113,8 +114,9 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className={[styles.selectSupplierBtn, isSel ? styles.selectSupplierBtnActive : ''].filter(Boolean).join(' ')}
|
className={[styles.selectSupplierBtn, isSel ? styles.selectSupplierBtnActive : ''].filter(Boolean).join(' ')}
|
||||||
onClick={() => setSelectedSupplierId(s.id)}
|
onClick={() => setSelectedSupplierId(toggleWinner(selectedSupplierId, s.id))}
|
||||||
aria-pressed={isSel}
|
aria-pressed={isSel}
|
||||||
|
title={isSel ? 'Снять выбор' : 'Выбрать победителем'}
|
||||||
>
|
>
|
||||||
{isSel ? 'Выбран' : 'Выбрать'}
|
{isSel ? 'Выбран' : 'Выбрать'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+8
-2
@@ -56,7 +56,8 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
const matrix = useStep3Matrix({ service, formData, isReadOnly: !canEdit, 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,
|
||||||
|
validateBeforeSave, handleSave,
|
||||||
extraPositions, suppliersWithOffer, columnDisplayNames, allPositions, computedOffers, selectedSupplierId,
|
extraPositions, suppliersWithOffer, columnDisplayNames, allPositions, computedOffers, selectedSupplierId,
|
||||||
excludedColumns, handleRestoreSupplier,
|
excludedColumns, handleRestoreSupplier,
|
||||||
} = matrix;
|
} = matrix;
|
||||||
@@ -242,7 +243,12 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => (!ai.aiComment && !ai.isAiLoading) ? ai.setShowAiPromptDialog(true) : handleSave()}
|
onClick={() => {
|
||||||
|
// Сначала проверка (победитель, километраж), потом предложение комментария ИИ.
|
||||||
|
if (!validateBeforeSave()) return;
|
||||||
|
if (!ai.aiComment && !ai.isAiLoading) ai.setShowAiPromptDialog(true);
|
||||||
|
else void handleSave();
|
||||||
|
}}
|
||||||
disabled={isSaving || isReadOnly}
|
disabled={isSaving || isReadOnly}
|
||||||
>
|
>
|
||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
import { STEP3_SAVE_ERRORS, getStep3SaveError, toggleWinner } from './step3Validation';
|
||||||
|
|
||||||
|
describe('toggleWinner', () => {
|
||||||
|
it('нажали «Выбрать» — поставщик выбран', () => {
|
||||||
|
expect(toggleWinner(undefined, '259_278')).toBe('259_278');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Раньше повторное нажатие ничего не делало — снять выбор было нельзя.
|
||||||
|
it('повторное нажатие на «Выбран» снимает выбор', () => {
|
||||||
|
expect(toggleWinner('259_278', '259_278')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нажали «Выбрать» у другого — выбор переходит к нему', () => {
|
||||||
|
expect(toggleWinner('259_278', '282_320')).toBe('282_320');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStep3SaveError', () => {
|
||||||
|
const compared = ['259_278', '282_320'];
|
||||||
|
|
||||||
|
it('победитель выбран — сохранять можно', () => {
|
||||||
|
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasDeliveryWithoutKm: false }))
|
||||||
|
.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('победитель не выбран — нельзя', () => {
|
||||||
|
expect(getStep3SaveError({ selectedSupplierId: undefined, comparedColumnIds: compared, hasDeliveryWithoutKm: false }))
|
||||||
|
.toBe(STEP3_SAVE_ERRORS.noWinner);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Выбор из сессии мог остаться на поставщике, которого исключили или чей КП убрали.
|
||||||
|
it('выбран поставщик не из сравниваемых — как будто не выбран', () => {
|
||||||
|
expect(getStep3SaveError({ selectedSupplierId: '300_400', comparedColumnIds: compared, hasDeliveryWithoutKm: false }))
|
||||||
|
.toBe(STEP3_SAVE_ERRORS.noWinner);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('логистика без километража — нельзя', () => {
|
||||||
|
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasDeliveryWithoutKm: true }))
|
||||||
|
.toBe(STEP3_SAVE_ERRORS.deliveryKmMissing);
|
||||||
|
});
|
||||||
|
});
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
// Проверки перед «Сохранить и продолжить» на Шаге 3. Сообщения — для пользователя.
|
||||||
|
|
||||||
|
export const STEP3_SAVE_ERRORS = {
|
||||||
|
noWinner: 'Выберите поставщика-победителя: кнопка «Выбрать» в шапке его колонки.',
|
||||||
|
deliveryKmMissing: 'Укажите километраж для каждого потенциального поставщика в строке логистики',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Step3SaveCheckInput {
|
||||||
|
selectedSupplierId: string | undefined;
|
||||||
|
// Колонки, которые сравниваются (без исключённых).
|
||||||
|
comparedColumnIds: string[];
|
||||||
|
hasDeliveryWithoutKm: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Победитель обязателен (решение от 2026-10-27): он уходит в шапку СТ (winSupplier) — без него
|
||||||
|
// отчёт и согласование не знают, кого выбрали. Выбранный должен быть среди сравниваемых:
|
||||||
|
// исключённый или удалённый поставщик победителем не считается.
|
||||||
|
export function getStep3SaveError(input: Step3SaveCheckInput): string | undefined {
|
||||||
|
const { selectedSupplierId, comparedColumnIds, hasDeliveryWithoutKm } = input;
|
||||||
|
if (!selectedSupplierId || !comparedColumnIds.includes(selectedSupplierId)) {
|
||||||
|
return STEP3_SAVE_ERRORS.noWinner;
|
||||||
|
}
|
||||||
|
if (hasDeliveryWithoutKm) return STEP3_SAVE_ERRORS.deliveryKmMissing;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка «Выбрать» / «Выбран»: повторное нажатие на выбранного снимает выбор.
|
||||||
|
export function toggleWinner(currentId: string | undefined, clickedId: string): string | undefined {
|
||||||
|
return currentId === clickedId ? undefined : clickedId;
|
||||||
|
}
|
||||||
+17
-7
@@ -7,6 +7,7 @@ import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, Cr
|
|||||||
import { applyCreatedIds, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers';
|
import { applyCreatedIds, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers';
|
||||||
import { buildExcludedRecordsForSave } from '../utils/excludedColumns';
|
import { buildExcludedRecordsForSave } from '../utils/excludedColumns';
|
||||||
import { parseNumberInput } from '../utils/parseNumberInput';
|
import { parseNumberInput } from '../utils/parseNumberInput';
|
||||||
|
import { getStep3SaveError } from './step3Validation';
|
||||||
import { IVendorRecord } from '../../services/VendorDatabaseService';
|
import { IVendorRecord } from '../../services/VendorDatabaseService';
|
||||||
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
|
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
|
||||||
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
|
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
|
||||||
@@ -705,15 +706,23 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
onSave({ step3ExtraSuppliers: nextSuppliers, step3ExtraPositions: nextPositions });
|
onSave({ step3ExtraSuppliers: nextSuppliers, step3ExtraPositions: nextPositions });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Проверка перед «Сохранить и продолжить». Показывает ошибку и говорит, можно ли сохранять.
|
||||||
|
// Вызывается и до диалога «Комментарий от ИИ» — чтобы не гонять пользователя через ИИ к отказу.
|
||||||
|
const validateBeforeSave = (): boolean => {
|
||||||
|
const error = getStep3SaveError({
|
||||||
|
selectedSupplierId,
|
||||||
|
comparedColumnIds: suppliersWithOffer.map(s => s.id),
|
||||||
|
hasDeliveryWithoutKm: extraPositions.some(ep =>
|
||||||
|
ep.isDelivery && suppliersWithOffer.some(s => !deliveryKm[`${ep.id}::${s.id}`])
|
||||||
|
),
|
||||||
|
});
|
||||||
|
setSaveError(error);
|
||||||
|
return error === undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
const handleSave = async (): Promise<void> => {
|
||||||
if (!ct) return;
|
if (!ct) return;
|
||||||
const deliveryMissingKm = extraPositions.filter(ep =>
|
if (!validateBeforeSave()) return;
|
||||||
ep.isDelivery && suppliersWithOffer.some(s => !deliveryKm[`${ep.id}::${s.id}`])
|
|
||||||
);
|
|
||||||
if (deliveryMissingKm.length > 0) {
|
|
||||||
setSaveError('Укажите километраж для каждого потенциального поставщика в строке логистики');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setSaveError(undefined);
|
setSaveError(undefined);
|
||||||
|
|
||||||
@@ -980,6 +989,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
handleAddDelivery,
|
handleAddDelivery,
|
||||||
handleDeliveryKmChange,
|
handleDeliveryKmChange,
|
||||||
handleDeleteExtraPosition,
|
handleDeleteExtraPosition,
|
||||||
|
validateBeforeSave,
|
||||||
handleSave,
|
handleSave,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export interface CT {
|
|||||||
// блока «Выгрузка в INCORE» и т.п.: в новом механизме согласования (approval-route API)
|
// блока «Выгрузка в INCORE» и т.п.: в новом механизме согласования (approval-route API)
|
||||||
// инициатор в саму матрицу согласования не входит, в отличие от прежней цепочки.
|
// инициатор в саму матрицу согласования не входит, в отличие от прежней цепочки.
|
||||||
authorEmail?: string;
|
authorEmail?: string;
|
||||||
|
// Победитель (лукап winSupplier → «Поставщики»), записывается на Шаге 3.
|
||||||
|
winSupplierSpId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CTStatus =
|
export type CTStatus =
|
||||||
|
|||||||
@@ -1,6 +1,27 @@
|
|||||||
import { buildColumnDisplayNames, buildColumnLabels, buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findSiblingColumnIds } from './supplierColumns';
|
import { buildColumnDisplayNames, buildColumnLabels, buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findSiblingColumnIds, findWinnerColumnId } from './supplierColumns';
|
||||||
import { Supplier } from '../types/domain';
|
import { Supplier } from '../types/domain';
|
||||||
|
|
||||||
|
describe('findWinnerColumnId (победитель из шапки СТ при открытии без сессии)', () => {
|
||||||
|
const columns: Supplier[] = [
|
||||||
|
{ id: '281_318', spId: 281, kpFileSpId: 318, name: 'SteelGate', hasOffer: true, exclusion: { reason: 'Дорого' } },
|
||||||
|
{ id: '281_330', spId: 281, kpFileSpId: 330, name: 'SteelGate', hasOffer: true },
|
||||||
|
{ id: '282_320', spId: 282, kpFileSpId: 320, name: 'Каспий Дорс', hasOffer: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('колонка победителя по SP Id поставщика', () => {
|
||||||
|
expect(findWinnerColumnId(columns, 282)).toBe('282_320');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('у поставщика с несколькими КП — первая неисключённая колонка', () => {
|
||||||
|
expect(findWinnerColumnId(columns, 281)).toBe('281_330');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('победителя нет или его колонки нет — undefined', () => {
|
||||||
|
expect(findWinnerColumnId(columns, undefined)).toBeUndefined();
|
||||||
|
expect(findWinnerColumnId(columns, 999)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('buildSupplierColumnId', () => {
|
describe('buildSupplierColumnId', () => {
|
||||||
it('колонка = поставщик + файл КП', () => {
|
it('колонка = поставщик + файл КП', () => {
|
||||||
expect(buildSupplierColumnId(263, 277)).toBe('263_277');
|
expect(buildSupplierColumnId(263, 277)).toBe('263_277');
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ export function buildColumnsFromSpOffers(records: SpOfferColumnSource[]): Suppli
|
|||||||
return Array.from(columnsById.values());
|
return Array.from(columnsById.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Колонка победителя по winSupplier из шапки СТ (СТ открыта без сессии). В SP хранится поставщик,
|
||||||
|
// а не колонка: у поставщика с несколькими КП берём первую неисключённую колонку.
|
||||||
|
export function findWinnerColumnId(columns: Supplier[], winSupplierSpId: number | undefined): string | undefined {
|
||||||
|
if (winSupplierSpId === undefined) return undefined;
|
||||||
|
return columns.find(c => c.spId === winSupplierSpId && c.hasOffer !== false && !c.exclusion)?.id;
|
||||||
|
}
|
||||||
|
|
||||||
// Колонки той же компании (тот же SP Id поставщика), включая саму колонку. Нужно для данных, которые
|
// Колонки той же компании (тот же SP Id поставщика), включая саму колонку. Нужно для данных, которые
|
||||||
// принадлежат компании, а не КП: проверка Adata, БИН.
|
// принадлежат компании, а не КП: проверка Adata, БИН.
|
||||||
export function findSiblingColumnIds(columns: Supplier[], columnId: string): string[] {
|
export function findSiblingColumnIds(columns: Supplier[], columnId: string): string[] {
|
||||||
|
|||||||
@@ -819,7 +819,7 @@ export class CtService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadCTById(ctId: string): Promise<CT> {
|
async loadCTById(ctId: string): Promise<CT> {
|
||||||
const url = `${this._liById(LISTS.CT, ctId)}?$select=Id,Title,number,project,status,Author/EMail&$expand=Author`;
|
const url = `${this._liById(LISTS.CT, ctId)}?$select=Id,Title,number,project,status,winSupplierId,Author/EMail&$expand=Author`;
|
||||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||||
if (!res.ok) throw new Error(`Ошибка загрузки СТ: ${res.status}`);
|
if (!res.ok) throw new Error(`Ошибка загрузки СТ: ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -829,6 +829,7 @@ export class CtService {
|
|||||||
projectName: String(data.project ?? ""),
|
projectName: String(data.project ?? ""),
|
||||||
status: CHOICE_TO_CT_STATUS[String(data.status ?? "")] ?? "formed",
|
status: CHOICE_TO_CT_STATUS[String(data.status ?? "")] ?? "formed",
|
||||||
authorEmail: String(data.Author?.EMail ?? "") || undefined,
|
authorEmail: String(data.Author?.EMail ?? "") || undefined,
|
||||||
|
winSupplierSpId: data.winSupplierId ? Number(data.winSupplierId) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user