Add hourly detail charts for single-day ranges
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
This commit is contained in:
@@ -414,6 +414,8 @@ const getDateOnly = (value) => {
|
|||||||
return match ? match[1] : null;
|
return match ? match[1] : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSingleDayRange = (start, end) => Boolean(start && end && start === end);
|
||||||
|
|
||||||
const WEEKDAY_LABELS = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab'];
|
const WEEKDAY_LABELS = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab'];
|
||||||
|
|
||||||
const getWeekdayIndex = (value) => {
|
const getWeekdayIndex = (value) => {
|
||||||
@@ -751,6 +753,7 @@ const getProductDetailsAnalytics = async (productId, range = {}) => {
|
|||||||
|
|
||||||
const normalizedStart = normalizeDateParam(range.start);
|
const normalizedStart = normalizeDateParam(range.start);
|
||||||
const normalizedEnd = normalizeDateParam(range.end);
|
const normalizedEnd = normalizeDateParam(range.end);
|
||||||
|
const useHourlyChart = isSingleDayRange(normalizedStart, normalizedEnd);
|
||||||
const periodParams = [normalizedProductId];
|
const periodParams = [normalizedProductId];
|
||||||
const periodFilters = [
|
const periodFilters = [
|
||||||
'produto_id = $1',
|
'produto_id = $1',
|
||||||
@@ -798,7 +801,16 @@ const getProductDetailsAnalytics = async (productId, range = {}) => {
|
|||||||
LEFT JOIN order_info ON order_info.id = selected_product.id
|
LEFT JOIN order_info ON order_info.id = selected_product.id
|
||||||
WHERE stock_info.id IS NOT NULL OR order_info.id IS NOT NULL;
|
WHERE stock_info.id IS NOT NULL OR order_info.id IS NOT NULL;
|
||||||
`, [normalizedProductId]),
|
`, [normalizedProductId]),
|
||||||
pool.query(`
|
pool.query(useHourlyChart ? `
|
||||||
|
SELECT
|
||||||
|
EXTRACT(HOUR FROM created_at AT TIME ZONE 'America/Sao_Paulo')::int as hour,
|
||||||
|
COALESCE(SUM(quantidade), 0) as quantity_sold,
|
||||||
|
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue
|
||||||
|
FROM orders
|
||||||
|
WHERE ${periodFilters.join(' AND ')}
|
||||||
|
GROUP BY hour
|
||||||
|
ORDER BY hour ASC;
|
||||||
|
` : `
|
||||||
SELECT
|
SELECT
|
||||||
data_pedido_date,
|
data_pedido_date,
|
||||||
MAX(data_pedido) as date_label,
|
MAX(data_pedido) as date_label,
|
||||||
@@ -814,7 +826,15 @@ const getProductDetailsAnalytics = async (productId, range = {}) => {
|
|||||||
const summary = summaryResult.rows[0];
|
const summary = summaryResult.rows[0];
|
||||||
if (!summary) return null;
|
if (!summary) return null;
|
||||||
|
|
||||||
const chartData = periodResult.rows.map(row => ({
|
const chartData = useHourlyChart
|
||||||
|
? Array.from({ length: 24 }, (_, hour) => {
|
||||||
|
const row = periodResult.rows.find(item => toNumber(item.hour) === hour);
|
||||||
|
return {
|
||||||
|
date: `${String(hour).padStart(2, '0')}h`,
|
||||||
|
value: row ? toNumber(row.quantity_sold) : 0
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: periodResult.rows.map(row => ({
|
||||||
date: row.date_label || getDateOnly(row.data_pedido_date) || '',
|
date: row.date_label || getDateOnly(row.data_pedido_date) || '',
|
||||||
value: toNumber(row.quantity_sold)
|
value: toNumber(row.quantity_sold)
|
||||||
}));
|
}));
|
||||||
@@ -934,6 +954,7 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => {
|
|||||||
|
|
||||||
const normalizedStart = normalizeDateParam(range.start);
|
const normalizedStart = normalizeDateParam(range.start);
|
||||||
const normalizedEnd = normalizeDateParam(range.end);
|
const normalizedEnd = normalizeDateParam(range.end);
|
||||||
|
const useHourlyChart = isSingleDayRange(normalizedStart, normalizedEnd);
|
||||||
const periodParams = [resolvedCustomerKey];
|
const periodParams = [resolvedCustomerKey];
|
||||||
const periodFilters = [
|
const periodFilters = [
|
||||||
`${CUSTOMER_KEY_SQL} = $1`,
|
`${CUSTOMER_KEY_SQL} = $1`,
|
||||||
@@ -1006,6 +1027,10 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => {
|
|||||||
|
|
||||||
const groupedOrdersByKey = new Map();
|
const groupedOrdersByKey = new Map();
|
||||||
const spentByDate = new Map();
|
const spentByDate = new Map();
|
||||||
|
const spentByHour = Array.from({ length: 24 }, (_, hour) => ({
|
||||||
|
date: `${String(hour).padStart(2, '0')}h`,
|
||||||
|
value: 0
|
||||||
|
}));
|
||||||
const weekdayCounts = WEEKDAY_LABELS.map(label => ({ label, value: 0 }));
|
const weekdayCounts = WEEKDAY_LABELS.map(label => ({ label, value: 0 }));
|
||||||
const hourCounts = Array.from({ length: 24 }, (_, hour) => ({
|
const hourCounts = Array.from({ length: 24 }, (_, hour) => ({
|
||||||
label: `${String(hour).padStart(2, '0')}h`,
|
label: `${String(hour).padStart(2, '0')}h`,
|
||||||
@@ -1047,6 +1072,13 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => {
|
|||||||
spentByDate.set(dateLabel, currentDateSpend);
|
spentByDate.set(dateLabel, currentDateSpend);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (useHourlyChart) {
|
||||||
|
const orderHour = getHourFromTimestamp(row.created_at) ?? getHourFromTimestamp(row.data_pedido);
|
||||||
|
if (orderHour !== null) {
|
||||||
|
spentByHour[orderHour].value += itemRevenue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!groupedOrdersByKey.has(groupKey)) {
|
if (!groupedOrdersByKey.has(groupKey)) {
|
||||||
groupedOrdersByKey.set(groupKey, {
|
groupedOrdersByKey.set(groupKey, {
|
||||||
date: dateLabel,
|
date: dateLabel,
|
||||||
@@ -1082,7 +1114,9 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => {
|
|||||||
const groupedOrders = [...groupedOrdersByKey.values()]
|
const groupedOrders = [...groupedOrdersByKey.values()]
|
||||||
.sort((a, b) => String(b.sortDate).localeCompare(String(a.sortDate)))
|
.sort((a, b) => String(b.sortDate).localeCompare(String(a.sortDate)))
|
||||||
.map(({ sortDate, ...group }) => group);
|
.map(({ sortDate, ...group }) => group);
|
||||||
const chartData = [...spentByDate.values()]
|
const chartData = useHourlyChart
|
||||||
|
? spentByHour
|
||||||
|
: [...spentByDate.values()]
|
||||||
.sort((a, b) => String(a.sortDate).localeCompare(String(b.sortDate)))
|
.sort((a, b) => String(a.sortDate).localeCompare(String(b.sortDate)))
|
||||||
.map(({ sortDate, ...entry }) => entry);
|
.map(({ sortDate, ...entry }) => entry);
|
||||||
const periodOrderCount = groupedOrders.length;
|
const periodOrderCount = groupedOrders.length;
|
||||||
|
|||||||
@@ -604,6 +604,42 @@ test('getProductDetailsAnalytics keeps known products visible with zero period s
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getProductDetailsAnalytics groups single-day chart by hour', async () => {
|
||||||
|
const originalQuery = pool.query;
|
||||||
|
|
||||||
|
pool.query = async (sql) => {
|
||||||
|
if (sql.includes('selected_product AS')) {
|
||||||
|
return {
|
||||||
|
rows: [{
|
||||||
|
id: '919483307',
|
||||||
|
name: 'Produto com venda por hora',
|
||||||
|
price: 11.9
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.match(sql, /EXTRACT\(HOUR FROM created_at AT TIME ZONE 'America\/Sao_Paulo'\)::int as hour/);
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{ hour: 9, quantity_sold: 2, revenue: 23.8 },
|
||||||
|
{ hour: 18, quantity_sold: 3, revenue: 35.7 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const details = await getProductDetailsAnalytics('919483307', { start: '2026-06-22', end: '2026-06-22' });
|
||||||
|
|
||||||
|
assert.equal(details.chartData.length, 24);
|
||||||
|
assert.deepEqual(details.chartData[9], { date: '09h', value: 2 });
|
||||||
|
assert.deepEqual(details.chartData[18], { date: '18h', value: 3 });
|
||||||
|
assert.equal(details.totalSold, 5);
|
||||||
|
assert.equal(details.totalRevenue, 59.5);
|
||||||
|
} finally {
|
||||||
|
pool.query = originalQuery;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('getClientAnalytics returns opaque client tokens', async () => {
|
test('getClientAnalytics returns opaque client tokens', async () => {
|
||||||
const originalQuery = pool.query;
|
const originalQuery = pool.query;
|
||||||
const calls = [];
|
const calls = [];
|
||||||
@@ -845,6 +881,74 @@ test('getClientDetailsAnalytics resolves legacy name tokens to the canonical pho
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getClientDetailsAnalytics groups single-day spend chart by hour', async () => {
|
||||||
|
const originalQuery = pool.query;
|
||||||
|
const clientToken = createClientToken('(16) 99999-9999');
|
||||||
|
|
||||||
|
pool.query = async (sql, params = []) => {
|
||||||
|
if (sql.includes('FROM client_identity_tokens')) {
|
||||||
|
return { rows: [{ customer_key: '(16) 99999-9999' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes('SELECT customer_key') && sql.includes('FROM identity_orders')) {
|
||||||
|
return { rows: [{ customer_key: '(16) 99999-9999' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes('all_time_order_count')) {
|
||||||
|
return {
|
||||||
|
rows: [{
|
||||||
|
name: 'Cliente Teste',
|
||||||
|
phone: '(16) 99999-9999',
|
||||||
|
all_time_order_count: 2
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(params[0], '(16) 99999-9999');
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
cliente_nome: 'Cliente Teste',
|
||||||
|
cliente_fone: '(16) 99999-9999',
|
||||||
|
data_pedido: '22-06-2026',
|
||||||
|
data_pedido_date: '2026-06-22',
|
||||||
|
valor_pedido: 20,
|
||||||
|
produto_id: 'produto-1',
|
||||||
|
produto_descricao: 'Produto A',
|
||||||
|
quantidade: 2,
|
||||||
|
valor_unitario: 10,
|
||||||
|
pedido_id: 'pedido-1',
|
||||||
|
created_at: '2026-06-22T09:15:00.000-03:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cliente_nome: 'Cliente Teste',
|
||||||
|
cliente_fone: '(16) 99999-9999',
|
||||||
|
data_pedido: '22-06-2026',
|
||||||
|
data_pedido_date: '2026-06-22',
|
||||||
|
valor_pedido: 30,
|
||||||
|
produto_id: 'produto-2',
|
||||||
|
produto_descricao: 'Produto B',
|
||||||
|
quantidade: 1,
|
||||||
|
valor_unitario: 30,
|
||||||
|
pedido_id: 'pedido-2',
|
||||||
|
created_at: '2026-06-22T18:30:00.000-03:00'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const details = await getClientDetailsAnalytics(clientToken, { start: '2026-06-22', end: '2026-06-22' });
|
||||||
|
|
||||||
|
assert.equal(details.chartData.length, 24);
|
||||||
|
assert.deepEqual(details.chartData[9], { date: '09h', value: 20 });
|
||||||
|
assert.deepEqual(details.chartData[18], { date: '18h', value: 30 });
|
||||||
|
assert.equal(details.periodSpent, 50);
|
||||||
|
} finally {
|
||||||
|
pool.query = originalQuery;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('getClientDetailsAnalytics rejects invalid client tokens before querying', async () => {
|
test('getClientDetailsAnalytics rejects invalid client tokens before querying', async () => {
|
||||||
const originalQuery = pool.query;
|
const originalQuery = pool.query;
|
||||||
pool.query = async () => {
|
pool.query = async () => {
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ const CHART_DETAIL_BAR_COLOR = 'var(--chart-detail-bar)';
|
|||||||
const WEEKDAY_BAR_COLOR = '#25C2FF';
|
const WEEKDAY_BAR_COLOR = '#25C2FF';
|
||||||
const HOUR_BAR_COLOR = '#52DFA0';
|
const HOUR_BAR_COLOR = '#52DFA0';
|
||||||
|
|
||||||
|
const formatDateKey = (date: Date) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
type CustomTooltipProps = {
|
type CustomTooltipProps = {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
payload?: Array<{ value: number }>;
|
payload?: Array<{ value: number }>;
|
||||||
@@ -239,6 +246,7 @@ const ClientDetails = () => {
|
|||||||
const hasWeekdayPattern = purchaseWeekdays.some(day => day.value > 0);
|
const hasWeekdayPattern = purchaseWeekdays.some(day => day.value > 0);
|
||||||
const hasHourPattern = purchaseHours.some(hour => hour.value > 0);
|
const hasHourPattern = purchaseHours.some(hour => hour.value > 0);
|
||||||
const isRefreshing = isLoading && Boolean(details);
|
const isRefreshing = isLoading && Boolean(details);
|
||||||
|
const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -323,7 +331,9 @@ const ClientDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
||||||
<h3 className="text-lg font-bold mb-8 text-zinc-900 dark:text-dark-text">Gasto por Data</h3>
|
<h3 className="text-lg font-bold mb-8 text-zinc-900 dark:text-dark-text">
|
||||||
|
Gasto por {isSingleDayRange ? 'Horário' : 'Data'}
|
||||||
|
</h3>
|
||||||
{chartData.length === 0 ? (
|
{chartData.length === 0 ? (
|
||||||
<div className="flex h-[320px] items-center justify-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">
|
<div className="flex h-[320px] items-center justify-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">
|
||||||
Nenhum gasto no período selecionado.
|
Nenhum gasto no período selecionado.
|
||||||
@@ -331,7 +341,7 @@ const ClientDetails = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="h-[320px] w-full">
|
<div className="h-[320px] w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 80 }}>
|
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: isSingleDayRange ? 24 : 80 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="clientSpendGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="clientSpendGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={CHART_DETAIL_BAR_COLOR} stopOpacity={0.38} />
|
<stop offset="5%" stopColor={CHART_DETAIL_BAR_COLOR} stopOpacity={0.38} />
|
||||||
@@ -345,10 +355,10 @@ const ClientDetails = () => {
|
|||||||
fontSize={10}
|
fontSize={10}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
interval={0}
|
interval={isSingleDayRange ? 2 : 0}
|
||||||
angle={-45}
|
angle={isSingleDayRange ? 0 : -45}
|
||||||
textAnchor="end"
|
textAnchor={isSingleDayRange ? 'middle' : 'end'}
|
||||||
height={80}
|
height={isSingleDayRange ? 24 : 80}
|
||||||
/>
|
/>
|
||||||
<YAxis stroke={CHART_AXIS_COLOR} fontSize={12} tickLine={false} axisLine={false} tickFormatter={(value) => formatCurrency(Number(value))} />
|
<YAxis stroke={CHART_AXIS_COLOR} fontSize={12} tickLine={false} axisLine={false} tickFormatter={(value) => formatCurrency(Number(value))} />
|
||||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ const CHART_AXIS_COLOR = 'var(--chart-axis)';
|
|||||||
const CHART_CURSOR_COLOR = 'var(--chart-cursor)';
|
const CHART_CURSOR_COLOR = 'var(--chart-cursor)';
|
||||||
const CHART_DETAIL_BAR_COLOR = 'var(--chart-detail-bar)';
|
const CHART_DETAIL_BAR_COLOR = 'var(--chart-detail-bar)';
|
||||||
|
|
||||||
|
const formatDateKey = (date: Date) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
type CustomTooltipProps = {
|
type CustomTooltipProps = {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
payload?: Array<{ value: number }>;
|
payload?: Array<{ value: number }>;
|
||||||
@@ -126,6 +133,7 @@ const ProductDetails = () => {
|
|||||||
|
|
||||||
const { productInfo, chartData, totalSold, totalRevenue } = details;
|
const { productInfo, chartData, totalSold, totalRevenue } = details;
|
||||||
const isRefreshing = isLoading && Boolean(details);
|
const isRefreshing = isLoading && Boolean(details);
|
||||||
|
const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -177,10 +185,12 @@ const ProductDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
||||||
<h3 className="text-lg font-bold mb-8 text-zinc-900 dark:text-dark-text">Volume de Vendas por Data</h3>
|
<h3 className="text-lg font-bold mb-8 text-zinc-900 dark:text-dark-text">
|
||||||
|
Volume de Vendas por {isSingleDayRange ? 'Horário' : 'Data'}
|
||||||
|
</h3>
|
||||||
<div className="h-[400px] w-full">
|
<div className="h-[400px] w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 80 }}>
|
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: isSingleDayRange ? 24 : 80 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="productVolumeGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="productVolumeGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={CHART_DETAIL_BAR_COLOR} stopOpacity={0.38} />
|
<stop offset="5%" stopColor={CHART_DETAIL_BAR_COLOR} stopOpacity={0.38} />
|
||||||
@@ -190,10 +200,10 @@ const ProductDetails = () => {
|
|||||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date" stroke={CHART_AXIS_COLOR} fontSize={10} tickLine={false} axisLine={false}
|
dataKey="date" stroke={CHART_AXIS_COLOR} fontSize={10} tickLine={false} axisLine={false}
|
||||||
interval={0}
|
interval={isSingleDayRange ? 2 : 0}
|
||||||
angle={-45}
|
angle={isSingleDayRange ? 0 : -45}
|
||||||
textAnchor="end"
|
textAnchor={isSingleDayRange ? 'middle' : 'end'}
|
||||||
height={80}
|
height={isSingleDayRange ? 24 : 80}
|
||||||
/>
|
/>
|
||||||
<YAxis stroke={CHART_AXIS_COLOR} fontSize={12} tickLine={false} axisLine={false} />
|
<YAxis stroke={CHART_AXIS_COLOR} fontSize={12} tickLine={false} axisLine={false} />
|
||||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
|
||||||
|
|||||||
Reference in New Issue
Block a user