feat(f5.1): manage findings and cascade inventory classifications
This commit is contained in:
@@ -7,6 +7,7 @@ import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import {
|
||||
createAssetAttribute,
|
||||
getFindingCatalogAdmin,
|
||||
listAssetTypes,
|
||||
updateAssetAttribute,
|
||||
} from '../lib/api';
|
||||
@@ -14,10 +15,12 @@ import type {
|
||||
AssetAttributeDataType,
|
||||
AssetAttributeDefinition,
|
||||
AssetType,
|
||||
FindingAdminCatalog,
|
||||
} from '../lib/api';
|
||||
import {
|
||||
createInventoryFamily,
|
||||
listInventoryFamiliesAdmin,
|
||||
replaceInventoryFamilyFindings,
|
||||
updateInventoryFamily,
|
||||
} from '../lib/inventoryStructureApi';
|
||||
import type { InventoryFamily } from '../lib/inventoryStructureApi';
|
||||
@@ -31,8 +34,11 @@ const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> =
|
||||
{ value: 'SELECT', label: 'Lista de opciones' },
|
||||
];
|
||||
|
||||
const EMPTY_FINDING_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
|
||||
|
||||
type CanonicalKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
|
||||
type FamilyEditor = InventoryFamily | 'new' | null;
|
||||
type FamilyFindingMode = 'ASSOCIATED' | 'ALL';
|
||||
|
||||
const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string }> = [
|
||||
{ kind: 'EMPRESA', label: 'Empresa', description: 'Maestro independiente. Se vincula temporalmente a un Área.' },
|
||||
@@ -64,7 +70,9 @@ export function AssetTypesPage() {
|
||||
const canManage = hasPermission('asset_types.manage');
|
||||
const [types, setTypes] = useState<AssetType[]>([]);
|
||||
const [families, setFamilies] = useState<InventoryFamily[]>([]);
|
||||
const [findingCatalog, setFindingCatalog] = useState<FindingAdminCatalog>(EMPTY_FINDING_CATALOG);
|
||||
const [selectedKind, setSelectedKind] = useState<CanonicalKind>('INSTALACION');
|
||||
const [selectedInstallationFamilyId, setSelectedInstallationFamilyId] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -75,6 +83,9 @@ export function AssetTypesPage() {
|
||||
const [familyName, setFamilyName] = useState('');
|
||||
const [familyParentId, setFamilyParentId] = useState('');
|
||||
const [familyActive, setFamilyActive] = useState(true);
|
||||
const [familyFindingIds, setFamilyFindingIds] = useState<Set<string>>(new Set());
|
||||
const [familyFindingMode, setFamilyFindingMode] = useState<FamilyFindingMode>('ASSOCIATED');
|
||||
const [familyFindingSearch, setFamilyFindingSearch] = useState('');
|
||||
|
||||
const [attributeEditor, setAttributeEditor] = useState<AssetAttributeDefinition | 'new' | null>(null);
|
||||
const [attributeCode, setAttributeCode] = useState('');
|
||||
@@ -87,12 +98,14 @@ export function AssetTypesPage() {
|
||||
const [attributeOrder, setAttributeOrder] = useState(0);
|
||||
|
||||
const load = async () => {
|
||||
const [loadedTypes, loadedFamilies] = await Promise.all([
|
||||
const [loadedTypes, loadedFamilies, loadedFindingCatalog] = await Promise.all([
|
||||
listAssetTypes(),
|
||||
listInventoryFamiliesAdmin(),
|
||||
getFindingCatalogAdmin(),
|
||||
]);
|
||||
setTypes(loadedTypes);
|
||||
setFamilies(loadedFamilies);
|
||||
setFindingCatalog(loadedFindingCatalog);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,17 +115,47 @@ export function AssetTypesPage() {
|
||||
const selectedType = canonicalType(types, selectedKind);
|
||||
const installationFamilies = useMemo(() => families.filter((item) => item.level === 'INSTALLATION'), [families]);
|
||||
const subinstallationFamilies = useMemo(() => families.filter((item) => item.level === 'SUBINSTALLATION'), [families]);
|
||||
const visibleSubinstallationFamilies = useMemo(() => subinstallationFamilies.filter((family) =>
|
||||
family.isActive !== false && (!selectedInstallationFamilyId || family.parentFamilyId === selectedInstallationFamilyId),
|
||||
), [subinstallationFamilies, selectedInstallationFamilyId]);
|
||||
|
||||
const activeFindingCategoryIds = useMemo(() => new Set(
|
||||
findingCatalog.categories.filter((category) => category.isActive).map((category) => category.id),
|
||||
), [findingCatalog.categories]);
|
||||
const findingCategoryNames = useMemo(() => new Map(
|
||||
findingCatalog.categories.map((category) => [category.id, category.name]),
|
||||
), [findingCatalog.categories]);
|
||||
const visibleFamilyFindings = useMemo(() => {
|
||||
const needle = familyFindingSearch.trim().toLocaleLowerCase('es-AR');
|
||||
return findingCatalog.items.filter((item) => {
|
||||
if (!item.isActive || !activeFindingCategoryIds.has(item.categoryId)) return false;
|
||||
if (familyFindingMode === 'ASSOCIATED' && !familyFindingIds.has(item.id)) return false;
|
||||
if (!needle) return true;
|
||||
return [item.title, item.code, findingCategoryNames.get(item.categoryId) ?? '']
|
||||
.some((value) => value.toLocaleLowerCase('es-AR').includes(needle));
|
||||
});
|
||||
}, [findingCatalog.items, activeFindingCategoryIds, familyFindingMode, familyFindingIds, familyFindingSearch, findingCategoryNames]);
|
||||
|
||||
const openNewFamily = (level: 'INSTALLATION' | 'SUBINSTALLATION') => {
|
||||
setFamilyEditor('new'); setFamilyLevel(level); setFamilyName(''); setFamilyParentId(''); setFamilyActive(true);
|
||||
setFamilyEditor('new'); setFamilyLevel(level); setFamilyName('');
|
||||
setFamilyParentId(level === 'SUBINSTALLATION' ? selectedInstallationFamilyId : '');
|
||||
setFamilyActive(true); setFamilyFindingIds(new Set()); setFamilyFindingMode('ASSOCIATED'); setFamilyFindingSearch('');
|
||||
setError(''); setSuccess('');
|
||||
};
|
||||
const openFamily = (family: InventoryFamily) => {
|
||||
if (family.level === 'INSTALLATION') setSelectedInstallationFamilyId(family.id);
|
||||
setFamilyEditor(family); setFamilyLevel(family.level); setFamilyName(family.name);
|
||||
setFamilyParentId(family.parentFamilyId ?? ''); setFamilyActive(family.isActive !== false);
|
||||
setFamilyFindingIds(new Set(family.findingItemIds ?? [])); setFamilyFindingMode('ASSOCIATED'); setFamilyFindingSearch('');
|
||||
setError(''); setSuccess('');
|
||||
};
|
||||
|
||||
const toggleFamilyFinding = (id: string) => setFamilyFindingIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const saveFamily = async (event: FormEvent) => {
|
||||
event.preventDefault(); setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
@@ -129,7 +172,11 @@ export function AssetTypesPage() {
|
||||
parentFamilyId: familyLevel === 'SUBINSTALLATION' ? familyParentId : null,
|
||||
isActive: familyActive,
|
||||
});
|
||||
setSuccess('Clasificación actualizada.');
|
||||
await replaceInventoryFamilyFindings(familyEditor.id, {
|
||||
itemIds: [...familyFindingIds],
|
||||
reason: 'Actualización desde Configuración de Inventarios',
|
||||
});
|
||||
setSuccess('Clasificación y Hallazgos asociados actualizados.');
|
||||
}
|
||||
await load();
|
||||
setFamilyEditor(null);
|
||||
@@ -213,13 +260,14 @@ export function AssetTypesPage() {
|
||||
|
||||
<div className="dashboard-grid" style={{ alignItems: 'start' }}>
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Instalación</h2><p className="section-copy">Definen el rubro técnico y los Hallazgos aplicables.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('INSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
|
||||
<div className="attribute-list">{installationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos asociados</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>
|
||||
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Instalación</h2><p className="section-copy">Tocá una Instalación para filtrar sus Subinstalaciones y editar sus Hallazgos.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('INSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
|
||||
<div className="attribute-list">{installationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className={`attribute-card ${selectedInstallationFamilyId === family.id ? 'active' : ''}`} key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos asociados</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>
|
||||
</article>
|
||||
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Cada tipo pertenece a un tipo de Instalación y tiene sus propios Hallazgos.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
|
||||
<div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{subinstallationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilyName ?? 'Sin Instalación padre'} · {family.findingCount ?? 0} Hallazgos asociados</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>
|
||||
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Mostrá todas o sólo las que pertenecen a una Instalación.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
|
||||
<label className="field" style={{ marginBottom: 14 }}><span>Filtrar por tipo de Instalación</span><SearchableSelect value={selectedInstallationFamilyId} onChange={(event) => setSelectedInstallationFamilyId(event.target.value)}><option value="">Todas las Instalaciones</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}</SearchableSelect></label>
|
||||
{visibleSubinstallationFamilies.length === 0 ? <div className="inline-empty">{selectedInstallationFamilyId ? 'Esta Instalación todavía no tiene tipos de Subinstalación asociados.' : 'No hay tipos de Subinstalación configurados.'}</div> : <div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{visibleSubinstallationFamilies.map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilyName ?? 'Sin Instalación padre'} · {family.findingCount ?? 0} Hallazgos asociados</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -230,9 +278,9 @@ export function AssetTypesPage() {
|
||||
{!selectedType ? <Alert>Este nivel todavía no tiene un tipo maestro activo.</Alert> : selectedType.attributes.length === 0 ? <div className="inline-empty">No hay columnas adicionales configuradas para {LEVELS.find((item) => item.kind === selectedKind)?.label}.</div> : <div className="attribute-list">{selectedType.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}
|
||||
</article>
|
||||
|
||||
<div className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">HALLAZGOS</span><h2>Relación por clasificación</h2><p className="section-copy">Abrí una clasificación arriba para ver cuántos Hallazgos tiene y entrar directamente a sus Hallazgos asociados. OTROS permanece disponible en la APK.</p></div><Link className="button secondary" to="/admin/finding-catalog">Abrir Catálogo de hallazgos <Icon name="chevron" /></Link></div></div>
|
||||
<div className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">HALLAZGOS</span><h2>Relación por clasificación</h2><p className="section-copy">Los Hallazgos se pueden revisar y editar directamente dentro de cada tipo. El Catálogo completo sigue disponible para una administración masiva.</p></div><Link className="button secondary" to="/admin/finding-catalog">Abrir Catálogo de hallazgos <Icon name="chevron" /></Link></div></div>
|
||||
|
||||
{familyEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveFamily}><div className="drawer-heading"><div><span className="eyebrow">CLASIFICACIÓN DE INVENTARIO</span><h2>{familyEditor === 'new' ? 'Nuevo tipo' : 'Editar tipo'}</h2></div><button type="button" className="icon-button" onClick={() => setFamilyEditor(null)}>×</button></div><div className="catalog-editor-fields">{familyEditor === 'new' && <label className="field"><span>Nivel</span><SearchableSelect value={familyLevel} onChange={(event) => { setFamilyLevel(event.target.value as 'INSTALLATION' | 'SUBINSTALLATION'); setFamilyParentId(''); }}><option value="INSTALLATION">Instalación</option><option value="SUBINSTALLATION">Subinstalación</option></SearchableSelect></label>}<label className="field"><span>Nombre</span><input value={familyName} onChange={(event) => setFamilyName(event.target.value)} maxLength={240} required placeholder={familyLevel === 'INSTALLATION' ? 'Ej.: Estación, Planta…' : 'Ej.: Tanque, Bomba…'} /></label>{familyLevel === 'SUBINSTALLATION' && <label className="field"><span>Tipo de Instalación padre</span><SearchableSelect value={familyParentId} onChange={(event) => setFamilyParentId(event.target.value)} required><option value="">Seleccionar…</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option value={family.id} key={family.id}>{family.name}</option>)}</SearchableSelect></label>}{familyEditor !== 'new' && <><div className="temporal-notice"><Icon name="alert" /><p><strong>{familyEditor.findingCount ?? 0} Hallazgos asociados.</strong> Revisalos o modificá la asociación desde el Catálogo.</p></div><Link className="button secondary" to={`/admin/finding-catalog?familyId=${familyEditor.id}`}>Ver Hallazgos asociados <Icon name="chevron" /></Link><label className="check-row"><input type="checkbox" checked={familyActive} onChange={(event) => setFamilyActive(event.target.checked)} /><span><strong>Tipo disponible</strong><small>Al desactivarlo deja de ofrecerse en nuevas altas.</small></span></label></>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setFamilyEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !familyName.trim() || (familyLevel === 'SUBINSTALLATION' && !familyParentId)}>{saving ? 'Guardando…' : 'Guardar tipo'}</button></div></form></div>}
|
||||
{familyEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveFamily}><div className="drawer-heading"><div><span className="eyebrow">CLASIFICACIÓN DE INVENTARIO</span><h2>{familyEditor === 'new' ? 'Nuevo tipo' : 'Editar tipo'}</h2></div><button type="button" className="icon-button" onClick={() => setFamilyEditor(null)}>×</button></div><div className="catalog-editor-fields">{familyEditor === 'new' && <label className="field"><span>Nivel</span><SearchableSelect value={familyLevel} onChange={(event) => { setFamilyLevel(event.target.value as 'INSTALLATION' | 'SUBINSTALLATION'); setFamilyParentId(''); }}><option value="INSTALLATION">Instalación</option><option value="SUBINSTALLATION">Subinstalación</option></SearchableSelect></label>}<label className="field"><span>Nombre</span><input value={familyName} onChange={(event) => setFamilyName(event.target.value)} maxLength={240} required placeholder={familyLevel === 'INSTALLATION' ? 'Ej.: Estación, Planta…' : 'Ej.: Tanque, Bomba…'} /></label>{familyLevel === 'SUBINSTALLATION' && <label className="field"><span>Tipo de Instalación padre</span><SearchableSelect value={familyParentId} onChange={(event) => setFamilyParentId(event.target.value)} required><option value="">Seleccionar…</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option value={family.id} key={family.id}>{family.name}</option>)}</SearchableSelect></label>}{familyEditor !== 'new' && <><div className="temporal-notice"><Icon name="alert" /><p><strong>{familyFindingIds.size} Hallazgos asociados.</strong> Se guardan junto con esta clasificación.</p></div><div className="quick-view-row"><button type="button" className={familyFindingMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => setFamilyFindingMode('ASSOCIATED')}>Asociados ({familyFindingIds.size})</button><button type="button" className={familyFindingMode === 'ALL' ? 'active' : ''} onClick={() => setFamilyFindingMode('ALL')}>Agregar o quitar</button></div><label className="field"><span>Buscar Hallazgo</span><input value={familyFindingSearch} onChange={(event) => setFamilyFindingSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>{visibleFamilyFindings.length === 0 ? <div className="inline-empty">{familyFindingMode === 'ASSOCIATED' ? 'No hay Hallazgos asociados. Tocá “Agregar o quitar” para vincularlos.' : 'No hay Hallazgos que coincidan con la búsqueda.'}</div> : <div className="finding-selection-list" style={{ maxHeight: 300, overflow: 'auto' }}>{visibleFamilyFindings.map((item) => familyFindingMode === 'ALL' ? <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={familyFindingIds.has(item.id)} onChange={() => toggleFamilyFinding(item.id)} /><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></label> : <div className="finding-selection-row" key={item.id}><span className="asset-symbol"><Icon name="check" size={14} /></span><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></div>)}</div>}<Link className="button secondary" to={`/admin/finding-catalog?familyId=${familyEditor.id}`}>Abrir en Catálogo completo <Icon name="chevron" /></Link><label className="check-row"><input type="checkbox" checked={familyActive} onChange={(event) => setFamilyActive(event.target.checked)} /><span><strong>Tipo disponible</strong><small>Al desactivarlo deja de ofrecerse en nuevas altas.</small></span></label></>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setFamilyEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !familyName.trim() || (familyLevel === 'SUBINSTALLATION' && !familyParentId)}>{saving ? 'Guardando…' : 'Guardar tipo y Hallazgos'}</button></div></form></div>}
|
||||
|
||||
{attributeEditor && selectedType && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveAttribute}><div className="drawer-heading"><div><span className="eyebrow">COLUMNA DE {LEVELS.find((item) => item.kind === selectedKind)?.label.toUpperCase()}</span><h2>{attributeEditor === 'new' ? 'Nueva columna' : 'Editar columna'}</h2></div><button type="button" className="icon-button" onClick={() => setAttributeEditor(null)}>×</button></div><div className="catalog-editor-fields"><label className="field"><span>Nombre visible</span><input value={attributeName} onChange={(event) => { setAttributeName(event.target.value); if (attributeEditor === 'new') setAttributeCode(attributeCodeFromName(event.target.value)); }} maxLength={160} required /></label><label className="field"><span>Código interno</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} maxLength={80} required pattern="[a-z][a-z0-9_]*" /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}</SearchableSelect></label>{attributeType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={5} value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} placeholder="Una opción por línea" /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} /></label><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Campo obligatorio</strong><small>Debe completarse cuando se registra este nivel.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Columna activa</strong><small>Desactivarla conserva los datos actuales.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !attributeName.trim() || !attributeCode}>{saving ? 'Guardando…' : 'Guardar columna'}</button></div></form></div>}
|
||||
</section>;
|
||||
|
||||
Reference in New Issue
Block a user