diff --git a/src/api/admin.ts b/src/api/admin.ts index acc3d91..cc935f6 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -59,6 +59,8 @@ export interface TicketSettings { sla_check_interval_seconds: number sla_reminder_cooldown_minutes: number support_system_mode: string // tickets, contact, both + cabinet_user_notifications_enabled: boolean + cabinet_admin_notifications_enabled: boolean } export interface TicketSettingsUpdate { @@ -67,6 +69,8 @@ export interface TicketSettingsUpdate { sla_check_interval_seconds?: number sla_reminder_cooldown_minutes?: number support_system_mode?: string + cabinet_user_notifications_enabled?: boolean + cabinet_admin_notifications_enabled?: boolean } export interface AdminTicketListResponse { diff --git a/src/api/ticketNotifications.ts b/src/api/ticketNotifications.ts new file mode 100644 index 0000000..3adf58f --- /dev/null +++ b/src/api/ticketNotifications.ts @@ -0,0 +1,62 @@ +import apiClient from './client' +import type { TicketNotificationList, UnreadCountResponse } from '../types' + +export const ticketNotificationsApi = { + // User notifications + getNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise => { + const response = await apiClient.get('/cabinet/tickets/notifications', { + params: { unread_only: unreadOnly, limit, offset } + }) + return response.data + }, + + getUnreadCount: async (): Promise => { + const response = await apiClient.get('/cabinet/tickets/notifications/unread-count') + return response.data + }, + + markAsRead: async (notificationId: number): Promise<{ success: boolean }> => { + const response = await apiClient.post(`/cabinet/tickets/notifications/${notificationId}/read`) + return response.data + }, + + markAllAsRead: async (): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post('/cabinet/tickets/notifications/read-all') + return response.data + }, + + markTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post(`/cabinet/tickets/notifications/ticket/${ticketId}/read`) + return response.data + }, + + // Admin notifications + getAdminNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise => { + const response = await apiClient.get('/cabinet/admin/tickets/notifications', { + params: { unread_only: unreadOnly, limit, offset } + }) + return response.data + }, + + getAdminUnreadCount: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/tickets/notifications/unread-count') + return response.data + }, + + markAdminAsRead: async (notificationId: number): Promise<{ success: boolean }> => { + const response = await apiClient.post(`/cabinet/admin/tickets/notifications/${notificationId}/read`) + return response.data + }, + + markAllAdminAsRead: async (): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post('/cabinet/admin/tickets/notifications/read-all') + return response.data + }, + + markAdminTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => { + const response = await apiClient.post(`/cabinet/admin/tickets/notifications/ticket/${ticketId}/read`) + return response.data + }, +} + +export default ticketNotificationsApi diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx new file mode 100644 index 0000000..d7c0704 --- /dev/null +++ b/src/components/TicketNotificationBell.tsx @@ -0,0 +1,265 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { ticketNotificationsApi } from '../api/ticketNotifications' +import { useAuthStore } from '../store/auth' +import { useToast } from './Toast' +import { useWebSocket, WSMessage } from '../hooks/useWebSocket' +import type { TicketNotification } from '../types' + +const BellIcon = () => ( + + + +) + +const CheckIcon = () => ( + + + +) + +interface TicketNotificationBellProps { + isAdmin?: boolean +} + +export default function TicketNotificationBell({ isAdmin = false }: TicketNotificationBellProps) { + const { t } = useTranslation() + const navigate = useNavigate() + const queryClient = useQueryClient() + const { isAuthenticated } = useAuthStore() + const { showToast } = useToast() + const [isOpen, setIsOpen] = useState(false) + const dropdownRef = useRef(null) + + // Show toast for WebSocket notification + const showWSNotificationToast = useCallback((message: WSMessage) => { + const icon = message.type === 'ticket.new' ? '🎫' : + message.type === 'ticket.admin_reply' ? 'πŸ’¬' : 'πŸ“¨' + + const toastMessage = message.message || + (message.type === 'ticket.new' + ? t('notifications.newTicket', 'New ticket: {{title}}', { title: message.title }) + : t('notifications.newReply', 'New reply in ticket')) + + showToast({ + type: 'info', + message: toastMessage, + icon: {icon}, + onClick: () => { + navigate(isAdmin ? `/admin/tickets?ticket=${message.ticket_id}` : `/support?ticket=${message.ticket_id}`) + }, + duration: 8000, + }) + }, [showToast, navigate, isAdmin, t]) + + // Handle WebSocket message + const handleWSMessage = useCallback((message: WSMessage) => { + // Check if this notification is relevant for this user type + const isAdminNotification = message.type === 'ticket.new' || message.type === 'ticket.user_reply' + const isUserNotification = message.type === 'ticket.admin_reply' + + if ((isAdmin && isAdminNotification) || (!isAdmin && isUserNotification)) { + // Show toast + showWSNotificationToast(message) + + // Invalidate queries to refresh count and list + queryClient.invalidateQueries({ + queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] + }) + queryClient.invalidateQueries({ + queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] + }) + } + }, [isAdmin, showWSNotificationToast, queryClient]) + + // WebSocket connection + useWebSocket({ + onMessage: handleWSMessage, + }) + + // Fetch unread count (with slower polling as fallback when WS disconnects) + const { data: unreadData } = useQuery({ + queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'], + queryFn: isAdmin ? ticketNotificationsApi.getAdminUnreadCount : ticketNotificationsApi.getUnreadCount, + enabled: isAuthenticated, + refetchInterval: 60000, // Poll every 60 seconds as fallback + staleTime: 30000, + }) + + // Fetch notifications when dropdown is open + const { data: notificationsData, isLoading } = useQuery({ + queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'], + queryFn: () => isAdmin + ? ticketNotificationsApi.getAdminNotifications(false, 10) + : ticketNotificationsApi.getNotifications(false, 10), + enabled: isAuthenticated && isOpen, + staleTime: 5000, + }) + + // Mark all as read mutation + const markAllReadMutation = useMutation({ + mutationFn: isAdmin ? ticketNotificationsApi.markAllAdminAsRead : ticketNotificationsApi.markAllAsRead, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] }) + queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] }) + }, + }) + + // Mark single as read mutation + const markReadMutation = useMutation({ + mutationFn: isAdmin ? ticketNotificationsApi.markAdminAsRead : ticketNotificationsApi.markAsRead, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] }) + queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] }) + }, + }) + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const handleNotificationClick = (notification: TicketNotification) => { + if (!notification.is_read) { + markReadMutation.mutate(notification.id) + } + setIsOpen(false) + navigate(isAdmin ? `/admin/tickets?ticket=${notification.ticket_id}` : `/support?ticket=${notification.ticket_id}`) + } + + const formatTime = (dateStr: string) => { + const date = new Date(dateStr) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffMins < 1) return t('notifications.justNow', 'Just now') + if (diffMins < 60) return t('notifications.minutesAgo', '{{count}} min ago', { count: diffMins }) + if (diffHours < 24) return t('notifications.hoursAgo', '{{count}} h ago', { count: diffHours }) + return t('notifications.daysAgo', '{{count}} d ago', { count: diffDays }) + } + + const getNotificationIcon = (type: string) => { + switch (type) { + case 'new_ticket': + return 🎫 + case 'admin_reply': + return πŸ’¬ + case 'user_reply': + return πŸ“¨ + default: + return πŸ”” + } + } + + const unreadCount = unreadData?.unread_count || 0 + + return ( +
+ {/* Bell button */} + + + {/* Dropdown */} + {isOpen && ( +
+ {/* Header */} +
+

+ {t('notifications.ticketNotifications', 'Ticket Notifications')} +

+ {unreadCount > 0 && ( + + )} +
+ + {/* Notifications list */} +
+ {isLoading ? ( +
+
+
+ ) : notificationsData?.items && notificationsData.items.length > 0 ? ( + notificationsData.items.map((notification) => ( + + )) + ) : ( +
+ +

{t('notifications.noNotifications', 'No notifications')}

+
+ )} +
+ + {/* Footer */} + {notificationsData?.items && notificationsData.items.length > 0 && ( +
+ +
+ )} +
+ )} +
+ ) +} diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx new file mode 100644 index 0000000..fa0d3fd --- /dev/null +++ b/src/components/Toast.tsx @@ -0,0 +1,154 @@ +import { createContext, useContext, useState, useCallback, ReactNode } from 'react' +import { useNavigate } from 'react-router-dom' + +interface Toast { + id: string + message: string + type: 'info' | 'success' | 'warning' | 'error' + icon?: ReactNode + onClick?: () => void + duration?: number +} + +interface ToastContextType { + showToast: (toast: Omit) => void + hideToast: (id: string) => void +} + +const ToastContext = createContext(null) + +export function useToast() { + const context = useContext(ToastContext) + if (!context) { + throw new Error('useToast must be used within ToastProvider') + } + return context +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + + const showToast = useCallback((toast: Omit) => { + const id = Math.random().toString(36).substring(2, 9) + const newToast = { ...toast, id } + + setToasts(prev => [...prev, newToast]) + + // Auto remove after duration (default 6 seconds) + setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== id)) + }, toast.duration || 6000) + }, []) + + const hideToast = useCallback((id: string) => { + setToasts(prev => prev.filter(t => t.id !== id)) + }, []) + + return ( + + {children} + + + ) +} + +function ToastContainer({ toasts, onClose }: { toasts: Toast[], onClose: (id: string) => void }) { + if (toasts.length === 0) return null + + return ( +
+ {toasts.map(toast => ( + + ))} +
+ ) +} + +function ToastItem({ toast, onClose }: { toast: Toast, onClose: (id: string) => void }) { + const getBgColor = () => { + switch (toast.type) { + case 'success': return 'bg-success-500/95' + case 'warning': return 'bg-warning-500/95' + case 'error': return 'bg-error-500/95' + default: return 'bg-accent-500/95' + } + } + + const handleClick = () => { + if (toast.onClick) { + toast.onClick() + onClose(toast.id) + } + } + + return ( +
+ {toast.icon && ( +
+ {toast.icon} +
+ )} +
+

{toast.message}

+ {toast.onClick && ( +

Click to view

+ )} +
+ +
+ ) +} + +// Hook for ticket notification toasts +export function useTicketToast() { + const { showToast } = useToast() + const navigate = useNavigate() + + const showNewReplyToast = useCallback((ticketId: number, message: string, isAdmin: boolean) => { + showToast({ + type: 'info', + message: message || `New reply in ticket #${ticketId}`, + icon: πŸ’¬, + onClick: () => { + navigate(isAdmin ? `/admin/tickets?ticket=${ticketId}` : `/support?ticket=${ticketId}`) + }, + duration: 8000, + }) + }, [showToast, navigate]) + + const showNewTicketToast = useCallback((ticketId: number, title: string) => { + showToast({ + type: 'info', + message: `New ticket: ${title}`, + icon: 🎫, + onClick: () => { + navigate(`/admin/tickets?ticket=${ticketId}`) + }, + duration: 8000, + }) + }, [showToast, navigate]) + + return { showNewReplyToast, showNewTicketToast } +} diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index c1d2d98..c8d27d9 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query' import { useAuthStore } from '../../store/auth' import LanguageSwitcher from '../LanguageSwitcher' import PromoDiscountBadge from '../PromoDiscountBadge' +import TicketNotificationBell from '../TicketNotificationBell' import { contestsApi } from '../../api/contests' import { pollsApi } from '../../api/polls' import { brandingApi } from '../../api/branding' @@ -337,6 +338,7 @@ export default function Layout({ children }: LayoutProps) { )} + {/* Profile - Desktop */} diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..88c9887 --- /dev/null +++ b/src/hooks/useWebSocket.ts @@ -0,0 +1,144 @@ +import { useEffect, useRef, useCallback, useState } from 'react' +import { useAuthStore } from '../store/auth' + +export interface WSMessage { + type: string + ticket_id?: number + message?: string + title?: string + user_id?: number + is_admin?: boolean +} + +interface UseWebSocketOptions { + onMessage?: (message: WSMessage) => void + onConnect?: () => void + onDisconnect?: () => void +} + +export function useWebSocket(options: UseWebSocketOptions = {}) { + const { accessToken, isAuthenticated } = useAuthStore() + const wsRef = useRef(null) + const reconnectTimeoutRef = useRef | null>(null) + const pingIntervalRef = useRef | null>(null) + const [isConnected, setIsConnected] = useState(false) + const reconnectAttemptsRef = useRef(0) + const maxReconnectAttempts = 5 + const optionsRef = useRef(options) + + // Update options ref when they change + useEffect(() => { + optionsRef.current = options + }, [options]) + + const cleanup = useCallback(() => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + pingIntervalRef.current = null + } + if (wsRef.current) { + wsRef.current.close() + wsRef.current = null + } + }, []) + + const connect = useCallback(() => { + if (!accessToken || !isAuthenticated) { + return + } + + // Don't reconnect if already connected + if (wsRef.current?.readyState === WebSocket.OPEN) { + return + } + + cleanup() + + // Build WebSocket URL + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = import.meta.env.VITE_API_URL + ? new URL(import.meta.env.VITE_API_URL).host + : window.location.host + const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}` + + try { + const ws = new WebSocket(wsUrl) + wsRef.current = ws + + ws.onopen = () => { + console.log('[WS] Connected') + setIsConnected(true) + reconnectAttemptsRef.current = 0 + optionsRef.current.onConnect?.() + + // Setup ping interval (every 25 seconds) + pingIntervalRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })) + } + }, 25000) + } + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data) as WSMessage + + // Ignore pong messages + if (message.type === 'pong' || message.type === 'connected') { + return + } + + optionsRef.current.onMessage?.(message) + } catch (e) { + console.error('[WS] Failed to parse message:', e) + } + } + + ws.onclose = (event) => { + console.log('[WS] Disconnected:', event.code, event.reason) + setIsConnected(false) + optionsRef.current.onDisconnect?.() + + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + pingIntervalRef.current = null + } + + // Attempt to reconnect if not closed intentionally + if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) { + const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000) + console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`) + + reconnectTimeoutRef.current = setTimeout(() => { + reconnectAttemptsRef.current++ + connect() + }, delay) + } + } + + ws.onerror = (error) => { + console.error('[WS] Error:', error) + } + } catch (e) { + console.error('[WS] Failed to connect:', e) + } + }, [accessToken, isAuthenticated, cleanup]) + + // Connect when authenticated + useEffect(() => { + if (isAuthenticated && accessToken) { + connect() + } else { + cleanup() + setIsConnected(false) + } + + return cleanup + }, [isAuthenticated, accessToken, connect, cleanup]) + + return { isConnected } +} diff --git a/src/locales/en.json b/src/locales/en.json index 4e85c02..ab9d0ef 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -35,6 +35,20 @@ "info": "Info", "wheel": "Fortune Wheel" }, + "notifications": { + "ticketNotifications": "Ticket Notifications", + "markAllRead": "Mark all read", + "noNotifications": "No notifications", + "justNow": "Just now", + "minutesAgo": "{{count}} min ago", + "hoursAgo": "{{count}} h ago", + "daysAgo": "{{count}} d ago", + "viewAll": "View all tickets", + "newNotification": "New notification", + "clickToView": "Click to view", + "newTicket": "New ticket: {{title}}", + "newReply": "New reply in ticket" + }, "auth": { "login": "Login", "register": "Register", @@ -732,7 +746,12 @@ "reminderCooldown": "Reminder interval (minutes)", "reminderCooldownDesc": "Minimum time between reminders (1-120 minutes)", "settingsUpdateError": "Error saving settings", - "copyTelegramId": "Click to copy Telegram ID" + "copyTelegramId": "Click to copy Telegram ID", + "cabinetNotifications": "Cabinet Notifications", + "userNotificationsEnabled": "User Notifications", + "userNotificationsEnabledDesc": "Send notifications to users about admin replies", + "adminNotificationsEnabled": "Admin Notifications", + "adminNotificationsEnabledDesc": "Send notifications to admins about new tickets and replies" }, "tariffs": { "title": "Tariff Management", diff --git a/src/locales/ru.json b/src/locales/ru.json index e46a052..9cb1f19 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -35,6 +35,20 @@ "info": "Π˜Π½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΡ", "wheel": "КолСсо ΡƒΠ΄Π°Ρ‡ΠΈ" }, + "notifications": { + "ticketNotifications": "УвСдомлСния ΠΎ Ρ‚ΠΈΠΊΠ΅Ρ‚Π°Ρ…", + "markAllRead": "ΠŸΡ€ΠΎΡ‡ΠΈΡ‚Π°Ρ‚ΡŒ всС", + "noNotifications": "НСт ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠΉ", + "justNow": "Волько Ρ‡Ρ‚ΠΎ", + "minutesAgo": "{{count}} ΠΌΠΈΠ½. Π½Π°Π·Π°Π΄", + "hoursAgo": "{{count}} Ρ‡. Π½Π°Π·Π°Π΄", + "daysAgo": "{{count}} Π΄. Π½Π°Π·Π°Π΄", + "viewAll": "ВсС Ρ‚ΠΈΠΊΠ΅Ρ‚Ρ‹", + "newNotification": "НовоС ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅", + "clickToView": "НаТмитС для просмотра", + "newTicket": "Новый Ρ‚ΠΈΠΊΠ΅Ρ‚: {{title}}", + "newReply": "Новый ΠΎΡ‚Π²Π΅Ρ‚ Π² Ρ‚ΠΈΠΊΠ΅Ρ‚Π΅" + }, "auth": { "login": "Π’Ρ…ΠΎΠ΄", "register": "РСгистрация", @@ -732,7 +746,12 @@ "reminderCooldown": "Π˜Π½Ρ‚Π΅Ρ€Π²Π°Π» Π½Π°ΠΏΠΎΠΌΠΈΠ½Π°Π½ΠΈΠΉ (ΠΌΠΈΠ½ΡƒΡ‚Ρ‹)", "reminderCooldownDesc": "МинимальноС врСмя ΠΌΠ΅ΠΆΠ΄Ρƒ напоминаниями (1-120 ΠΌΠΈΠ½ΡƒΡ‚)", "settingsUpdateError": "Ошибка сохранСния настроСк", - "copyTelegramId": "НаТмитС Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡΠΊΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ Telegram ID" + "copyTelegramId": "НаТмитС Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡΠΊΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ Telegram ID", + "cabinetNotifications": "УвСдомлСния Π² ΠΊΠ°Π±ΠΈΠ½Π΅Ρ‚Π΅", + "userNotificationsEnabled": "УвСдомлСния для ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ", + "userNotificationsEnabledDesc": "ΠžΡ‚ΠΏΡ€Π°Π²Π»ΡΡ‚ΡŒ увСдомлСния ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡΠΌ ΠΎΠ± ΠΎΡ‚Π²Π΅Ρ‚Π°Ρ… Π°Π΄ΠΌΠΈΠ½Π°", + "adminNotificationsEnabled": "УвСдомлСния для Π°Π΄ΠΌΠΈΠ½ΠΎΠ²", + "adminNotificationsEnabledDesc": "ΠžΡ‚ΠΏΡ€Π°Π²Π»ΡΡ‚ΡŒ увСдомлСния Π°Π΄ΠΌΠΈΠ½Π°ΠΌ ΠΎ Π½ΠΎΠ²Ρ‹Ρ… Ρ‚ΠΈΠΊΠ΅Ρ‚Π°Ρ… ΠΈ ΠΎΡ‚Π²Π΅Ρ‚Π°Ρ…" }, "tariffs": { "title": "Π£ΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ Ρ‚Π°Ρ€ΠΈΡ„Π°ΠΌΠΈ", diff --git a/src/main.tsx b/src/main.tsx index 53f0e75..6297c78 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App' import { ThemeColorsProvider } from './providers/ThemeColorsProvider' +import { ToastProvider } from './components/Toast' import './i18n' import './styles/globals.css' @@ -21,7 +22,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + + + diff --git a/src/pages/AdminTickets.tsx b/src/pages/AdminTickets.tsx index 9d6b622..1206b13 100644 --- a/src/pages/AdminTickets.tsx +++ b/src/pages/AdminTickets.tsx @@ -460,6 +460,8 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) { sla_check_interval_seconds: settings?.sla_check_interval_seconds ?? 60, sla_reminder_cooldown_minutes: settings?.sla_reminder_cooldown_minutes ?? 15, support_system_mode: settings?.support_system_mode ?? 'both', + cabinet_user_notifications_enabled: settings?.cabinet_user_notifications_enabled ?? true, + cabinet_admin_notifications_enabled: settings?.cabinet_admin_notifications_enabled ?? true, }) // Update form when settings load @@ -471,6 +473,8 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) { sla_check_interval_seconds: settings.sla_check_interval_seconds, sla_reminder_cooldown_minutes: settings.sla_reminder_cooldown_minutes, support_system_mode: settings.support_system_mode, + cabinet_user_notifications_enabled: settings.cabinet_user_notifications_enabled ?? true, + cabinet_admin_notifications_enabled: settings.cabinet_admin_notifications_enabled ?? true, }) } }, [settings]) @@ -526,6 +530,43 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {

{t('admin.tickets.supportModeDesc')}

+ {/* Cabinet Notifications */} +
+

{t('admin.tickets.cabinetNotifications')}

+ + {/* User Notifications */} +
+ +
+ + {/* Admin Notifications */} +
+ +
+
+

{t('admin.tickets.slaSettings')}

diff --git a/src/types/index.ts b/src/types/index.ts index 64cb61f..a71cefe 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -516,3 +516,34 @@ export interface ManualCheckResponse { old_status: string | null new_status: string | null } + +// Ticket notifications types +export interface TicketNotification { + id: number + ticket_id: number + notification_type: 'new_ticket' | 'admin_reply' | 'user_reply' + message: string | null + is_read: boolean + created_at: string + read_at: string | null +} + +export interface TicketNotificationList { + items: TicketNotification[] + unread_count: number +} + +export interface UnreadCountResponse { + unread_count: number +} + +// Extended TicketSettings with cabinet notifications +export interface TicketSettings { + sla_enabled: boolean + sla_minutes: number + sla_check_interval_seconds: number + sla_reminder_cooldown_minutes: number + support_system_mode: string + cabinet_user_notifications_enabled: boolean + cabinet_admin_notifications_enabled: boolean +}