mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +00:00
Merge PR #526: охват сегментов и прогресс доставки на странице отправки
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
124
src/components/broadcasts/BroadcastDeliveryStats.tsx
Normal file
124
src/components/broadcasts/BroadcastDeliveryStats.tsx
Normal 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;
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -3305,7 +3305,9 @@
|
|||||||
"notificationsFailed": "(ناموفق: {{count}})",
|
"notificationsFailed": "(ناموفق: {{count}})",
|
||||||
"sendError": "ارسال پیشنهاد ناموفق بود",
|
"sendError": "ارسال پیشنهاد ناموفق بود",
|
||||||
"notificationsSent": "اعلانهای ارسال شده",
|
"notificationsSent": "اعلانهای ارسال شده",
|
||||||
"offersCreated": "پیشنهادهای ایجاد شده"
|
"offersCreated": "پیشنهادهای ایجاد شده",
|
||||||
|
"deliveryTitle": "تحویل در تلگرام",
|
||||||
|
"openAsBroadcast": "نمایش بهعنوان ارسال گروهی"
|
||||||
},
|
},
|
||||||
"notFound": "قالب یافت نشد",
|
"notFound": "قالب یافت نشد",
|
||||||
"noActiveTemplates": "قالب فعالی وجود ندارد",
|
"noActiveTemplates": "قالب فعالی وجود ندارد",
|
||||||
|
|||||||
@@ -4208,7 +4208,9 @@
|
|||||||
"sendError": "Не удалось отправить предложение",
|
"sendError": "Не удалось отправить предложение",
|
||||||
"notificationsSent": "Отправлено уведомлений",
|
"notificationsSent": "Отправлено уведомлений",
|
||||||
"offersCreated": "Создано офферов",
|
"offersCreated": "Создано офферов",
|
||||||
"notificationsFailed": "(не отправлено: {{count}})"
|
"notificationsFailed": "(не отправлено: {{count}})",
|
||||||
|
"deliveryTitle": "Доставка в Telegram",
|
||||||
|
"openAsBroadcast": "Открыть как рассылку"
|
||||||
},
|
},
|
||||||
"notFound": "Шаблон не найден",
|
"notFound": "Шаблон не найден",
|
||||||
"noActiveTemplates": "Нет активных шаблонов",
|
"noActiveTemplates": "Нет активных шаблонов",
|
||||||
|
|||||||
@@ -3304,7 +3304,9 @@
|
|||||||
"notificationsFailed": "(失败: {{count}})",
|
"notificationsFailed": "(失败: {{count}})",
|
||||||
"sendError": "发送优惠失败",
|
"sendError": "发送优惠失败",
|
||||||
"notificationsSent": "已发送通知",
|
"notificationsSent": "已发送通知",
|
||||||
"offersCreated": "已创建优惠"
|
"offersCreated": "已创建优惠",
|
||||||
|
"deliveryTitle": "Telegram 送达情况",
|
||||||
|
"openAsBroadcast": "作为群发查看"
|
||||||
},
|
},
|
||||||
"notFound": "未找到模板",
|
"notFound": "未找到模板",
|
||||||
"noActiveTemplates": "没有活跃的模板",
|
"noActiveTemplates": "没有活跃的模板",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
27
src/utils/broadcastStatus.test.ts
Normal file
27
src/utils/broadcastStatus.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
13
src/utils/broadcastStatus.ts
Normal file
13
src/utils/broadcastStatus.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user