refactor: add hydrogen customer drilldown
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Building2, ChevronRight, Fuel, Plug, TrendingUp, Truck, X } from 'lucide-react';
|
import { Building2, ChevronRight, Fuel, Plug, TrendingUp, Truck, UserRound, X } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
||||||
import TrendBadge from './TrendBadge';
|
import TrendBadge from './TrendBadge';
|
||||||
@@ -70,6 +70,9 @@ export default function HydrogenDaily() {
|
|||||||
const selectedStationName = drillContext.level === 'station'
|
const selectedStationName = drillContext.level === 'station'
|
||||||
? drillContext.stationName || `站点 #${drillContext.stationId}`
|
? drillContext.stationName || `站点 #${drillContext.stationId}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const selectedCustomerName = drillContext.level === 'customer'
|
||||||
|
? drillContext.customerName
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const commitDrillContext = useCallback((next: HydrogenDrillContext, mode: 'push' | 'replace') => {
|
const commitDrillContext = useCallback((next: HydrogenDrillContext, mode: 'push' | 'replace') => {
|
||||||
const url = buildHydrogenDrillUrl(window.location, next);
|
const url = buildHydrogenDrillUrl(window.location, next);
|
||||||
@@ -81,13 +84,13 @@ export default function HydrogenDaily() {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setError(null);
|
setError(null);
|
||||||
const query = pick === 'custom'
|
const query = pick === 'custom'
|
||||||
? { startDate: effectiveRange.start, endDate: effectiveRange.end, stationId: selectedStationId }
|
? { startDate: effectiveRange.start, endDate: effectiveRange.end, stationId: selectedStationId, customerName: selectedCustomerName }
|
||||||
: { range: pick, stationId: selectedStationId };
|
: { range: pick, stationId: selectedStationId, customerName: selectedCustomerName };
|
||||||
fetchHydrogenDaily(query, customer)
|
fetchHydrogenDaily(query, customer)
|
||||||
.then(r => { if (!cancelled) setRows(r); })
|
.then(r => { if (!cancelled) setRows(r); })
|
||||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [pick, customer, effectiveRange.start, effectiveRange.end, selectedStationId]);
|
}, [pick, customer, effectiveRange.start, effectiveRange.end, selectedStationId, selectedCustomerName]);
|
||||||
|
|
||||||
// 柱图:按日期升序,用于"从左到右时间流"
|
// 柱图:按日期升序,用于"从左到右时间流"
|
||||||
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
|
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
|
||||||
@@ -151,7 +154,7 @@ export default function HydrogenDaily() {
|
|||||||
}, 'replace');
|
}, 'replace');
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearStation = () => {
|
const clearEntity = () => {
|
||||||
commitDrillContext({
|
commitDrillContext({
|
||||||
level: 'overview',
|
level: 'overview',
|
||||||
year: drillContext.year,
|
year: drillContext.year,
|
||||||
@@ -252,7 +255,7 @@ export default function HydrogenDaily() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clearStation}
|
onClick={clearEntity}
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white text-blue-500 shadow-sm ring-1 ring-blue-100 hover:text-blue-700"
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white text-blue-500 shadow-sm ring-1 ring-blue-100 hover:text-blue-700"
|
||||||
aria-label="清除站点筛选"
|
aria-label="清除站点筛选"
|
||||||
title="清除站点筛选"
|
title="清除站点筛选"
|
||||||
@@ -262,6 +265,25 @@ export default function HydrogenDaily() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedCustomerName && (
|
||||||
|
<section className="flex items-center gap-3 rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-emerald-700 shadow-sm">
|
||||||
|
<UserRound size={16} className="shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-xs font-black">{selectedCustomerName}</div>
|
||||||
|
<div className="mt-0.5 text-[10px] font-bold text-emerald-600">客户每日明细 · {rangeText}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearEntity}
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white text-emerald-600 shadow-sm ring-1 ring-emerald-100 hover:text-emerald-800"
|
||||||
|
aria-label="清除客户筛选"
|
||||||
|
title="清除客户筛选"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
|
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
|
||||||
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
|
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw, Gauge, AlertTriangle, Building2, ChevronRight } from 'lucide-react';
|
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw, Gauge, AlertTriangle, Building2, ChevronRight, UserRound } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
@@ -176,6 +176,25 @@ export default function HydrogenOverview() {
|
|||||||
window.history.pushState(null, '', url);
|
window.history.pushState(null, '', url);
|
||||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||||
};
|
};
|
||||||
|
const openCustomer = (customer: typeof customers[number]) => {
|
||||||
|
const now = new Date();
|
||||||
|
const endDate = activeYear === now.getFullYear()
|
||||||
|
? `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||||
|
: `${activeYear}-12-31`;
|
||||||
|
const url = buildHydrogenDrillUrl(
|
||||||
|
{ pathname: window.location.pathname, search: window.location.search, hash: '#hydrogen' },
|
||||||
|
{
|
||||||
|
level: 'customer',
|
||||||
|
year: activeYear,
|
||||||
|
customerName: customer.name,
|
||||||
|
vehicleScope: 'lingniu',
|
||||||
|
startDate: `${activeYear}-01-01`,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
window.history.pushState(null, '', url);
|
||||||
|
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||||
|
};
|
||||||
|
|
||||||
// 月度收支组合数据(推算"年内每月"图)
|
// 月度收支组合数据(推算"年内每月"图)
|
||||||
const monthlyDual = monthly.map(m => ({
|
const monthlyDual = monthly.map(m => ({
|
||||||
@@ -587,7 +606,18 @@ export default function HydrogenOverview() {
|
|||||||
return (
|
return (
|
||||||
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
||||||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
||||||
<td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
|
<td className="py-1.5 max-w-[200px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openCustomer(c2)}
|
||||||
|
className="flex max-w-full items-center gap-1 text-left font-bold text-slate-700 hover:text-blue-600"
|
||||||
|
title={`查看 ${c2.name} 每日明细`}
|
||||||
|
>
|
||||||
|
<UserRound size={12} className="shrink-0 text-slate-300" />
|
||||||
|
<span className="truncate">{c2.name}</span>
|
||||||
|
<ChevronRight size={12} className="shrink-0 text-slate-300" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td className="py-1.5 text-center hidden sm:table-cell">
|
<td className="py-1.5 text-center hidden sm:table-cell">
|
||||||
{c2.payer === 'lingniu' ? (
|
{c2.payer === 'lingniu' ? (
|
||||||
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold">羚牛</span>
|
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold">羚牛</span>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface HydrogenDailyQuery {
|
|||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
stationId?: number;
|
stationId?: number;
|
||||||
|
customerName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||||
@@ -40,6 +41,7 @@ export function fetchHydrogenDaily(query: HydrogenDailyQuery, customer: Customer
|
|||||||
if (query.startDate) q.set('startDate', query.startDate);
|
if (query.startDate) q.set('startDate', query.startDate);
|
||||||
if (query.endDate) q.set('endDate', query.endDate);
|
if (query.endDate) q.set('endDate', query.endDate);
|
||||||
if (query.stationId !== undefined) q.set('stationId', String(query.stationId));
|
if (query.stationId !== undefined) q.set('stationId', String(query.stationId));
|
||||||
|
if (query.customerName) q.set('customerName', query.customerName);
|
||||||
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
|
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,31 @@ test('parses a station drill context including station zero', () => {
|
|||||||
test('requires a valid entity for station and customer levels', () => {
|
test('requires a valid entity for station and customer levels', () => {
|
||||||
assert.equal(parseHydrogenDrillContext('?hydrogenLevel=station&hydrogenStation=-1').level, 'overview');
|
assert.equal(parseHydrogenDrillContext('?hydrogenLevel=station&hydrogenStation=-1').level, 'overview');
|
||||||
assert.equal(parseHydrogenDrillContext('?hydrogenLevel=customer').level, 'overview');
|
assert.equal(parseHydrogenDrillContext('?hydrogenLevel=customer').level, 'overview');
|
||||||
|
assert.equal(
|
||||||
|
parseHydrogenDrillContext(`?hydrogenLevel=customer&hydrogenCustomer=${'x'.repeat(129)}`).level,
|
||||||
|
'overview',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses and builds a customer drill context', () => {
|
||||||
|
const context = parseHydrogenDrillContext(
|
||||||
|
'?hydrogenLevel=customer&hydrogenCustomer=%E6%AD%A6%E6%B1%89%E5%AE%A2%E6%88%B7&hydrogenScope=lingniu&hydrogenStart=2026-01-01&hydrogenEnd=2026-08-07',
|
||||||
|
);
|
||||||
|
assert.deepEqual(context, {
|
||||||
|
level: 'customer',
|
||||||
|
customerName: '武汉客户',
|
||||||
|
vehicleScope: 'lingniu',
|
||||||
|
startDate: '2026-01-01',
|
||||||
|
endDate: '2026-08-07',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
buildHydrogenDrillUrl(
|
||||||
|
{ pathname: '/energy', search: '', hash: '#hydrogen' },
|
||||||
|
{ ...context, year: 2026 },
|
||||||
|
),
|
||||||
|
'/energy?hydrogenLevel=customer&hydrogenYear=2026&hydrogenCustomer=%E6%AD%A6%E6%B1%89%E5%AE%A2%E6%88%B7&hydrogenScope=lingniu&hydrogenStart=2026-01-01&hydrogenEnd=2026-08-07#hydrogen',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('builds a namespaced station URL and preserves unrelated parameters', () => {
|
test('builds a namespaced station URL and preserves unrelated parameters', () => {
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ export function parseHydrogenDrillContext(search: string): HydrogenDrillContext
|
|||||||
const requestedLevel = params.get('hydrogenLevel');
|
const requestedLevel = params.get('hydrogenLevel');
|
||||||
const parsedStationId = stationId(params.get('hydrogenStation'));
|
const parsedStationId = stationId(params.get('hydrogenStation'));
|
||||||
const parsedStationName = params.get('hydrogenStationName')?.trim() || undefined;
|
const parsedStationName = params.get('hydrogenStationName')?.trim() || undefined;
|
||||||
const customerName = params.get('hydrogenCustomer')?.trim() || undefined;
|
const rawCustomerName = params.get('hydrogenCustomer')?.trim();
|
||||||
|
const customerName = rawCustomerName && rawCustomerName.length <= 128
|
||||||
|
? rawCustomerName
|
||||||
|
: undefined;
|
||||||
const context: HydrogenDrillContext = {
|
const context: HydrogenDrillContext = {
|
||||||
level: requestedLevel === 'station' && parsedStationId !== undefined
|
level: requestedLevel === 'station' && parsedStationId !== undefined
|
||||||
? 'station'
|
? 'station'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import hydrogenPool from '../../hydrogen-db.js';
|
|||||||
import { cached } from './cache.js';
|
import { cached } from './cache.js';
|
||||||
import {
|
import {
|
||||||
parseHydrogenCustomerKind,
|
parseHydrogenCustomerKind,
|
||||||
|
parseHydrogenCustomerName,
|
||||||
parseHydrogenStationId,
|
parseHydrogenStationId,
|
||||||
type HydrogenCustomerKind,
|
type HydrogenCustomerKind,
|
||||||
} from './query.js';
|
} from './query.js';
|
||||||
@@ -370,16 +371,25 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
// 氢能 每日:日期范围 + 客户类型 + 站点级下钻
|
// 氢能 每日:日期范围 + 车辆归属 + 站点/客户下钻
|
||||||
// =========================================================
|
// =========================================================
|
||||||
app.get('/hydrogen/daily', async (c) => {
|
app.get('/hydrogen/daily', async (c) => {
|
||||||
const range = (c.req.query('range') || 'last15') as Range;
|
const range = (c.req.query('range') || 'last15') as Range;
|
||||||
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
|
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
|
||||||
const customer = parseHydrogenCustomerKind(c.req.query('customer'));
|
const customer = parseHydrogenCustomerKind(c.req.query('customer'));
|
||||||
const stationId = parseHydrogenStationId(c.req.query('stationId'));
|
const stationIdParam = c.req.query('stationId');
|
||||||
|
const customerNameParam = c.req.query('customerName');
|
||||||
|
const stationId = parseHydrogenStationId(stationIdParam);
|
||||||
|
const customerName = parseHydrogenCustomerName(customerNameParam);
|
||||||
|
if (stationIdParam !== undefined && stationId === null) {
|
||||||
|
return c.json({ error: 'stationId 必须是非负整数' }, 400);
|
||||||
|
}
|
||||||
|
if (customerNameParam !== undefined && customerName === null) {
|
||||||
|
return c.json({ error: 'customerName 必须是 1-128 个字符' }, 400);
|
||||||
|
}
|
||||||
const force = c.req.query('force') === '1';
|
const force = c.req.query('force') === '1';
|
||||||
|
|
||||||
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}&station=${stationId ?? 'all'}`, async () => {
|
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}&station=${stationId ?? 'all'}&customerName=${encodeURIComponent(customerName ?? 'all')}`, async () => {
|
||||||
|
|
||||||
const whereParts = [
|
const whereParts = [
|
||||||
HYDROGEN_BASE_WHERE_B,
|
HYDROGEN_BASE_WHERE_B,
|
||||||
@@ -392,6 +402,10 @@ app.get('/hydrogen/daily', async (c) => {
|
|||||||
whereParts.push('COALESCE(b.station_id, 0) = ?');
|
whereParts.push('COALESCE(b.station_id, 0) = ?');
|
||||||
whereParams.push(stationId);
|
whereParams.push(stationId);
|
||||||
}
|
}
|
||||||
|
if (customerName !== null) {
|
||||||
|
whereParts.push("COALESCE(NULLIF(TRIM(b.customer_name), ''), '未指定客户') = ?");
|
||||||
|
whereParams.push(customerName);
|
||||||
|
}
|
||||||
const where = whereParts.join(' AND ');
|
const where = whereParts.join(' AND ');
|
||||||
|
|
||||||
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import app from './index.js';
|
||||||
import {
|
import {
|
||||||
parseHydrogenCustomerKind,
|
parseHydrogenCustomerKind,
|
||||||
|
parseHydrogenCustomerName,
|
||||||
parseHydrogenStationId,
|
parseHydrogenStationId,
|
||||||
} from './query.js';
|
} from './query.js';
|
||||||
|
|
||||||
@@ -19,3 +21,21 @@ test('defaults unknown customer scopes to lingniu', () => {
|
|||||||
assert.equal(parseHydrogenCustomerKind('invalid'), 'lingniu');
|
assert.equal(parseHydrogenCustomerKind('invalid'), 'lingniu');
|
||||||
assert.equal(parseHydrogenCustomerKind(undefined), 'lingniu');
|
assert.equal(parseHydrogenCustomerKind(undefined), 'lingniu');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizes bounded customer names for exact matching', () => {
|
||||||
|
assert.equal(parseHydrogenCustomerName(' 武汉客户 '), '武汉客户');
|
||||||
|
assert.equal(parseHydrogenCustomerName('未指定客户'), '未指定客户');
|
||||||
|
assert.equal(parseHydrogenCustomerName(' '), null);
|
||||||
|
assert.equal(parseHydrogenCustomerName('x'.repeat(129)), null);
|
||||||
|
assert.equal(parseHydrogenCustomerName(undefined), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid entity filters before querying hydrogen data', async () => {
|
||||||
|
const invalidStation = await app.request('/hydrogen/daily?stationId=-1');
|
||||||
|
assert.equal(invalidStation.status, 400);
|
||||||
|
assert.deepEqual(await invalidStation.json(), { error: 'stationId 必须是非负整数' });
|
||||||
|
|
||||||
|
const invalidCustomer = await app.request(`/hydrogen/daily?customerName=${'x'.repeat(129)}`);
|
||||||
|
assert.equal(invalidCustomer.status, 400);
|
||||||
|
assert.deepEqual(await invalidCustomer.json(), { error: 'customerName 必须是 1-128 个字符' });
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,3 +10,9 @@ export function parseHydrogenStationId(value: string | undefined): number | null
|
|||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseHydrogenCustomerName(value: string | undefined): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const normalized = value.trim();
|
||||||
|
return normalized.length > 0 && normalized.length <= 128 ? normalized : null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user