update ui update and fix layout issue
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import {ChartCard} from '@/components/charts/ChartCard';
|
||||
import {BarChartView} from '@/components/charts/BarChartView';
|
||||
import {CHART} from '@/components/charts/palette';
|
||||
import {formatCompact, formatDayLabel} from '@/lib/format';
|
||||
import type {AttendancePoint} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
import {ChartCard} from '@/shared/components/charts/ChartCard';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {CHART} from '@/shared/components/charts/palette';
|
||||
import {formatCompact, formatDayLabel} from '@/shared/utils/format';
|
||||
import type {AttendancePoint} from '@/features/staff/types/staff';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* Daily attendance, stacked.
|
||||
@@ -1,21 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import {ResponsiveTable} from '@/components/patterns/ResponsiveTable';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
||||
import {PanelCard} from '@/components/patterns/PanelCard';
|
||||
import {SkeletonRows} from '@/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
|
||||
import {ATTENDANCE} from './attendance';
|
||||
import {storeName} from '@/lib/mock/stores';
|
||||
import {formatCompact, formatPct} from '@/lib/format';
|
||||
import type {Attendance, StaffMember} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {ATTENDANCE} from '@/features/staff/utils/attendance';
|
||||
import {useStoreName} from '@/features/stores/hooks/useStoreDirectory';
|
||||
import {formatCompact, formatPct} from '@/shared/utils/format';
|
||||
import type {Attendance, StaffMember} from '@/features/staff/types/staff';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
interface Row extends Record<string, unknown> {
|
||||
id: string;
|
||||
@@ -43,6 +43,10 @@ export function Leaderboard({
|
||||
}: {
|
||||
resource: Resource<StaffMember[]>;
|
||||
}) {
|
||||
// Resolved from the workspace store directory, not from a fixture — see
|
||||
// useStoreDirectory.
|
||||
const storeName = useStoreName();
|
||||
|
||||
const columns: TableColumn<Row>[] = [
|
||||
{
|
||||
key: 'rank',
|
||||
@@ -7,13 +7,13 @@ import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {HoverLift} from '@/components/motion/HoverLift';
|
||||
import {SectionHeader} from '@/components/patterns/SectionHeader';
|
||||
import {StatPair, StatRow} from '@/components/patterns/StatPair';
|
||||
import {ATTENDANCE} from './attendance';
|
||||
import {storeName} from '@/lib/mock/stores';
|
||||
import {formatCompact, formatPct} from '@/lib/format';
|
||||
import type {StaffMember} from '@/lib/api/contracts';
|
||||
import {HoverLift} from '@/shared/components/motion/HoverLift';
|
||||
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
|
||||
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
|
||||
import {ATTENDANCE} from '@/features/staff/utils/attendance';
|
||||
import {useStoreName} from '@/features/stores/hooks/useStoreDirectory';
|
||||
import {formatCompact, formatPct} from '@/shared/utils/format';
|
||||
import type {StaffMember} from '@/features/staff/types/staff';
|
||||
|
||||
/**
|
||||
* One team member.
|
||||
@@ -25,6 +25,9 @@ import type {StaffMember} from '@/lib/api/contracts';
|
||||
*/
|
||||
export function StaffCard({member}: {member: StaffMember}) {
|
||||
const attendance = ATTENDANCE[member.attendance];
|
||||
// Resolved from the workspace store directory, not from a fixture — see
|
||||
// useStoreDirectory.
|
||||
const storeName = useStoreName();
|
||||
|
||||
return (
|
||||
<HoverLift>
|
||||
@@ -2,54 +2,41 @@
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
|
||||
import {FilterBar} from '@/components/patterns/FilterBar';
|
||||
import {CollectionRegion} from '@/components/patterns/CollectionRegion';
|
||||
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
|
||||
import {SkeletonCardGrid} from '@/components/patterns/LoadingState';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {FilterBar} from '@/shared/components/patterns/FilterBar';
|
||||
import {CollectionRegion} from '@/shared/components/patterns/CollectionRegion';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {StaffCard} from './StaffCard';
|
||||
import {ATTENDANCE} from './attendance';
|
||||
import type {Attendance, StaffMember} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
|
||||
type Filter = 'all' | Attendance;
|
||||
import {ATTENDANCE} from '@/features/staff/utils/attendance';
|
||||
import {
|
||||
countByAttendance,
|
||||
selectStaff,
|
||||
} from '@/features/staff/services/staffService';
|
||||
import type {StaffFilter} from '@/features/staff/services/staffService';
|
||||
import type {StaffMember} from '@/features/staff/types/staff';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
const STAFF_GRID_COLUMNS = {minWidth: 300, max: 3, repeat: 'fit'} as const;
|
||||
|
||||
export function StaffGrid({resource}: {resource: Resource<StaffMember[]>}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const [filter, setFilter] = useState<StaffFilter>('all');
|
||||
|
||||
// `?? []` allocates a fresh array every render, which would make it an
|
||||
// unstable dependency and recompute both memos on every keystroke.
|
||||
const staff = useMemo(() => resource.data ?? [], [resource.data]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<Filter, number> = {
|
||||
all: staff.length,
|
||||
present: 0,
|
||||
late: 0,
|
||||
absent: 0,
|
||||
leave: 0,
|
||||
};
|
||||
for (const m of staff) c[m.attendance] += 1;
|
||||
return c;
|
||||
}, [staff]);
|
||||
// Both derivations live in staffService: what counts as a match and what
|
||||
// order the roster is in are product rules, and a redesign of this grid must
|
||||
// not be able to change them by accident. The memos stay here because
|
||||
// memoisation is a React concern, not a domain one.
|
||||
const counts = useMemo(() => countByAttendance(staff), [staff]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return staff
|
||||
.filter(
|
||||
(m) =>
|
||||
(filter === 'all' || m.attendance === filter) &&
|
||||
(q === '' ||
|
||||
m.name.toLowerCase().includes(q) ||
|
||||
m.role.toLowerCase().includes(q)),
|
||||
)
|
||||
// Best performers first — the roster's default question is "who is
|
||||
// carrying this store", not "who was added first".
|
||||
.sort((a, b) => b.performanceScore - a.performanceScore);
|
||||
}, [staff, query, filter]);
|
||||
const visible = useMemo(
|
||||
() => selectStaff(staff, {filter, search: query}),
|
||||
[staff, query, filter],
|
||||
);
|
||||
|
||||
return (
|
||||
<CollectionRegion label="Team roster">
|
||||
@@ -58,7 +45,7 @@ export function StaffGrid({resource}: {resource: Resource<StaffMember[]>}) {
|
||||
onQueryChange={setQuery}
|
||||
placeholder="Search name or role…"
|
||||
filterValue={filter}
|
||||
onFilterChange={(v) => setFilter(v as Filter)}
|
||||
onFilterChange={(v) => setFilter(v as StaffFilter)}
|
||||
filterLabel="Filter staff by attendance"
|
||||
options={[
|
||||
{value: 'all', label: 'All', hint: counts.all},
|
||||
@@ -5,15 +5,15 @@ import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
|
||||
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
|
||||
import {HoverLift} from '@/components/motion/HoverLift';
|
||||
import {AnimatedNumber} from '@/components/motion/AnimatedNumber';
|
||||
import {useMetricColumns} from '@/features/dashboard/KpiRow';
|
||||
import {ATTENDANCE} from './attendance';
|
||||
import {formatPct} from '@/lib/format';
|
||||
import type {StaffSummary} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {HoverLift} from '@/shared/components/motion/HoverLift';
|
||||
import {AnimatedNumber} from '@/shared/components/motion/AnimatedNumber';
|
||||
import {useMetricColumns} from '@/features/dashboard/components/KpiRow';
|
||||
import {ATTENDANCE} from '@/features/staff/utils/attendance';
|
||||
import {formatPct} from '@/shared/utils/format';
|
||||
import type {StaffSummary} from '@/features/staff/types/staff';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {StatusDotVariant} from '@astryxdesign/core/StatusDot';
|
||||
|
||||
/**
|
||||
23
src/features/staff/hooks/useStaff.ts
Normal file
23
src/features/staff/hooks/useStaff.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import {staffRepository} from '@/features/staff/repositories/staffRepository';
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
|
||||
/** Staff data access. See features/dashboard/hooks/useDashboard for the pattern. */
|
||||
|
||||
export function useStaffList() {
|
||||
return useResource(staffRepository.list(useScope()));
|
||||
}
|
||||
|
||||
export function useStaffSummary() {
|
||||
return useResource(staffRepository.summary(useScope()), {
|
||||
// A summary of zero people is still a summary; emptiness is the total
|
||||
// being zero, not the object being absent.
|
||||
isEmpty: (s) => s.total === 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useStaffAttendance() {
|
||||
return useResource(staffRepository.attendance(useScope()));
|
||||
}
|
||||
139
src/features/staff/mock/staff.mock.ts
Normal file
139
src/features/staff/mock/staff.mock.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type {Attendance, AttendancePoint, StaffMember, StaffSummary} from '@/features/staff/types/staff';
|
||||
import type {RangeKey} from '@/shared/types/api';
|
||||
import {createRng} from '@/shared/mock/rng';
|
||||
import {STORE_SEED} from '@/features/stores/mock/stores.mock';
|
||||
import {RANGE_DAYS} from '@/features/dashboard/mock/dashboard.mock';
|
||||
|
||||
const FIRST = [
|
||||
'Anita', 'Vikram', 'Priya', 'Rahul', 'Deepa', 'Suresh', 'Meera', 'Arjun',
|
||||
'Kavya', 'Nikhil', 'Sneha', 'Rohit', 'Divya', 'Karthik', 'Ananya', 'Manish',
|
||||
'Lakshmi', 'Sanjay', 'Pooja', 'Ravi', 'Nisha', 'Ajay', 'Shreya', 'Vinod',
|
||||
];
|
||||
const LAST = [
|
||||
'R', 'S', 'M', 'K', 'N', 'T', 'P', 'V', 'B', 'G', 'J', 'D',
|
||||
];
|
||||
const ROLES = [
|
||||
'Store manager',
|
||||
'Shift lead',
|
||||
'Sales associate',
|
||||
'Sales associate',
|
||||
'Cashier',
|
||||
];
|
||||
|
||||
/**
|
||||
* The roster is sized from each store's own staffCount, so the Staff module
|
||||
* and a store's card cannot disagree about how many people work there — the
|
||||
* store card literally says "14 staff" and this must produce 14 rows.
|
||||
*/
|
||||
/**
|
||||
* No `endMs` parameter, unlike the other builders: a roster is not a time
|
||||
* series. Who works at a store depends on the store and the period length,
|
||||
* not on which day you ask.
|
||||
*/
|
||||
export function buildStaff(storeId: string, range: RangeKey): StaffMember[] {
|
||||
const stores =
|
||||
storeId === 'all'
|
||||
? STORE_SEED
|
||||
: STORE_SEED.filter((s) => s.id === storeId);
|
||||
|
||||
const out: StaffMember[] = [];
|
||||
let nameIdx = 0;
|
||||
|
||||
for (const store of stores) {
|
||||
for (let i = 0; i < store.staffCount; i++) {
|
||||
const rng = createRng('staff', store.id, i, range);
|
||||
const first = FIRST[nameIdx % FIRST.length];
|
||||
const last = LAST[(nameIdx * 7 + i) % LAST.length];
|
||||
nameIdx += 1;
|
||||
|
||||
// Attendance is weighted, not uniform: a roster where a fifth of the
|
||||
// team is absent every day would be a business in crisis, not a normal
|
||||
// Tuesday. Most people are present.
|
||||
const roll = rng.next();
|
||||
const attendance: Attendance =
|
||||
store.status === 'closed'
|
||||
? 'absent'
|
||||
: roll < 0.72
|
||||
? 'present'
|
||||
: roll < 0.85
|
||||
? 'late'
|
||||
: roll < 0.94
|
||||
? 'absent'
|
||||
: 'leave';
|
||||
|
||||
// Sales track the store's own traffic, so a flagship associate
|
||||
// outsells a quiet-branch associate — which is what makes the
|
||||
// leaderboard mean anything.
|
||||
const salesCount = Math.round(
|
||||
rng.int(18, 64) * store.scale * (RANGE_DAYS[range] / 30),
|
||||
);
|
||||
const punctualityPct = Number(rng.float(72, 99).toFixed(1));
|
||||
|
||||
out.push({
|
||||
id: `${store.id}-${i}`,
|
||||
name: `${first} ${last}`,
|
||||
avatarUrl: null,
|
||||
storeId: store.id,
|
||||
role: i === 0 ? ROLES[0] : ROLES[1 + (i % (ROLES.length - 1))],
|
||||
attendance,
|
||||
salesCount,
|
||||
rewardsIssued: Math.round(salesCount * rng.float(0.3, 0.7)),
|
||||
// Weighted toward sales, then punctuality — a scorecard that is
|
||||
// mostly attendance rewards showing up over selling.
|
||||
performanceScore: Math.round(
|
||||
Math.min(
|
||||
100,
|
||||
(salesCount / (store.scale * 2)) * 0.7 + punctualityPct * 0.3,
|
||||
),
|
||||
),
|
||||
punctualityPct,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildStaffSummary(
|
||||
storeId: string,
|
||||
range: RangeKey,
|
||||
): StaffSummary {
|
||||
const staff = buildStaff(storeId, range);
|
||||
const count = (a: Attendance) =>
|
||||
staff.filter((s) => s.attendance === a).length;
|
||||
|
||||
return {
|
||||
present: count('present'),
|
||||
absent: count('absent'),
|
||||
late: count('late'),
|
||||
onLeave: count('leave'),
|
||||
total: staff.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Daily attendance split for the stacked chart. */
|
||||
export function buildAttendance(
|
||||
storeId: string,
|
||||
range: RangeKey,
|
||||
endMs: number,
|
||||
): AttendancePoint[] {
|
||||
const days = RANGE_DAYS[range];
|
||||
const total = buildStaff(storeId, range).length;
|
||||
const rng = createRng('attendance', storeId, range);
|
||||
|
||||
return Array.from({length: days}, (_, i) => {
|
||||
const date = new Date(endMs - (days - 1 - i) * 86400000);
|
||||
const dow = (date.getUTCDay() + 6) % 7;
|
||||
// Weekends run leaner rosters, and Mondays carry more late arrivals.
|
||||
const rostered = Math.round(total * (dow >= 5 ? 0.72 : 0.94));
|
||||
const late = Math.round(rostered * (dow === 0 ? 0.14 : 0.07) * rng.float(0.6, 1.4));
|
||||
const absent = Math.round(rostered * 0.06 * rng.float(0.4, 1.6));
|
||||
|
||||
return {
|
||||
t: date.toISOString().slice(0, 10),
|
||||
present: Math.max(0, rostered - late - absent),
|
||||
late,
|
||||
absent,
|
||||
};
|
||||
});
|
||||
}
|
||||
19
src/features/staff/repositories/staffRepository.ts
Normal file
19
src/features/staff/repositories/staffRepository.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {scopedEndpoint} from '@/shared/services/httpClient';
|
||||
import type {Endpoint, Scope} from '@/shared/services/httpClient';
|
||||
import type {
|
||||
AttendancePoint,
|
||||
StaffMember,
|
||||
StaffSummary,
|
||||
} from '@/features/staff/types/staff';
|
||||
|
||||
/** Staff endpoints. See dashboardRepository for the layer's contract. */
|
||||
export const staffRepository = {
|
||||
list: (scope: Scope): Endpoint<StaffMember[]> =>
|
||||
scopedEndpoint('/api/staff', scope),
|
||||
|
||||
summary: (scope: Scope): Endpoint<StaffSummary> =>
|
||||
scopedEndpoint('/api/staff/summary', scope),
|
||||
|
||||
attendance: (scope: Scope): Endpoint<AttendancePoint[]> =>
|
||||
scopedEndpoint('/api/staff/attendance', scope),
|
||||
};
|
||||
60
src/features/staff/services/staffService.ts
Normal file
60
src/features/staff/services/staffService.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type {Attendance, StaffMember} from '@/features/staff/types/staff';
|
||||
|
||||
/**
|
||||
* Domain rules for the staff module.
|
||||
*
|
||||
* These were inline in StaffGrid's useMemo. They are rules, not rendering:
|
||||
* "the roster is ordered by who is carrying the store" is a product decision
|
||||
* that a redesign of the grid must not be able to change by accident, and a
|
||||
* second surface (the leaderboard, an export, a mobile view) has to be able to
|
||||
* reuse it without copying a filter chain out of a component.
|
||||
*
|
||||
* Framework-free on purpose — no React, no Astryx — so it is unit-testable and
|
||||
* survives any change to how staff are displayed.
|
||||
*/
|
||||
|
||||
export type StaffFilter = Attendance | 'all';
|
||||
|
||||
export interface StaffQuery {
|
||||
filter: StaffFilter;
|
||||
/** Free text, matched against name and role. */
|
||||
search: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter, then rank.
|
||||
*
|
||||
* Best performers first: the roster's default question is "who is carrying
|
||||
* this store", not "who was added first". Ordering by score rather than name
|
||||
* is the single decision that makes the grid answer it without a sort control.
|
||||
*/
|
||||
export function selectStaff(
|
||||
staff: StaffMember[],
|
||||
{filter, search}: StaffQuery,
|
||||
): StaffMember[] {
|
||||
const q = search.trim().toLowerCase();
|
||||
return staff
|
||||
.filter(
|
||||
(m) =>
|
||||
(filter === 'all' || m.attendance === filter) &&
|
||||
(q === '' ||
|
||||
m.name.toLowerCase().includes(q) ||
|
||||
m.role.toLowerCase().includes(q)),
|
||||
)
|
||||
.sort((a, b) => b.performanceScore - a.performanceScore);
|
||||
}
|
||||
|
||||
/** How many people are in each attendance state. Drives the filter counts. */
|
||||
export function countByAttendance(
|
||||
staff: StaffMember[],
|
||||
): Record<StaffFilter, number> {
|
||||
const counts: Record<StaffFilter, number> = {
|
||||
all: staff.length,
|
||||
present: 0,
|
||||
late: 0,
|
||||
absent: 0,
|
||||
leave: 0,
|
||||
};
|
||||
for (const member of staff) counts[member.attendance] += 1;
|
||||
return counts;
|
||||
}
|
||||
45
src/features/staff/types/staff.ts
Normal file
45
src/features/staff/types/staff.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Staff, attendance and performance contracts.
|
||||
*
|
||||
* The wire shape for this feature. Imported by BOTH its route handlers and its
|
||||
* components, so a server/client drift is a type error rather than a runtime
|
||||
* surprise. When a real backend arrives, this file is the negotiation artifact.
|
||||
*/
|
||||
|
||||
export type Attendance = 'present' | 'absent' | 'late' | 'leave';
|
||||
|
||||
export interface StaffMember {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
/** Which store they are rostered at. */
|
||||
storeId: string;
|
||||
role: string;
|
||||
attendance: Attendance;
|
||||
salesCount: number;
|
||||
rewardsIssued: number;
|
||||
/** 0–100. */
|
||||
performanceScore: number;
|
||||
/** Share of shifts attended on time, over the selected period. */
|
||||
punctualityPct: number;
|
||||
}
|
||||
|
||||
export interface StaffSummary {
|
||||
present: number;
|
||||
absent: number;
|
||||
late: number;
|
||||
onLeave: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AttendancePoint {
|
||||
t: string;
|
||||
present: number;
|
||||
late: number;
|
||||
absent: number;
|
||||
}
|
||||
|
||||
export interface StaffSalesPoint {
|
||||
t: string;
|
||||
sales: number;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {Attendance} from '@/lib/api/contracts';
|
||||
import type {Attendance} from '@/features/staff/types/staff';
|
||||
import type {StatusDotVariant} from '@astryxdesign/core/StatusDot';
|
||||
|
||||
/**
|
||||
Reference in New Issue
Block a user