diff --git a/src/pages/AdminBroadcasts.tsx b/src/pages/AdminBroadcasts.tsx new file mode 100644 index 0000000..1203bb2 --- /dev/null +++ b/src/pages/AdminBroadcasts.tsx @@ -0,0 +1,762 @@ +import { useState, useRef, useMemo } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { + adminBroadcastsApi, + Broadcast, + BroadcastFilter, + TariffFilter, + BroadcastButton, + BroadcastCreateRequest, +} from '../api/adminBroadcasts' + +// Icons +const BroadcastIcon = () => ( + + + +) + +const PlusIcon = () => ( + + + +) + +const XIcon = () => ( + + + +) + +const RefreshIcon = () => ( + + + +) + +const StopIcon = () => ( + + + +) + +const PhotoIcon = () => ( + + + +) + +const VideoIcon = () => ( + + + +) + +const DocumentIcon = () => ( + + + +) + +const UsersIcon = () => ( + + + +) + +const ChevronDownIcon = () => ( + + + +) + +// Status badge component +function StatusBadge({ status }: { status: string }) { + const statusConfig: Record = { + queued: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'В очереди' }, + in_progress: { bg: 'bg-blue-500/20', text: 'text-blue-400', label: 'Отправляется' }, + completed: { bg: 'bg-green-500/20', text: 'text-green-400', label: 'Завершено' }, + partial: { bg: 'bg-orange-500/20', text: 'text-orange-400', label: 'Частично' }, + failed: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Ошибка' }, + cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-400', label: 'Отменено' }, + cancelling: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Отменяется' }, + } + const config = statusConfig[status] || statusConfig.queued + return ( + + {config.label} + + ) +} + +// Filter labels +const FILTER_GROUP_LABELS: Record = { + basic: 'Основные', + subscription: 'По подписке', + traffic: 'По трафику', + registration: 'По регистрации', + activity: 'По активности', + source: 'По источнику', + tariff: 'По тарифу', +} + +// Create broadcast modal +interface CreateModalProps { + onClose: () => void + onSuccess: () => void +} + +function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const fileInputRef = useRef(null) + + const [target, setTarget] = useState('') + const [messageText, setMessageText] = useState('') + const [selectedButtons, setSelectedButtons] = useState(['home']) + const [mediaFile, setMediaFile] = useState(null) + const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo') + const [mediaPreview, setMediaPreview] = useState(null) + const [uploadedFileId, setUploadedFileId] = useState(null) + const [isUploading, setIsUploading] = useState(false) + const [showFilters, setShowFilters] = useState(false) + + // Fetch filters + const { data: filtersData, isLoading: filtersLoading } = useQuery({ + queryKey: ['admin', 'broadcasts', 'filters'], + queryFn: adminBroadcastsApi.getFilters, + }) + + // Fetch buttons + const { data: buttonsData } = useQuery({ + queryKey: ['admin', 'broadcasts', 'buttons'], + queryFn: adminBroadcastsApi.getButtons, + }) + + // Preview mutation + const previewMutation = useMutation({ + mutationFn: adminBroadcastsApi.preview, + }) + + // Create mutation + const createMutation = useMutation({ + mutationFn: adminBroadcastsApi.create, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] }) + onSuccess() + onClose() + }, + }) + + // Group filters + const groupedFilters = useMemo(() => { + if (!filtersData) return {} + const groups: Record = {} + + // Basic filters + filtersData.filters.forEach(f => { + const group = f.group || 'basic' + if (!groups[group]) groups[group] = [] + groups[group].push(f) + }) + + // Tariff filters + if (filtersData.tariff_filters.length > 0) { + groups['tariff'] = filtersData.tariff_filters + } + + // Custom filters + filtersData.custom_filters.forEach(f => { + const group = f.group || 'custom' + if (!groups[group]) groups[group] = [] + groups[group].push(f) + }) + + return groups + }, [filtersData]) + + // Selected filter info + const selectedFilter = useMemo(() => { + if (!target || !filtersData) return null + const all = [ + ...filtersData.filters, + ...filtersData.tariff_filters, + ...filtersData.custom_filters, + ] + return all.find(f => f.key === target) + }, [target, filtersData]) + + // Handle file selection + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + setMediaFile(file) + + // Determine media type + if (file.type.startsWith('image/')) { + setMediaType('photo') + setMediaPreview(URL.createObjectURL(file)) + } else if (file.type.startsWith('video/')) { + setMediaType('video') + setMediaPreview(null) + } else { + setMediaType('document') + setMediaPreview(null) + } + + // Upload file + setIsUploading(true) + try { + const result = await adminBroadcastsApi.uploadMedia(file, mediaType) + setUploadedFileId(result.file_id) + } catch (err) { + console.error('Upload failed:', err) + setMediaFile(null) + setMediaPreview(null) + } finally { + setIsUploading(false) + } + } + + // Remove media + const handleRemoveMedia = () => { + setMediaFile(null) + setMediaPreview(null) + setUploadedFileId(null) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + + // Toggle button + const toggleButton = (key: string) => { + setSelectedButtons(prev => + prev.includes(key) ? prev.filter(b => b !== key) : [...prev, key] + ) + } + + // Preview recipients count + const handlePreview = () => { + if (target) { + previewMutation.mutate(target) + } + } + + // Submit + const handleSubmit = () => { + if (!target || !messageText.trim()) return + + const data: BroadcastCreateRequest = { + target, + message_text: messageText, + selected_buttons: selectedButtons, + } + + if (uploadedFileId) { + data.media = { + type: mediaType, + file_id: uploadedFileId, + } + } + + createMutation.mutate(data) + } + + const recipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null + + return ( +
+
+ {/* Header */} +
+
+
+ +
+

{t('admin.broadcasts.create')}

+
+ +
+ + {/* Content */} +
+ {/* Filter selection */} +
+ +
+ + + {showFilters && ( +
+ {filtersLoading ? ( +
Loading...
+ ) : ( + Object.entries(groupedFilters).map(([group, filters]) => ( +
+
+ {FILTER_GROUP_LABELS[group] || group} +
+ {filters.map(filter => ( + + ))} +
+ )) + )} +
+ )} +
+
+ + {/* Message text */} +
+ +