255 lines
10 KiB
JavaScript
255 lines
10 KiB
JavaScript
/**
|
||
* @file SmsCampaignStats.jsx
|
||
* @description SMS Campaign statistics with charts:
|
||
* - KPI cards (sent, delivered, in transit, errors)
|
||
* - Daily trend (sent vs delivered, last 14 days)
|
||
* - Status donut chart
|
||
* - Delivery rate
|
||
*/
|
||
import React, { useState, useEffect, useCallback } from "react";
|
||
import {
|
||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||
PieChart, Pie, Cell, LineChart, Line, CartesianGrid, Legend,
|
||
} from "recharts";
|
||
import { Panel } from "../UI/Panel";
|
||
import { supabase } from "../../supabaseClient";
|
||
|
||
const STATUS_COLORS = {
|
||
delivered: "#22c55e",
|
||
sent: "#3b82f6",
|
||
checking: "#94a3b8",
|
||
expired: "#eab308",
|
||
send_failed: "#ef4444",
|
||
error: "#ef4444",
|
||
limit_exceeded: "#f97316",
|
||
};
|
||
|
||
const STATUS_LABELS = {
|
||
delivered: "Доставлено",
|
||
sent: "Отправлено",
|
||
checking: "Проверяется",
|
||
expired: "Истёк срок",
|
||
send_failed: "Ошибка отправки",
|
||
error: "Ошибка доставки",
|
||
limit_exceeded: "Лимит",
|
||
};
|
||
|
||
export const SmsCampaignStats = ({ campaignType }) => {
|
||
const [stats, setStats] = useState(null);
|
||
const [dailyData, setDailyData] = useState([]);
|
||
const [statusData, setStatusData] = useState([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [period, setPeriod] = useState("14d");
|
||
|
||
const loadStats = useCallback(async () => {
|
||
setIsLoading(true);
|
||
try {
|
||
const days = period === "7d" ? 7 : period === "30d" ? 30 : period === "all" ? 90 : 14;
|
||
const startDate = new Date();
|
||
startDate.setDate(startDate.getDate() - days);
|
||
const startDateISO = startDate.toISOString();
|
||
|
||
const { data, error } = await supabase
|
||
.from("sms_campaign_log")
|
||
.select("status, sms_code, attempts, created_at, campaign_type")
|
||
.eq("campaign_type", campaignType)
|
||
.gte("created_at", startDateISO)
|
||
.order("created_at", { ascending: true });
|
||
|
||
if (error) throw error;
|
||
|
||
// KPI
|
||
const total = data.length;
|
||
const delivered = data.filter(d => d.status === "delivered").length;
|
||
const inTransit = data.filter(d => d.status === "sent" || d.status === "checking").length;
|
||
const errors = data.filter(d => ["send_failed", "error", "limit_exceeded"].includes(d.status)).length;
|
||
const expired = data.filter(d => d.status === "expired").length;
|
||
const deliveryRate = total > 0 ? Math.round((delivered / total) * 100) : 0;
|
||
|
||
setStats({ total, delivered, inTransit, errors, expired, deliveryRate });
|
||
|
||
// Daily data
|
||
const byDay = {};
|
||
data.forEach(d => {
|
||
const day = new Date(d.created_at).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
|
||
if (!byDay[day]) byDay[day] = { date: day, sent: 0, delivered: 0, errors: 0 };
|
||
byDay[day].sent++;
|
||
if (d.status === "delivered") byDay[day].delivered++;
|
||
if (["send_failed", "error", "limit_exceeded"].includes(d.status)) byDay[day].errors++;
|
||
});
|
||
setDailyData(Object.values(byDay));
|
||
|
||
// Status pie
|
||
const byStatus = {};
|
||
data.forEach(d => {
|
||
byStatus[d.status] = (byStatus[d.status] || 0) + 1;
|
||
});
|
||
setStatusData(Object.entries(byStatus).map(([name, value]) => ({
|
||
name: STATUS_LABELS[name] || name,
|
||
value,
|
||
color: STATUS_COLORS[name] || "#94a3b8",
|
||
})));
|
||
} catch (e) {
|
||
console.error("Stats error:", e);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [campaignType, period]);
|
||
|
||
useEffect(() => { loadStats(); }, [loadStats]);
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<Panel className="p-5">
|
||
<div className="animate-pulse text-sm text-[var(--color-text-muted)]">Загрузка статистики…</div>
|
||
</Panel>
|
||
);
|
||
}
|
||
|
||
if (!stats || stats.total === 0) {
|
||
return (
|
||
<Panel className="p-4">
|
||
<div className="text-xs text-[var(--color-text-muted)]">Нет данных для статистики</div>
|
||
</Panel>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{/* ── Period selector ──────────────────────────────────────────────── */}
|
||
<div className="flex gap-2">
|
||
{[
|
||
{ key: "7d", label: "7 дней" },
|
||
{ key: "14d", label: "14 дней" },
|
||
{ key: "30d", label: "30 дней" },
|
||
{ key: "all", label: "Всё время" },
|
||
].map(p => (
|
||
<button
|
||
key={p.key}
|
||
onClick={() => setPeriod(p.key)}
|
||
className={`rounded-full px-3 py-1 text-xs font-medium transition ${
|
||
period === p.key
|
||
? "bg-[var(--color-accent)] text-white"
|
||
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
||
}`}
|
||
>
|
||
{p.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── KPI cards ────────────────────────────────────────────────────── */}
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||
<KpiCard label="Всего" value={stats.total} color="var(--color-text)" />
|
||
<KpiCard label="Доставлено" value={stats.delivered} color="#22c55e" />
|
||
<KpiCard label="В пути" value={stats.inTransit} color="#3b82f6" />
|
||
<KpiCard label="Истёк срок" value={stats.expired} color="#eab308" />
|
||
<KpiCard label="Ошибки" value={stats.errors} color="#ef4444" />
|
||
<KpiCard label="Конверсия" value={`${stats.deliveryRate}%`} color="var(--color-accent)" />
|
||
</div>
|
||
|
||
{/* ── Charts ───────────────────────────────────────────────────────── */}
|
||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||
{/* Daily trend */}
|
||
<Panel className="p-4">
|
||
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">📈 Отправки по дням</div>
|
||
<ResponsiveContainer width="100%" height={220}>
|
||
<BarChart data={dailyData} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} />
|
||
<YAxis tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} allowDecimals={false} />
|
||
<Tooltip
|
||
contentStyle={{
|
||
background: "var(--color-surface-strong)",
|
||
border: "1px solid var(--color-border)",
|
||
borderRadius: "12px",
|
||
fontSize: "12px",
|
||
}}
|
||
/>
|
||
<Legend wrapperStyle={{ fontSize: "11px" }} />
|
||
<Bar dataKey="sent" name="Отправлено" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||
<Bar dataKey="delivered" name="Доставлено" fill="#22c55e" radius={[4, 4, 0, 0]} />
|
||
<Bar dataKey="errors" name="Ошибки" fill="#ef4444" radius={[4, 4, 0, 0]} />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</Panel>
|
||
|
||
{/* Status donut */}
|
||
<Panel className="p-4">
|
||
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">🍩 По статусам</div>
|
||
<ResponsiveContainer width="100%" height={220}>
|
||
<PieChart>
|
||
<Pie
|
||
data={statusData}
|
||
cx="50%"
|
||
cy="50%"
|
||
innerRadius={50}
|
||
outerRadius={85}
|
||
paddingAngle={3}
|
||
dataKey="value"
|
||
>
|
||
{statusData.map((entry, i) => (
|
||
<Cell key={i} fill={entry.color} />
|
||
))}
|
||
</Pie>
|
||
<Tooltip
|
||
contentStyle={{
|
||
background: "var(--color-surface-strong)",
|
||
border: "1px solid var(--color-border)",
|
||
borderRadius: "12px",
|
||
fontSize: "12px",
|
||
}}
|
||
/>
|
||
<Legend wrapperStyle={{ fontSize: "11px" }} />
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
</Panel>
|
||
</div>
|
||
|
||
{/* ── Delivery rate trend ──────────────────────────────────────────── */}
|
||
{dailyData.length > 1 && (
|
||
<Panel className="p-4">
|
||
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">📊 Конверсия доставки по дням</div>
|
||
<ResponsiveContainer width="100%" height={180}>
|
||
<LineChart
|
||
data={dailyData.map(d => ({
|
||
date: d.date,
|
||
rate: d.sent > 0 ? Math.round((d.delivered / d.sent) * 100) : 0,
|
||
}))}
|
||
margin={{ top: 5, right: 10, left: -20, bottom: 5 }}
|
||
>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} />
|
||
<YAxis tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} domain={[0, 100]} />
|
||
<Tooltip
|
||
contentStyle={{
|
||
background: "var(--color-surface-strong)",
|
||
border: "1px solid var(--color-border)",
|
||
borderRadius: "12px",
|
||
fontSize: "12px",
|
||
}}
|
||
formatter={(v) => [`${v}%`, "Конверсия"]}
|
||
/>
|
||
<Line
|
||
type="monotone"
|
||
dataKey="rate"
|
||
name="Конверсия %"
|
||
stroke="var(--color-accent)"
|
||
strokeWidth={2}
|
||
dot={{ r: 3, fill: "var(--color-accent)" }}
|
||
/>
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
</Panel>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ── KPI Card ─────────────────────────────────────────────────────────────────
|
||
const KpiCard = ({ label, value, color }) => (
|
||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
||
<div className="text-[10px] font-medium text-[var(--color-text-muted)]">{label}</div>
|
||
<div className="mt-1 text-lg font-bold" style={{ color }}>{value}</div>
|
||
</div>
|
||
); |