Expand statistics dashboard and fix activity chart
Backend (/stats/managers): - sources: aggregated count and won per source value - types: inbound vs outbound totals, conversion %, won amount - industries: top-10 by lead count with won and pipeline - tasksPerManager: total, done, overdue, completion % - cycle: avg days from create to won and to lost Frontend (StatsView): - Activity bar chart rewritten in SVG; previous CSS percentage heights collapsed because the flex-col container had no fixed height - New widgets: - Источники (per-source horizontal bars) - Inbound vs Outbound with won/lost stacked bar - Цикл сделки (avg days to won / lost) - Топ отраслей (top-10 industries) - Задачи менеджеров (completion rate progress bars) - All loaded from the single /stats/managers call Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ba121a6bfc
commit
ef92acdd3f
@ -151,6 +151,108 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
return { date: `${y}-${m}-${day}`, count: Number(r.count) };
|
||||
});
|
||||
|
||||
return { managers, totals, funnel, forecast: Math.round(forecast), timeline };
|
||||
const bySourceRes = await query<{ source: string | null; lead_type: string; count: string; won: string }>(
|
||||
`SELECT source, lead_type, COUNT(*)::text AS count,
|
||||
COUNT(*) FILTER (WHERE deal_stage='won')::text AS won
|
||||
FROM leads
|
||||
GROUP BY source, lead_type`,
|
||||
);
|
||||
const sources = bySourceRes.rows.map((r) => ({
|
||||
source: r.source ?? 'n/a',
|
||||
lead_type: r.lead_type,
|
||||
count: Number(r.count),
|
||||
won: Number(r.won),
|
||||
}));
|
||||
|
||||
const byTypeRes = await query<{ lead_type: string; total: string; won: string; lost: string; amount: string }>(
|
||||
`SELECT lead_type,
|
||||
COUNT(*)::text AS total,
|
||||
COUNT(*) FILTER (WHERE deal_stage='won')::text AS won,
|
||||
COUNT(*) FILTER (WHERE deal_stage='lost')::text AS lost,
|
||||
COALESCE(SUM(expected_amount) FILTER (WHERE deal_stage='won'),0)::text AS amount
|
||||
FROM leads
|
||||
GROUP BY lead_type`,
|
||||
);
|
||||
const types = byTypeRes.rows.map((r) => {
|
||||
const won = Number(r.won);
|
||||
const lost = Number(r.lost);
|
||||
const decided = won + lost;
|
||||
return {
|
||||
lead_type: r.lead_type,
|
||||
total: Number(r.total),
|
||||
won,
|
||||
lost,
|
||||
won_amount: Number(r.amount),
|
||||
conversion_rate: decided === 0 ? 0 : Math.round((won / decided) * 1000) / 10,
|
||||
};
|
||||
});
|
||||
|
||||
const byIndustryRes = await query<{ industry: string; count: string; won: string; pipeline: string }>(
|
||||
`SELECT COALESCE(NULLIF(TRIM(industry), ''), 'не указана') AS industry,
|
||||
COUNT(*)::text AS count,
|
||||
COUNT(*) FILTER (WHERE deal_stage='won')::text AS won,
|
||||
COALESCE(SUM(expected_amount) FILTER (WHERE deal_stage NOT IN ('won','lost')),0)::text AS pipeline
|
||||
FROM leads
|
||||
GROUP BY 1
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 10`,
|
||||
);
|
||||
const industries = byIndustryRes.rows.map((r) => ({
|
||||
industry: r.industry,
|
||||
count: Number(r.count),
|
||||
won: Number(r.won),
|
||||
pipeline: Number(r.pipeline),
|
||||
}));
|
||||
|
||||
const tasksRes = await query<{
|
||||
assigned_to: string;
|
||||
total: string;
|
||||
done: string;
|
||||
overdue: string;
|
||||
}>(
|
||||
`SELECT assigned_to,
|
||||
COUNT(*)::text AS total,
|
||||
COUNT(*) FILTER (WHERE completed_at IS NOT NULL)::text AS done,
|
||||
COUNT(*) FILTER (WHERE completed_at IS NULL AND due_date < CURRENT_DATE)::text AS overdue
|
||||
FROM lead_tasks
|
||||
GROUP BY assigned_to`,
|
||||
);
|
||||
const tasksPerManager = tasksRes.rows.map((r) => {
|
||||
const total = Number(r.total);
|
||||
const done = Number(r.done);
|
||||
return {
|
||||
manager: r.assigned_to,
|
||||
total,
|
||||
done,
|
||||
overdue: Number(r.overdue),
|
||||
completion_rate: total === 0 ? 0 : Math.round((done / total) * 1000) / 10,
|
||||
};
|
||||
});
|
||||
|
||||
const timeInStageRes = await query<{ avg_days_to_won: string | null; avg_days_to_lost: string | null }>(
|
||||
`SELECT
|
||||
AVG(EXTRACT(EPOCH FROM (updated_at - created_at)) / 86400)
|
||||
FILTER (WHERE deal_stage = 'won')::numeric(10,1)::text AS avg_days_to_won,
|
||||
AVG(EXTRACT(EPOCH FROM (updated_at - created_at)) / 86400)
|
||||
FILTER (WHERE deal_stage = 'lost')::numeric(10,1)::text AS avg_days_to_lost
|
||||
FROM leads`,
|
||||
);
|
||||
const cycle = {
|
||||
avg_days_to_won: Number(timeInStageRes.rows[0]?.avg_days_to_won ?? 0) || 0,
|
||||
avg_days_to_lost: Number(timeInStageRes.rows[0]?.avg_days_to_lost ?? 0) || 0,
|
||||
};
|
||||
|
||||
return {
|
||||
managers,
|
||||
totals,
|
||||
funnel,
|
||||
forecast: Math.round(forecast),
|
||||
timeline,
|
||||
sources,
|
||||
types,
|
||||
industries,
|
||||
tasksPerManager,
|
||||
cycle,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -44,6 +44,42 @@ export interface TimelineEntry {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SourceEntry {
|
||||
source: string;
|
||||
lead_type: string;
|
||||
count: number;
|
||||
won: number;
|
||||
}
|
||||
|
||||
export interface TypeEntry {
|
||||
lead_type: string;
|
||||
total: number;
|
||||
won: number;
|
||||
lost: number;
|
||||
won_amount: number;
|
||||
conversion_rate: number;
|
||||
}
|
||||
|
||||
export interface IndustryEntry {
|
||||
industry: string;
|
||||
count: number;
|
||||
won: number;
|
||||
pipeline: number;
|
||||
}
|
||||
|
||||
export interface ManagerTaskStat {
|
||||
manager: string;
|
||||
total: number;
|
||||
done: number;
|
||||
overdue: number;
|
||||
completion_rate: number;
|
||||
}
|
||||
|
||||
export interface CycleStat {
|
||||
avg_days_to_won: number;
|
||||
avg_days_to_lost: number;
|
||||
}
|
||||
|
||||
export function getManagerStats() {
|
||||
return apiFetch<{
|
||||
managers: ManagerStat[];
|
||||
@ -51,5 +87,10 @@ export function getManagerStats() {
|
||||
funnel: FunnelEntry[];
|
||||
forecast: number;
|
||||
timeline: TimelineEntry[];
|
||||
sources: SourceEntry[];
|
||||
types: TypeEntry[];
|
||||
industries: IndustryEntry[];
|
||||
tasksPerManager: ManagerTaskStat[];
|
||||
cycle: CycleStat;
|
||||
}>('/stats/managers');
|
||||
}
|
||||
|
||||
@ -3,10 +3,15 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
getManagerStats,
|
||||
type CycleStat,
|
||||
type FunnelEntry,
|
||||
type IndustryEntry,
|
||||
type ManagerStat,
|
||||
type ManagerTaskStat,
|
||||
type ManagerTotals,
|
||||
type SourceEntry,
|
||||
type TimelineEntry,
|
||||
type TypeEntry,
|
||||
} from '@/api/stats';
|
||||
import Skeleton from '@/components/Skeleton.vue';
|
||||
|
||||
@ -21,6 +26,12 @@ const totals = ref<ManagerTotals | null>(null);
|
||||
const funnel = ref<FunnelEntry[]>([]);
|
||||
const forecast = ref(0);
|
||||
const timeline = ref<TimelineEntry[]>([]);
|
||||
const sources = ref<SourceEntry[]>([]);
|
||||
const types = ref<TypeEntry[]>([]);
|
||||
const industries = ref<IndustryEntry[]>([]);
|
||||
const tasksPerManager = ref<ManagerTaskStat[]>([]);
|
||||
const cycle = ref<CycleStat>({ avg_days_to_won: 0, avg_days_to_lost: 0 });
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
@ -34,6 +45,11 @@ async function load() {
|
||||
funnel.value = res.funnel;
|
||||
forecast.value = res.forecast;
|
||||
timeline.value = res.timeline;
|
||||
sources.value = res.sources;
|
||||
types.value = res.types;
|
||||
industries.value = res.industries;
|
||||
tasksPerManager.value = res.tasksPerManager;
|
||||
cycle.value = res.cycle;
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message;
|
||||
} finally {
|
||||
@ -69,6 +85,13 @@ const stageBg: Record<string, string> = {
|
||||
lost: 'bg-red-300',
|
||||
};
|
||||
|
||||
const sourceLabels: Record<string, string> = {
|
||||
cold: 'Холодный',
|
||||
referral: 'Реферал',
|
||||
website: 'Сайт',
|
||||
'n/a': 'Outbound',
|
||||
};
|
||||
|
||||
const funnelMax = computed(() =>
|
||||
Math.max(1, ...funnel.value.filter((f) => f.stage !== 'lost').map((f) => f.count)),
|
||||
);
|
||||
@ -79,6 +102,53 @@ const timelineSum = computed(() => timeline.value.reduce((s, t) => s + t.count,
|
||||
const managerBarMax = computed(() =>
|
||||
Math.max(1, ...managers.value.map((m) => m.total)),
|
||||
);
|
||||
|
||||
const sourcesAgg = computed(() => {
|
||||
const map = new Map<string, { count: number; won: number }>();
|
||||
for (const s of sources.value) {
|
||||
const cur = map.get(s.source) ?? { count: 0, won: 0 };
|
||||
cur.count += s.count;
|
||||
cur.won += s.won;
|
||||
map.set(s.source, cur);
|
||||
}
|
||||
return [...map.entries()]
|
||||
.map(([source, v]) => ({ source, ...v }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
});
|
||||
|
||||
const sourcesMax = computed(() => Math.max(1, ...sourcesAgg.value.map((s) => s.count)));
|
||||
const industriesMax = computed(() => Math.max(1, ...industries.value.map((i) => i.count)));
|
||||
const tasksPerManagerMax = computed(() => Math.max(1, ...tasksPerManager.value.map((t) => t.total)));
|
||||
|
||||
const chartW = 800;
|
||||
const chartH = 120;
|
||||
const chartPad = { top: 8, bottom: 18, left: 24, right: 8 };
|
||||
const innerW = chartW - chartPad.left - chartPad.right;
|
||||
const innerH = chartH - chartPad.top - chartPad.bottom;
|
||||
|
||||
const barWidth = computed(() =>
|
||||
timeline.value.length === 0 ? 0 : innerW / timeline.value.length - 4,
|
||||
);
|
||||
|
||||
function barX(i: number) {
|
||||
return chartPad.left + (i * innerW) / Math.max(1, timeline.value.length);
|
||||
}
|
||||
|
||||
function barH(count: number) {
|
||||
if (count === 0) return 0;
|
||||
return Math.max(2, (count / timelineMax.value) * innerH);
|
||||
}
|
||||
|
||||
function barY(count: number) {
|
||||
return chartPad.top + innerH - barH(count);
|
||||
}
|
||||
|
||||
function tickX(date: string, i: number): { x: number; label: string } | null {
|
||||
if (timeline.value.length === 0) return null;
|
||||
if (i !== 0 && i !== timeline.value.length - 1 && i !== Math.floor(timeline.value.length / 2))
|
||||
return null;
|
||||
return { x: barX(i) + barWidth.value / 2, label: date.slice(5) };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -88,12 +158,7 @@ const managerBarMax = computed(() =>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Статистика менеджеров</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Сводка по всей команде продаж</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
:disabled="loading"
|
||||
@click="load"
|
||||
>
|
||||
<button type="button" class="btn-secondary" :disabled="loading" @click="load">
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
@ -172,7 +237,9 @@ const managerBarMax = computed(() =>
|
||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">
|
||||
Воронка
|
||||
</h2>
|
||||
<span class="text-xs text-gray-500">{{ funnel.reduce((s, f) => s + f.count, 0) }} лидов</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ funnel.reduce((s, f) => s + f.count, 0) }} лидов
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div v-for="f in funnel" :key="f.stage" class="flex items-center gap-3 text-sm">
|
||||
@ -203,26 +270,206 @@ const managerBarMax = computed(() =>
|
||||
</h2>
|
||||
<span class="text-xs text-gray-500">{{ timelineSum }} новых</span>
|
||||
</div>
|
||||
<div class="mt-4 flex items-end gap-1 h-32">
|
||||
<div
|
||||
v-for="t in timeline"
|
||||
:key="t.date"
|
||||
class="flex-1 flex flex-col items-center gap-1 group"
|
||||
:title="`${t.date}: ${t.count}`"
|
||||
>
|
||||
<div class="text-[10px] tabular-nums text-gray-400 opacity-0 group-hover:opacity-100 transition">
|
||||
<svg
|
||||
v-if="timeline.length > 0"
|
||||
:viewBox="`0 0 ${chartW} ${chartH}`"
|
||||
class="mt-3 w-full h-32"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<line
|
||||
v-for="(_, i) in 4"
|
||||
:key="i"
|
||||
:x1="chartPad.left"
|
||||
:x2="chartW - chartPad.right"
|
||||
:y1="chartPad.top + (innerH * i) / 3"
|
||||
:y2="chartPad.top + (innerH * i) / 3"
|
||||
stroke="#f3f4f6"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<g v-for="(t, i) in timeline" :key="t.date">
|
||||
<rect
|
||||
:x="barX(i) + 2"
|
||||
:y="barY(t.count)"
|
||||
:width="Math.max(0, barWidth)"
|
||||
:height="barH(t.count)"
|
||||
rx="2"
|
||||
fill="#1f2937"
|
||||
opacity="0.85"
|
||||
>
|
||||
<title>{{ t.date }}: {{ t.count }}</title>
|
||||
</rect>
|
||||
<text
|
||||
v-if="t.count > 0"
|
||||
:x="barX(i) + 2 + barWidth / 2"
|
||||
:y="barY(t.count) - 3"
|
||||
font-size="9"
|
||||
text-anchor="middle"
|
||||
fill="#6b7280"
|
||||
>
|
||||
{{ t.count }}
|
||||
</text>
|
||||
</g>
|
||||
<text
|
||||
v-for="(t, i) in timeline"
|
||||
:key="`x-${t.date}`"
|
||||
:x="barX(i) + 2 + barWidth / 2"
|
||||
:y="chartH - 4"
|
||||
font-size="9"
|
||||
text-anchor="middle"
|
||||
fill="#9ca3af"
|
||||
>
|
||||
<template v-if="i === 0 || i === timeline.length - 1 || i === Math.floor(timeline.length / 2)">
|
||||
{{ t.date.slice(5) }}
|
||||
</template>
|
||||
</text>
|
||||
</svg>
|
||||
<div v-else class="text-xs text-gray-400 mt-4">Нет данных за период.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">
|
||||
Источники
|
||||
</h2>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ sourcesAgg.reduce((s, x) => s + x.count, 0) }} лидов
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="sourcesAgg.length === 0" class="text-xs text-gray-400 mt-4">—</div>
|
||||
<ul v-else class="mt-3 space-y-2">
|
||||
<li v-for="s in sourcesAgg" :key="s.source" class="text-sm">
|
||||
<div class="flex items-center justify-between text-xs text-gray-600 mb-1">
|
||||
<span>{{ sourceLabels[s.source] ?? s.source }}</span>
|
||||
<span class="tabular-nums">
|
||||
{{ s.count }}
|
||||
<span v-if="s.won > 0" class="text-emerald-600">· won {{ s.won }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-gray-900 rounded-full"
|
||||
:style="{ width: (s.count / sourcesMax) * 100 + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">
|
||||
Inbound vs Outbound
|
||||
</h2>
|
||||
</div>
|
||||
<div v-if="types.length === 0" class="text-xs text-gray-400 mt-4">—</div>
|
||||
<div v-else class="mt-3 space-y-3">
|
||||
<div v-for="t in types" :key="t.lead_type" class="text-sm">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="font-medium capitalize">{{ t.lead_type }}</span>
|
||||
<span class="text-xs text-gray-500 tabular-nums">
|
||||
{{ t.total }} лидов · конв. {{ t.conversion_rate }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex h-2 rounded-full overflow-hidden bg-gray-100">
|
||||
<div
|
||||
class="bg-emerald-400"
|
||||
:style="{ width: t.total === 0 ? 0 : (t.won / t.total) * 100 + '%' }"
|
||||
:title="`won: ${t.won}`"
|
||||
></div>
|
||||
<div
|
||||
class="bg-red-300"
|
||||
:style="{ width: t.total === 0 ? 0 : (t.lost / t.total) * 100 + '%' }"
|
||||
:title="`lost: ${t.lost}`"
|
||||
></div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1 tabular-nums">
|
||||
Выручка: <span class="text-emerald-700">{{ money(t.won_amount) }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="w-full rounded-sm bg-gray-200 group-hover:bg-gray-900 transition-all"
|
||||
:style="{ height: Math.max(2, (t.count / timelineMax) * 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex justify-between text-[10px] text-gray-400 tabular-nums">
|
||||
<span>{{ timeline[0]?.date.slice(5) }}</span>
|
||||
<span>{{ timeline[timeline.length - 1]?.date.slice(5) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">
|
||||
Цикл сделки
|
||||
</h2>
|
||||
</div>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Среднее до Won</div>
|
||||
<div class="text-2xl font-semibold tabular-nums text-emerald-700 mt-1">
|
||||
{{ cycle.avg_days_to_won.toFixed(1) }} <span class="text-base">дн.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Среднее до Lost</div>
|
||||
<div class="text-2xl font-semibold tabular-nums text-red-600 mt-1">
|
||||
{{ cycle.avg_days_to_lost.toFixed(1) }} <span class="text-base">дн.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">
|
||||
Топ отраслей
|
||||
</h2>
|
||||
<span class="text-xs text-gray-500">{{ industries.length }} групп</span>
|
||||
</div>
|
||||
<div v-if="industries.length === 0" class="text-xs text-gray-400 mt-4">—</div>
|
||||
<ul v-else class="mt-3 space-y-2">
|
||||
<li v-for="i in industries" :key="i.industry" class="text-sm">
|
||||
<div class="flex items-center justify-between text-xs text-gray-600 mb-1">
|
||||
<span class="truncate">{{ i.industry }}</span>
|
||||
<span class="tabular-nums whitespace-nowrap">
|
||||
{{ i.count }}
|
||||
<span v-if="i.won > 0" class="text-emerald-600">· {{ i.won }} won</span>
|
||||
<span v-if="i.pipeline > 0" class="text-gray-500">· {{ money(i.pipeline) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-sky-400 rounded-full"
|
||||
:style="{ width: (i.count / industriesMax) * 100 + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">
|
||||
Задачи менеджеров
|
||||
</h2>
|
||||
</div>
|
||||
<div v-if="tasksPerManager.length === 0" class="text-xs text-gray-400 mt-4">Задач пока нет.</div>
|
||||
<ul v-else class="mt-3 space-y-2">
|
||||
<li v-for="t in tasksPerManager" :key="t.manager" class="text-sm">
|
||||
<div class="flex items-center justify-between text-xs text-gray-600 mb-1">
|
||||
<span class="font-medium text-gray-800">{{ t.manager }}</span>
|
||||
<span class="tabular-nums">
|
||||
{{ t.done }} / {{ t.total }}
|
||||
<span v-if="t.overdue > 0" class="text-red-600">· просроч. {{ t.overdue }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-emerald-400 rounded-full"
|
||||
:style="{ width: t.completion_rate + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="text-[10px] text-gray-500 mt-0.5 tabular-nums text-right">
|
||||
{{ t.completion_rate }}% выполнено
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user