Merge PR #526: охват сегментов и прогресс доставки на странице отправки

This commit is contained in:
Egor
2026-07-29 12:29:29 +03:00
committed by GitHub
10 changed files with 298 additions and 128 deletions

View File

@@ -67,8 +67,21 @@ export interface PromoOfferBroadcastResponse {
created_offers: number; created_offers: number;
user_ids: number[]; user_ids: number[];
target: string | null; target: string | null;
/** Счётчики email-уведомлений; доставка в Telegram отслеживается через broadcast_id */
notifications_sent: number; notifications_sent: number;
notifications_failed: number; notifications_failed: number;
/** Запись рассылки с прогрессом доставки в Telegram (null — слать было некому) */
broadcast_id: number | null;
telegram_recipients: number;
}
export interface PromoOfferSegment {
key: string;
count: number;
}
export interface PromoOfferSegmentListResponse {
segments: PromoOfferSegment[];
} }
export interface PromoOfferTemplate { export interface PromoOfferTemplate {
@@ -206,6 +219,12 @@ export const promoOffersApi = {
return response.data; return response.data;
}, },
// Get recipient counts per target segment
getSegments: async (): Promise<PromoOfferSegmentListResponse> => {
const response = await apiClient.get('/cabinet/admin/promo-offers/segments');
return response.data;
},
// Get promo offer logs // Get promo offer logs
getLogs: async (params?: { getLogs: async (params?: {
limit?: number; limit?: number;

View File

@@ -0,0 +1,124 @@
import { useTranslation } from 'react-i18next';
import { StatCard } from '@/components/stats';
import { BanIcon, SendIcon, UsersIcon, XCircleIcon } from '@/components/icons';
import { isBroadcastInFlight } from '../../utils/broadcastStatus';
const statusConfig: Record<string, { bg: string; text: string; labelKey: string }> = {
queued: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.queued',
},
in_progress: {
bg: 'bg-accent-500/20',
text: 'text-accent-400',
labelKey: 'admin.broadcasts.status.inProgress',
},
completed: {
bg: 'bg-success-500/20',
text: 'text-success-400',
labelKey: 'admin.broadcasts.status.completed',
},
partial: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.partial',
},
failed: {
bg: 'bg-error-500/20',
text: 'text-error-400',
labelKey: 'admin.broadcasts.status.failed',
},
cancelled: {
bg: 'bg-dark-500/20',
text: 'text-dark-400',
labelKey: 'admin.broadcasts.status.cancelled',
},
cancelling: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.cancelling',
},
};
export function BroadcastStatusBadge({ status }: { status: string }) {
const { t } = useTranslation();
const config = statusConfig[status] || statusConfig.queued;
return (
<span className={`rounded-full px-3 py-1 text-sm font-medium ${config.bg} ${config.text}`}>
{t(config.labelKey)}
</span>
);
}
export interface BroadcastDeliveryStatsProps {
status: string;
progressPercent: number;
totalCount: number;
sentCount: number;
blockedCount: number;
failedCount: number;
}
/**
* Прогресс доставки и счётчики: сколько ушло, сколько заблокировало бота, сколько
* не доехало. Используется и на странице рассылки, и на отправке промопредложения.
*/
export function BroadcastDeliveryStats({
status,
progressPercent,
totalCount,
sentCount,
blockedCount,
failedCount,
}: BroadcastDeliveryStatsProps) {
const { t } = useTranslation();
return (
<div className="space-y-4">
{isBroadcastInFlight(status) && (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
<div className="mb-2 flex justify-between text-sm">
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
<span className="font-medium text-dark-100">{progressPercent.toFixed(1)}%</span>
</div>
<div className="h-3 overflow-hidden rounded-full bg-dark-700">
<div
className="h-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
label={t('admin.broadcasts.total')}
value={totalCount}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.broadcasts.sent')}
value={sentCount}
icon={<SendIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.broadcasts.blocked')}
value={blockedCount}
icon={<BanIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard
label={t('admin.broadcasts.failed')}
value={failedCount}
icon={<XCircleIcon className="h-5 w-5" />}
tone="error"
/>
</div>
</div>
);
}
export default BroadcastDeliveryStats;

View File

@@ -3807,7 +3807,9 @@
"sendError": "Failed to send offer", "sendError": "Failed to send offer",
"notificationsSent": "Notifications sent", "notificationsSent": "Notifications sent",
"offersCreated": "Offers created", "offersCreated": "Offers created",
"notificationsFailed": "(failed: {{count}})" "notificationsFailed": "(failed: {{count}})",
"deliveryTitle": "Telegram delivery",
"openAsBroadcast": "Open as broadcast"
}, },
"notFound": "Template not found", "notFound": "Template not found",
"noActiveTemplates": "No active templates", "noActiveTemplates": "No active templates",

View File

@@ -3305,7 +3305,9 @@
"notificationsFailed": "(ناموفق: {{count}})", "notificationsFailed": "(ناموفق: {{count}})",
"sendError": "ارسال پیشنهاد ناموفق بود", "sendError": "ارسال پیشنهاد ناموفق بود",
"notificationsSent": "اعلان‌های ارسال شده", "notificationsSent": "اعلان‌های ارسال شده",
"offersCreated": "پیشنهادهای ایجاد شده" "offersCreated": "پیشنهادهای ایجاد شده",
"deliveryTitle": "تحویل در تلگرام",
"openAsBroadcast": "نمایش به‌عنوان ارسال گروهی"
}, },
"notFound": "قالب یافت نشد", "notFound": "قالب یافت نشد",
"noActiveTemplates": "قالب فعالی وجود ندارد", "noActiveTemplates": "قالب فعالی وجود ندارد",

View File

@@ -4208,7 +4208,9 @@
"sendError": "Не удалось отправить предложение", "sendError": "Не удалось отправить предложение",
"notificationsSent": "Отправлено уведомлений", "notificationsSent": "Отправлено уведомлений",
"offersCreated": "Создано офферов", "offersCreated": "Создано офферов",
"notificationsFailed": "(не отправлено: {{count}})" "notificationsFailed": "(не отправлено: {{count}})",
"deliveryTitle": "Доставка в Telegram",
"openAsBroadcast": "Открыть как рассылку"
}, },
"notFound": "Шаблон не найден", "notFound": "Шаблон не найден",
"noActiveTemplates": "Нет активных шаблонов", "noActiveTemplates": "Нет активных шаблонов",

