feat: Add real-time ticket updates via WebSocket and staff permission control

WebSocket Updates:
- Create useTicketWebSocket hook for real-time ticket list updates
- Hook invalidates React Query cache when tickets are created/updated
- Shows toast notifications for new tickets and comments
- Auto-reconnect with exponential backoff

Staff Permissions:
- Add can_access_tickets() method to User model
- Owners and managers always have ticket access
- Staff members need explicit can_access_tickets permission
- Update Sidebar to conditionally show Tickets menu based on permission
- Add can_access_tickets to API user response

Backend Updates:
- Update ticket signals to broadcast updates to all relevant users
- Check ticket access permission in views

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-28 05:44:39 -05:00
parent c400e8a722
commit 9dabb0cb83
9 changed files with 253 additions and 28 deletions

View File

@@ -51,6 +51,8 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
const canViewAdminPages = role === 'owner' || role === 'manager';
const canViewManagementPages = role === 'owner' || role === 'manager' || role === 'staff';
const canViewSettings = role === 'owner';
// Tickets: owners/managers always, staff only with permission
const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets);
const getDashboardLink = () => {
if (role === 'resource') return '/';
@@ -135,13 +137,16 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
<ClipboardList size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.resources')}</span>}
</Link>
<Link to="/tickets" className={getNavClass('/tickets')} title={t('nav.tickets')}>
<Ticket size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.tickets')}</span>}
</Link>
</>
)}
{canViewTickets && (
<Link to="/tickets" className={getNavClass('/tickets')} title={t('nav.tickets')}>
<Ticket size={20} className="shrink-0" />
{!isCollapsed && <span>{t('nav.tickets')}</span>}
</Link>
)}
{canViewAdminPages && (
<>
{/* Payments link: always visible for owners, only visible for others if enabled */}

View File

@@ -0,0 +1,174 @@
import { useEffect, useRef, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';
import { useCurrentUser } from './useAuth';
interface TicketWebSocketMessage {
type: 'new_ticket' | 'ticket_update' | 'ticket_deleted' | 'new_comment' | 'ticket_assigned' | 'ticket_status_changed';
ticket_id: number | string;
subject?: string;
ticket_type?: string;
priority?: string;
status?: string;
creator_name?: string;
assignee_name?: string;
comment_id?: number | string;
author_name?: string;
message: string;
}
interface UseTicketWebSocketOptions {
showToasts?: boolean;
onNewTicket?: (data: TicketWebSocketMessage) => void;
onTicketUpdate?: (data: TicketWebSocketMessage) => void;
onNewComment?: (data: TicketWebSocketMessage) => void;
}
/**
* Custom hook to manage WebSocket connection for real-time ticket updates.
* Automatically invalidates React Query cache when ticket changes occur.
*/
export const useTicketWebSocket = (options: UseTicketWebSocketOptions = {}) => {
const { showToasts = true, onNewTicket, onTicketUpdate, onNewComment } = options;
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttempts = useRef(0);
const maxReconnectAttempts = 5;
const { data: user } = useCurrentUser();
const queryClient = useQueryClient();
const handleMessage = useCallback((event: MessageEvent) => {
try {
const data: TicketWebSocketMessage = JSON.parse(event.data);
console.log('Ticket WebSocket message received:', data);
switch (data.type) {
case 'new_ticket':
// Invalidate ticket list to refetch
queryClient.invalidateQueries({ queryKey: ['tickets'] });
if (showToasts) {
toast.success(data.message || `New ticket: ${data.subject}`, {
duration: 5000,
position: 'top-right',
});
}
onNewTicket?.(data);
break;
case 'ticket_update':
case 'ticket_assigned':
case 'ticket_status_changed':
// Invalidate specific ticket and list
queryClient.invalidateQueries({ queryKey: ['tickets'] });
if (data.ticket_id) {
queryClient.invalidateQueries({ queryKey: ['tickets', String(data.ticket_id)] });
}
if (showToasts && data.type !== 'ticket_update') {
toast(data.message || 'Ticket updated', {
duration: 4000,
position: 'top-right',
icon: '🎫',
});
}
onTicketUpdate?.(data);
break;
case 'ticket_deleted':
// Invalidate ticket list
queryClient.invalidateQueries({ queryKey: ['tickets'] });
if (data.ticket_id) {
// Remove specific ticket from cache
queryClient.removeQueries({ queryKey: ['tickets', String(data.ticket_id)] });
}
break;
case 'new_comment':
// Invalidate comments for the ticket
if (data.ticket_id) {
queryClient.invalidateQueries({ queryKey: ['ticketComments', String(data.ticket_id)] });
queryClient.invalidateQueries({ queryKey: ['tickets', String(data.ticket_id)] });
}
if (showToasts) {
toast(data.message || 'New comment on ticket', {
duration: 4000,
position: 'top-right',
icon: '💬',
});
}
onNewComment?.(data);
break;
default:
console.log('Unknown ticket WebSocket message type:', data.type);
}
} catch (error) {
console.error('Error parsing ticket WebSocket message:', error);
}
}, [queryClient, showToasts, onNewTicket, onTicketUpdate, onNewComment]);
const connect = useCallback(() => {
if (!user || !user.id) {
return;
}
// Determine WebSocket URL - connect to backend on port 8000
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = window.location.hostname + ':8000';
const wsUrl = `${protocol}//${wsHost}/ws/tickets/`;
console.log('Connecting to ticket WebSocket:', wsUrl);
try {
wsRef.current = new WebSocket(wsUrl);
wsRef.current.onopen = () => {
console.log('Ticket WebSocket connected');
reconnectAttempts.current = 0; // Reset reconnect attempts on successful connection
};
wsRef.current.onmessage = handleMessage;
wsRef.current.onclose = (event) => {
console.log('Ticket WebSocket disconnected:', event.code, event.reason);
// Attempt to reconnect with exponential backoff
if (user && user.id && reconnectAttempts.current < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
reconnectAttempts.current += 1;
console.log(`Attempting to reconnect ticket WebSocket in ${delay}ms (attempt ${reconnectAttempts.current})`);
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, delay);
}
};
wsRef.current.onerror = (error) => {
console.error('Ticket WebSocket error:', error);
};
} catch (error) {
console.error('Failed to create ticket WebSocket:', error);
}
}, [user, handleMessage]);
useEffect(() => {
connect();
return () => {
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
// Close WebSocket
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, [connect]);
// Return connection status for debugging
return {
isConnected: wsRef.current?.readyState === WebSocket.OPEN,
};
};

View File

@@ -2,6 +2,7 @@ import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, User, Clock, AlertCircle, CheckCircle, XCircle, Circle, Loader2 } from 'lucide-react';
import { useTickets } from '../hooks/useTickets';
import { useTicketWebSocket } from '../hooks/useTicketWebSocket';
import { Ticket, TicketStatus } from '../types';
import TicketModal from '../components/TicketModal';
import { useCurrentUser } from '../hooks/useAuth';
@@ -82,6 +83,9 @@ const Tickets: React.FC = () => {
const [isTicketModalOpen, setIsTicketModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
// Enable real-time ticket updates via WebSocket
useTicketWebSocket({ showToasts: true });
// Fetch all tickets (backend will filter based on user role)
const { data: tickets = [], isLoading, error } = useTickets();

View File

@@ -2,12 +2,16 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Ticket as TicketIcon, AlertCircle, CheckCircle2, Clock, Circle, Loader2, XCircle, Plus } from 'lucide-react';
import { useTickets } from '../../hooks/useTickets';
import { useTicketWebSocket } from '../../hooks/useTicketWebSocket';
import { Ticket, TicketStatus } from '../../types';
import TicketModal from '../../components/TicketModal';
import Portal from '../../components/Portal';
const PlatformSupport: React.FC = () => {
const { t } = useTranslation();
// Enable real-time ticket updates via WebSocket
useTicketWebSocket({ showToasts: true });
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [statusFilter, setStatusFilter] = useState<TicketStatus | 'ALL'>('ALL');

View File

@@ -88,6 +88,9 @@ export interface User {
timezone?: string;
locale?: string;
notification_preferences?: NotificationPreferences;
can_invite_staff?: boolean;
can_access_tickets?: boolean;
permissions?: Record<string, boolean>;
}
export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT';