supersam/src/components/admin/SmsCampaignStats.jsx

255 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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>
);