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;
user_ids: number[];
target: string | null;
/** Счётчики email-уведомлений; доставка в Telegram отслеживается через broadcast_id */
notifications_sent: 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 {
@@ -206,6 +219,12 @@ export const promoOffersApi = {
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
getLogs: async (params?: {
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",
"notificationsSent": "Notifications sent",
"offersCreated": "Offers created",
"notificationsFailed": "(failed: {{count}})"
"notificationsFailed": "(failed: {{count}})",
"deliveryTitle": "Telegram delivery",
"openAsBroadcast": "Open as broadcast"
},
"notFound": "Template not found",
"noActiveTemplates": "No active templates",

View File

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

View File

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

View File

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

View File

@@ -3,19 +3,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts';
import { AdminBackButton } from '../components/admin';
import { StatCard } from '@/components/stats';
import {
BanIcon,
BroadcastDeliveryStats,
BroadcastStatusBadge,
} from '../components/broadcasts/BroadcastDeliveryStats';
import { broadcastPollInterval, isBroadcastInFlight } from '../utils/broadcastStatus';
import {
DocumentIcon,
EmailIcon,
PhotoIcon,
RefreshIcon,
SendIcon,
StopIcon,
TelegramIcon,
UsersIcon,
VideoIcon,
XCircleIcon,
} from '@/components/icons';
// 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() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -116,13 +67,7 @@ export default function AdminBroadcastDetail() {
return adminBroadcastsApi.get(broadcastId);
},
enabled: !!broadcastId && !isNaN(broadcastId),
refetchInterval: (query) => {
const data = query.state.data;
if (data && ['queued', 'in_progress', 'cancelling'].includes(data.status)) {
return 3000;
}
return false;
},
refetchInterval: (query) => broadcastPollInterval(query.state.data?.status),
});
// 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)) {
navigate('/admin/broadcasts');
@@ -174,7 +119,7 @@ export default function AdminBroadcastDetail() {
<h1 className="text-xl font-bold text-dark-100">
{t('admin.broadcasts.detail')} #{broadcast.id}
</h1>
<StatusBadge status={broadcast.status} />
<BroadcastStatusBadge status={broadcast.status} />
<ChannelBadge channel={broadcast.channel} />
</div>
<p className="text-sm text-dark-400">
@@ -190,51 +135,15 @@ export default function AdminBroadcastDetail() {
</button>
</div>
{/* Progress */}
{isRunning && (
<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">
{broadcast.progress_percent.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: `${broadcast.progress_percent}%` }}
{/* Progress + stats */}
<BroadcastDeliveryStats
status={broadcast.status}
progressPercent={broadcast.progress_percent}
totalCount={broadcast.total_count}
sentCount={broadcast.sent_count}
blockedCount={broadcast.blocked_count}
failedCount={broadcast.failed_count}
/>
</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 */}
<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 {
promoOffersApi,
PromoOfferBroadcastRequest,
type PromoOfferBroadcastRequest,
TARGET_SEGMENTS,
TargetSegment,
type TargetSegment,
OFFER_TYPE_CONFIG,
OfferType,
type OfferType,
} 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 {
BroadcastDeliveryStats,
BroadcastStatusBadge,
} from '../components/broadcasts/BroadcastDeliveryStats';
import { broadcastPollInterval } from '../utils/broadcastStatus';
import {
SendIcon,
CheckIcon,
@@ -43,6 +49,7 @@ export default function AdminPromoOfferSend() {
title: string;
message: string;
isSuccess: boolean;
broadcastId?: number | null;
} | null>(null);
// Query templates
@@ -51,6 +58,27 @@ export default function AdminPromoOfferSend() {
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 activeTemplates = templates.filter((t) => t.is_active);
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
@@ -102,6 +130,7 @@ export default function AdminPromoOfferSend() {
mutationFn: promoOffersApi.broadcastOffer,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] });
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
let message = t('admin.promoOffers.result.offersCreated', { count: data.created_offers });
if (data.notifications_sent > 0 || data.notifications_failed > 0) {
@@ -121,6 +150,7 @@ export default function AdminPromoOfferSend() {
title: t('admin.promoOffers.result.sentTitle'),
message,
isSuccess: true,
broadcastId: data.broadcast_id,
});
},
onError: (error: unknown) => {
@@ -182,7 +212,7 @@ export default function AdminPromoOfferSend() {
if (result) {
return (
<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
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'
@@ -196,6 +226,33 @@ export default function AdminPromoOfferSend() {
</div>
<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>
{/* Прогресс доставки в 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">
<button
onClick={() => navigate('/admin/promo-offers')}
@@ -324,17 +381,30 @@ export default function AdminPromoOfferSend() {
</div>
{sendMode === 'segment' ? (
<>
<select
value={selectedTarget}
onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)}
className="input"
>
{Object.entries(TARGET_SEGMENTS).map(([key, labelKey]) => (
{Object.entries(TARGET_SEGMENTS).map(([key, labelKey]) => {
const count = segmentCounts.get(key);
return (
<option key={key} value={key}>
{t(labelKey)}
{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">
{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;
}