View File

@@ -3304,7 +3304,9 @@
"notificationsFailed": "(失败: {{count}}", "notificationsFailed": "(失败: {{count}}",
"sendError": "发送优惠失败", "sendError": "发送优惠失败",
"notificationsSent": "已发送通知", "notificationsSent": "已发送通知",
"offersCreated": "已创建优惠" "offersCreated": "已创建优惠",
"deliveryTitle": "Telegram 送达情况",
"openAsBroadcast": "作为群发查看"
}, },
"notFound": "未找到模板", "notFound": "未找到模板",
"noActiveTemplates": "没有活跃的模板", "noActiveTemplates": "没有活跃的模板",

View File

@@ -3,19 +3,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts'; import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { StatCard } from '@/components/stats';
import { import {
BanIcon, BroadcastDeliveryStats,
BroadcastStatusBadge,
} from '../components/broadcasts/BroadcastDeliveryStats';
import { broadcastPollInterval, isBroadcastInFlight } from '../utils/broadcastStatus';
import {
DocumentIcon, DocumentIcon,
EmailIcon, EmailIcon,
PhotoIcon, PhotoIcon,
RefreshIcon, RefreshIcon,
SendIcon,
StopIcon, StopIcon,
TelegramIcon, TelegramIcon,
UsersIcon,
VideoIcon, VideoIcon,
XCircleIcon,
} from '@/components/icons'; } from '@/components/icons';
// Channel badge component // Channel badge component
@@ -47,55 +47,6 @@ function ChannelBadge({ channel }: { channel?: BroadcastChannel }) {
); );
} }
// Status badge component
const statusConfig: Record<string, { bg: string; text: string; labelKey: string }> = {
queued: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.queued',
},
in_progress: {
bg: 'bg-accent-500/20',
text: 'text-accent-400',
labelKey: 'admin.broadcasts.status.inProgress',
},
completed: {
bg: 'bg-success-500/20',
text: 'text-success-400',
labelKey: 'admin.broadcasts.status.completed',
},
partial: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.partial',
},
failed: {
bg: 'bg-error-500/20',
text: 'text-error-400',
labelKey: 'admin.broadcasts.status.failed',
},
cancelled: {
bg: 'bg-dark-500/20',
text: 'text-dark-400',
labelKey: 'admin.broadcasts.status.cancelled',
},
cancelling: {
bg: 'bg-warning-500/20',
text: 'text-warning-400',
labelKey: 'admin.broadcasts.status.cancelling',
},
};
function StatusBadge({ status }: { status: string }) {
const { t } = useTranslation();
const config = statusConfig[status] || statusConfig.queued;
return (
<span className={`rounded-full px-3 py-1 text-sm font-medium ${config.bg} ${config.text}`}>
{t(config.labelKey)}
</span>
);
}
export default function AdminBroadcastDetail() { export default function AdminBroadcastDetail() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -116,13 +67,7 @@ export default function AdminBroadcastDetail() {
return adminBroadcastsApi.get(broadcastId); return adminBroadcastsApi.get(broadcastId);
}, },
enabled: !!broadcastId && !isNaN(broadcastId), enabled: !!broadcastId && !isNaN(broadcastId),
refetchInterval: (query) => { refetchInterval: (query) => broadcastPollInterval(query.state.data?.status),
const data = query.state.data;
if (data && ['queued', 'in_progress', 'cancelling'].includes(data.status)) {
return 3000;
}
return false;
},
}); });
// Stop mutation // Stop mutation
@@ -134,7 +79,7 @@ export default function AdminBroadcastDetail() {
}, },
}); });
const isRunning = broadcast && ['queued', 'in_progress', 'cancelling'].includes(broadcast.status); const isRunning = broadcast && isBroadcastInFlight(broadcast.status);
if (!broadcastId || isNaN(broadcastId)) { if (!broadcastId || isNaN(broadcastId)) {
navigate('/admin/broadcasts'); navigate('/admin/broadcasts');
@@ -174,7 +119,7 @@ export default function AdminBroadcastDetail() {
<h1 className="text-xl font-bold text-dark-100"> <h1 className="text-xl font-bold text-dark-100">
{t('admin.broadcasts.detail')} #{broadcast.id} {t('admin.broadcasts.detail')} #{broadcast.id}
</h1> </h1>
<StatusBadge status={broadcast.status} /> <BroadcastStatusBadge status={broadcast.status} />
<ChannelBadge channel={broadcast.channel} /> <ChannelBadge channel={broadcast.channel} />
</div> </div>
<p className="text-sm text-dark-400"> <p className="text-sm text-dark-400">
@@ -190,51 +135,15 @@ export default function AdminBroadcastDetail() {
</button> </button>
</div> </div>
{/* Progress */} {/* Progress + stats */}
{isRunning && ( <BroadcastDeliveryStats
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4"> status={broadcast.status}
<div className="mb-2 flex justify-between text-sm"> progressPercent={broadcast.progress_percent}
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span> totalCount={broadcast.total_count}
<span className="font-medium text-dark-100"> sentCount={broadcast.sent_count}
{broadcast.progress_percent.toFixed(1)}% blockedCount={broadcast.blocked_count}
</span> failedCount={broadcast.failed_count}
</div> />
<div className="h-3 overflow-hidden rounded-full bg-dark-700">
<div
className="h-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-300"
style={{ width: `${broadcast.progress_percent}%` }}
/>
</div>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
label={t('admin.broadcasts.total')}
value={broadcast.total_count}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.broadcasts.sent')}
value={broadcast.sent_count}
icon={<SendIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.broadcasts.blocked')}
value={broadcast.blocked_count}
icon={<BanIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard
label={t('admin.broadcasts.failed')}
value={broadcast.failed_count}
icon={<XCircleIcon className="h-5 w-5" />}
tone="error"
/>
</div>
{/* Target */} {/* Target */}
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4"> <div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">

View File

@@ -4,14 +4,20 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
promoOffersApi, promoOffersApi,
PromoOfferBroadcastRequest, type PromoOfferBroadcastRequest,
TARGET_SEGMENTS, TARGET_SEGMENTS,
TargetSegment, type TargetSegment,
OFFER_TYPE_CONFIG, OFFER_TYPE_CONFIG,
OfferType, type OfferType,
} from '../api/promoOffers'; } from '../api/promoOffers';
import { adminUsersApi, UserListItem } from '../api/adminUsers'; import { adminBroadcastsApi } from '../api/adminBroadcasts';
import { adminUsersApi, type UserListItem } from '../api/adminUsers';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import {
BroadcastDeliveryStats,
BroadcastStatusBadge,
} from '../components/broadcasts/BroadcastDeliveryStats';
import { broadcastPollInterval } from '../utils/broadcastStatus';
import { import {
SendIcon, SendIcon,
CheckIcon, CheckIcon,
@@ -43,6 +49,7 @@ export default function AdminPromoOfferSend() {
title: string; title: string;
message: string; message: string;
isSuccess: boolean; isSuccess: boolean;
broadcastId?: number | null;
} | null>(null); } | null>(null);
// Query templates // Query templates
@@ -51,6 +58,27 @@ export default function AdminPromoOfferSend() {
queryFn: promoOffersApi.getTemplates, queryFn: promoOffersApi.getTemplates,
}); });
// Recipient counts per segment — админ видит охват до отправки
const { data: segmentsData } = useQuery({
queryKey: ['admin-promo-segments'],
queryFn: promoOffersApi.getSegments,
staleTime: 60000,
});
const segmentCounts = new Map(
(segmentsData?.segments || []).map((segment) => [segment.key, segment.count]),
);
const selectedSegmentCount = segmentCounts.get(selectedTarget);
// Delivery progress of the offer we have just sent
const broadcastId = result?.broadcastId ?? null;
const { data: delivery } = useQuery({
queryKey: ['admin', 'broadcasts', 'detail', broadcastId],
queryFn: async () => adminBroadcastsApi.get(broadcastId as number),
enabled: broadcastId !== null,
refetchInterval: (query) => broadcastPollInterval(query.state.data?.status),
});
const templates = templatesData?.items || []; const templates = templatesData?.items || [];
const activeTemplates = templates.filter((t) => t.is_active); const activeTemplates = templates.filter((t) => t.is_active);
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId); const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
@@ -102,6 +130,7 @@ export default function AdminPromoOfferSend() {
mutationFn: promoOffersApi.broadcastOffer, mutationFn: promoOffersApi.broadcastOffer,
onSuccess: (data) => { onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] }); queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] });
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
let message = t('admin.promoOffers.result.offersCreated', { count: data.created_offers }); let message = t('admin.promoOffers.result.offersCreated', { count: data.created_offers });
if (data.notifications_sent > 0 || data.notifications_failed > 0) { if (data.notifications_sent > 0 || data.notifications_failed > 0) {
@@ -121,6 +150,7 @@ export default function AdminPromoOfferSend() {
title: t('admin.promoOffers.result.sentTitle'), title: t('admin.promoOffers.result.sentTitle'),
message, message,
isSuccess: true, isSuccess: true,
broadcastId: data.broadcast_id,
}); });
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
@@ -182,7 +212,7 @@ export default function AdminPromoOfferSend() {
if (result) { if (result) {
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
<div className="mx-auto max-w-md py-12 text-center"> <div className="mx-auto max-w-2xl py-12 text-center">
<div <div
className={`mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full ${ className={`mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full ${
result.isSuccess ? 'bg-success-500/20' : 'bg-error-500/20' result.isSuccess ? 'bg-success-500/20' : 'bg-error-500/20'
@@ -196,6 +226,33 @@ export default function AdminPromoOfferSend() {
</div> </div>
<h3 className="mb-2 text-lg font-semibold text-dark-100">{result.title}</h3> <h3 className="mb-2 text-lg font-semibold text-dark-100">{result.title}</h3>
<p className="mb-6 whitespace-pre-wrap text-dark-400">{result.message}</p> <p className="mb-6 whitespace-pre-wrap text-dark-400">{result.message}</p>
{/* Прогресс доставки в Telegram: сколько дошло, кто заблокировал бота */}
{delivery && (
<div className="mb-6 space-y-4 text-left">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-dark-300">
{t('admin.promoOffers.result.deliveryTitle')}
</span>
<BroadcastStatusBadge status={delivery.status} />
</div>
<BroadcastDeliveryStats
status={delivery.status}
progressPercent={delivery.progress_percent}
totalCount={delivery.total_count}
sentCount={delivery.sent_count}
blockedCount={delivery.blocked_count}
failedCount={delivery.failed_count}
/>
<button
onClick={() => navigate(`/admin/broadcasts/${delivery.id}`)}
className="text-sm text-accent-400 transition-colors hover:text-accent-300"
>
{t('admin.promoOffers.result.openAsBroadcast')}
</button>
</div>
)}
<div className="flex justify-center gap-3"> <div className="flex justify-center gap-3">
<button <button
onClick={() => navigate('/admin/promo-offers')} onClick={() => navigate('/admin/promo-offers')}
@@ -324,17 +381,30 @@ export default function AdminPromoOfferSend() {
</div> </div>
{sendMode === 'segment' ? ( {sendMode === 'segment' ? (
<select <>
value={selectedTarget} <select
onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)} value={selectedTarget}
className="input" onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)}
> className="input"
{Object.entries(TARGET_SEGMENTS).map(([key, labelKey]) => ( >
<option key={key} value={key}> {Object.entries(TARGET_SEGMENTS).map(([key, labelKey]) => {
{t(labelKey)} const count = segmentCounts.get(key);
</option> return (
))} <option key={key} value={key}>
</select> {count === undefined
? t(labelKey)
: `${t(labelKey)}${count} ${t('admin.broadcasts.recipients')}`}
</option>
);
})}
</select>
{selectedSegmentCount !== undefined && (
<div className="mt-2 text-sm text-dark-400">
{t('admin.broadcasts.willBeSent')}:{' '}
<strong className="text-accent-400">{selectedSegmentCount}</strong>
</div>
)}
</>
) : ( ) : (
<div ref={searchRef} className="relative"> <div ref={searchRef} className="relative">
{selectedUser ? ( {selectedUser ? (

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { broadcastPollInterval, isBroadcastInFlight } from './broadcastStatus';
describe('broadcastStatus', () => {
it('treats queued, in-progress and cancelling deliveries as running', () => {
expect(isBroadcastInFlight('queued')).toBe(true);
expect(isBroadcastInFlight('in_progress')).toBe(true);
expect(isBroadcastInFlight('cancelling')).toBe(true);
});
it('treats finished deliveries as done', () => {
expect(isBroadcastInFlight('completed')).toBe(false);
expect(isBroadcastInFlight('partial')).toBe(false);
expect(isBroadcastInFlight('failed')).toBe(false);
expect(isBroadcastInFlight('cancelled')).toBe(false);
});
it('handles a missing status without polling', () => {
expect(isBroadcastInFlight(undefined)).toBe(false);
expect(broadcastPollInterval(undefined)).toBe(false);
});
it('polls only while the delivery is running', () => {
expect(broadcastPollInterval('in_progress')).toBe(3000);
expect(broadcastPollInterval('completed')).toBe(false);
});
});

View File

@@ -0,0 +1,13 @@
/** Статусы, при которых доставка ещё идёт и запись рассылки нужно перезапрашивать. */
export const BROADCAST_IN_FLIGHT_STATUSES = ['queued', 'in_progress', 'cancelling'] as const;
/** Доставка ещё не завершена: показываем прогресс и продолжаем поллинг. */
export function isBroadcastInFlight(status: string | undefined): boolean {
if (!status) return false;
return (BROADCAST_IN_FLIGHT_STATUSES as readonly string[]).includes(status);
}
/** Интервал поллинга для react-query: пока идёт доставка — 3 секунды, иначе не опрашиваем. */
export function broadcastPollInterval(status: string | undefined): number | false {
return isBroadcastInFlight(status) ? 3000 : false;
}