Neue Seite um Betriebe zu überwachen
This commit is contained in:
34
package-lock.json
generated
34
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "temp-react-app",
|
"name": "temp-react-app",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
@@ -3550,6 +3551,39 @@
|
|||||||
"url": "https://github.com/sponsors/gregberge"
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-table": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/table-core": "8.21.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8",
|
||||||
|
"react-dom": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/table-core": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
|
|||||||
@@ -1,4 +1,49 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper();
|
||||||
|
|
||||||
|
const ColumnTextFilter = ({ column, placeholder }) => {
|
||||||
|
if (!column.getCanFilter()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={column.getFilterValue() ?? ''}
|
||||||
|
onChange={(event) => column.setFilterValue(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="mt-1 w-full rounded border px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ColumnSelectFilter = ({ column, options }) => {
|
||||||
|
if (!column.getCanFilter()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={column.getFilterValue() ?? ''}
|
||||||
|
onChange={(event) => column.setFilterValue(event.target.value || undefined)}
|
||||||
|
className="mt-1 w-full rounded border px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
||||||
const [regions, setRegions] = useState([]);
|
const [regions, setRegions] = useState([]);
|
||||||
@@ -12,8 +57,8 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [filterText, setFilterText] = useState('');
|
const [sorting, setSorting] = useState([]);
|
||||||
const [sortBy, setSortBy] = useState('name');
|
const [columnFilters, setColumnFilters] = useState([]);
|
||||||
|
|
||||||
const watchedIds = useMemo(
|
const watchedIds = useMemo(
|
||||||
() => new Set(watchList.map((entry) => String(entry.storeId))),
|
() => new Set(watchList.map((entry) => String(entry.storeId))),
|
||||||
@@ -48,47 +93,186 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
|||||||
return map;
|
return map;
|
||||||
}, [knownStores]);
|
}, [knownStores]);
|
||||||
|
|
||||||
const filteredStores = useMemo(() => {
|
const tableData = useMemo(
|
||||||
const search = filterText.trim().toLowerCase();
|
() =>
|
||||||
const data = !search
|
eligibleStores.map((store) => ({
|
||||||
? [...eligibleStores]
|
...store,
|
||||||
: eligibleStores.filter((store) => {
|
membership: membershipMap.has(String(store.id))
|
||||||
const haystack = [
|
})),
|
||||||
store.name,
|
[eligibleStores, membershipMap]
|
||||||
store.city,
|
);
|
||||||
store.street,
|
|
||||||
store.zipCode,
|
const columns = useMemo(
|
||||||
store.id
|
() => [
|
||||||
]
|
columnHelper.accessor('name', {
|
||||||
.filter(Boolean)
|
header: ({ column }) => (
|
||||||
.map((value) => String(value).toLowerCase());
|
<div>
|
||||||
return haystack.some((value) => value.includes(search));
|
<button
|
||||||
});
|
type="button"
|
||||||
const compareString = (a = '', b = '') => a.localeCompare(b, 'de', { sensitivity: 'base' });
|
className="flex w-full items-center justify-between text-left font-semibold"
|
||||||
data.sort((a, b) => {
|
onClick={column.getToggleSortingHandler()}
|
||||||
switch (sortBy) {
|
>
|
||||||
case 'city':
|
<span>Betrieb</span>
|
||||||
return compareString(a.city || '', b.city || '') || compareString(a.name || '', b.name || '');
|
{column.getIsSorted() ? (
|
||||||
case 'created-desc': {
|
<span className="text-xs text-gray-500">{column.getIsSorted() === 'asc' ? '▲' : '▼'}</span>
|
||||||
const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
) : (
|
||||||
const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
<span className="text-xs text-gray-400">⇅</span>
|
||||||
if (timeA === timeB) {
|
)}
|
||||||
return compareString(a.name || '', b.name || '');
|
</button>
|
||||||
}
|
<ColumnTextFilter column={column} placeholder="Name / ID" />
|
||||||
return timeB - timeA;
|
</div>
|
||||||
}
|
),
|
||||||
case 'membership':
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{row.original.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">#{row.original.id}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
sortingFn: 'alphanumeric',
|
||||||
|
enableColumnFilter: true,
|
||||||
|
filterFn: 'includesString'
|
||||||
|
}),
|
||||||
|
columnHelper.accessor((row) => row.city || '', {
|
||||||
|
id: 'city',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between text-left font-semibold"
|
||||||
|
onClick={column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
<span>Ort</span>
|
||||||
|
{column.getIsSorted() ? (
|
||||||
|
<span className="text-xs text-gray-500">{column.getIsSorted() === 'asc' ? '▲' : '▼'}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">⇅</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<ColumnTextFilter column={column} placeholder="Ort / PLZ" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-800 text-sm">{row.original.city || 'unbekannt'}</p>
|
||||||
|
<p className="text-xs text-gray-500">{row.original.street || ''}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
sortingFn: 'alphanumeric',
|
||||||
|
filterFn: 'includesString'
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('createdAt', {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between text-left font-semibold"
|
||||||
|
onClick={column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
<span>Kooperation seit</span>
|
||||||
|
{column.getIsSorted() ? (
|
||||||
|
<span className="text-xs text-gray-500">{column.getIsSorted() === 'asc' ? '▲' : '▼'}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">⇅</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = getValue();
|
||||||
return (
|
return (
|
||||||
Number(membershipMap.has(String(b.id))) - Number(membershipMap.has(String(a.id))) ||
|
<span className="text-sm text-gray-600">
|
||||||
compareString(a.name || '', b.name || '')
|
{value ? new Date(value).toLocaleDateString('de-DE') : 'unbekannt'}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
case 'name':
|
},
|
||||||
default:
|
sortingFn: (rowA, rowB, columnId) => {
|
||||||
return compareString(a.name || '', b.name || '');
|
const a = rowA.getValue(columnId);
|
||||||
}
|
const b = rowB.getValue(columnId);
|
||||||
});
|
return new Date(a || 0).getTime() - new Date(b || 0).getTime();
|
||||||
return data;
|
}
|
||||||
}, [eligibleStores, filterText, sortBy, membershipMap]);
|
}),
|
||||||
|
columnHelper.accessor('membership', {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between text-left font-semibold"
|
||||||
|
onClick={column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
<span>Mitglied</span>
|
||||||
|
{column.getIsSorted() ? (
|
||||||
|
<span className="text-xs text-gray-500">{column.getIsSorted() === 'asc' ? '▲' : '▼'}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">⇅</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<ColumnSelectFilter
|
||||||
|
column={column}
|
||||||
|
options={[
|
||||||
|
{ value: 'true', label: 'Ja' },
|
||||||
|
{ value: 'false', label: 'Nein' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = getValue();
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center rounded px-2 py-1 text-xs font-semibold ${
|
||||||
|
value ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value ? 'Ja' : 'Nein'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
filterFn: (row, columnId, value) => {
|
||||||
|
if (value === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const boolValue = value === 'true';
|
||||||
|
return row.getValue(columnId) === boolValue;
|
||||||
|
},
|
||||||
|
sortingFn: (rowA, rowB, columnId) => {
|
||||||
|
const a = rowA.getValue(columnId);
|
||||||
|
const b = rowB.getValue(columnId);
|
||||||
|
return Number(b) - Number(a);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
id: 'watch',
|
||||||
|
header: () => <span>Überwachen</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const store = row.original;
|
||||||
|
const checked = watchedIds.has(String(store.id));
|
||||||
|
return (
|
||||||
|
<div className="text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-5 w-5"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(event) => handleToggleStore(store, event.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
[handleToggleStore, watchedIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: tableData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters
|
||||||
|
},
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel()
|
||||||
|
});
|
||||||
|
|
||||||
const loadRegions = useCallback(async () => {
|
const loadRegions = useCallback(async () => {
|
||||||
if (!authorizedFetch) {
|
if (!authorizedFetch) {
|
||||||
@@ -333,42 +517,6 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 border border-gray-200 rounded-lg p-4 bg-gray-50">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1" htmlFor="store-filter">
|
|
||||||
Betriebe filtern
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="store-filter"
|
|
||||||
type="text"
|
|
||||||
value={filterText}
|
|
||||||
onChange={(event) => setFilterText(event.target.value)}
|
|
||||||
className="border rounded-md p-2 w-full"
|
|
||||||
placeholder="Name, Ort oder PLZ"
|
|
||||||
disabled={!selectedRegionId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1" htmlFor="store-sort">
|
|
||||||
Sortieren nach
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="store-sort"
|
|
||||||
value={sortBy}
|
|
||||||
onChange={(event) => setSortBy(event.target.value)}
|
|
||||||
className="border rounded-md p-2 w-full"
|
|
||||||
disabled={!selectedRegionId}
|
|
||||||
>
|
|
||||||
<option value="name">Name (A-Z)</option>
|
|
||||||
<option value="city">Ort (A-Z)</option>
|
|
||||||
<option value="created-desc">Kooperation (neueste zuerst)</option>
|
|
||||||
<option value="membership">Eigene Betriebe zuerst</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h2 className="text-lg font-semibold text-gray-800">Betriebe in der Region</h2>
|
<h2 className="text-lg font-semibold text-gray-800">Betriebe in der Region</h2>
|
||||||
@@ -383,61 +531,35 @@ const StoreWatchPage = ({ authorizedFetch, knownStores = [] }) => {
|
|||||||
{!storesLoading && !selectedRegionId && (
|
{!storesLoading && !selectedRegionId && (
|
||||||
<p className="text-sm text-gray-500">Bitte zuerst eine Region auswählen.</p>
|
<p className="text-sm text-gray-500">Bitte zuerst eine Region auswählen.</p>
|
||||||
)}
|
)}
|
||||||
{!storesLoading && selectedRegionId && filteredStores.length === 0 && (
|
{!storesLoading && selectedRegionId && table.getRowModel().rows.length === 0 && (
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Keine Betriebe gefunden. Prüfe Filter oder sortiere anders.
|
Keine Betriebe gefunden. Prüfe Filter oder sortiere anders.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!storesLoading && filteredStores.length > 0 && (
|
{!storesLoading && table.getRowModel().rows.length > 0 && (
|
||||||
<div className="overflow-x-auto border border-gray-200 rounded-lg">
|
<div className="overflow-x-auto border border-gray-200 rounded-lg">
|
||||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
<thead className="bg-gray-100">
|
<thead className="bg-gray-100">
|
||||||
<tr>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<th className="px-4 py-2 text-left">Betrieb</th>
|
<tr key={headerGroup.id}>
|
||||||
<th className="px-4 py-2 text-left">Ort</th>
|
{headerGroup.headers.map((header) => (
|
||||||
<th className="px-4 py-2 text-left">Kooperation seit</th>
|
<th key={header.id} className="px-4 py-2 text-left align-top text-sm font-semibold">
|
||||||
<th className="px-4 py-2 text-center">Mitglied</th>
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
<th className="px-4 py-2 text-center">Überwachen</th>
|
</th>
|
||||||
</tr>
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200">
|
<tbody className="divide-y divide-gray-200">
|
||||||
{filteredStores.map((store) => {
|
{table.getRowModel().rows.map((row) => (
|
||||||
const checked = watchedIds.has(String(store.id));
|
<tr key={row.id} className="bg-white">
|
||||||
const isMember = membershipMap.has(String(store.id));
|
{row.getVisibleCells().map((cell) => (
|
||||||
const sinceLabel = store.createdAt
|
<td key={cell.id} className="px-4 py-2 align-middle">
|
||||||
? new Date(store.createdAt).toLocaleDateString('de-DE')
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
: 'unbekannt';
|
|
||||||
return (
|
|
||||||
<tr key={store.id} className="bg-white">
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<p className="font-medium text-gray-900">{store.name}</p>
|
|
||||||
<p className="text-xs text-gray-500">#{store.id}</p>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
))}
|
||||||
<p className="text-gray-800 text-sm">{store.city || 'unbekannt'}</p>
|
</tr>
|
||||||
<p className="text-xs text-gray-500">{store.street || ''}</p>
|
))}
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-sm text-gray-600">{sinceLabel}</td>
|
|
||||||
<td className="px-4 py-2 text-center">
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center justify-center px-2 py-1 text-xs font-semibold rounded ${
|
|
||||||
isMember ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isMember ? 'Ja' : 'Nein'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-5 w-5"
|
|
||||||
checked={checked}
|
|
||||||
onChange={(event) => handleToggleStore(store, event.target.checked)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user