Modernize frontend and centralize branding
This commit is contained in:
1 parent
bc4542d0fb
commit
2d61d80550
55 files changed
+1901
-14446
No files matched your search
@@ -0,0 +1,907 @@
|
||||
// TicketSystem.jsx - Updated with auto-reloading feature
|
||||
import React, { useState, useEffect, useContext, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { AuthContext } from '../../App.jsx';
|
||||
import useWebSocket from '../../hooks/useWebSocket.js';
|
||||
import {
|
||||
MessageCircle, Plus, Search, Filter, Clock, AlertCircle, CheckCircle,
|
||||
X, User, FileText, Tag, AlertTriangle as Priority,
|
||||
Send, Edit, RefreshCw,
|
||||
Calendar, Clock as ClockIcon, Users
|
||||
} from "lucide-react";
|
||||
import { colors } from '../../theme/colors';
|
||||
import { dashLayout } from '../../theme/dashboardTokens';
|
||||
import { DashboardViewShell, PageHeaderBar, StatTile, ActionButton, Dropdown, SectionCard } from '../../components/dashboardPrimitives';
|
||||
import { UnifiedTooltip } from '../../components/DashboardComponents';
|
||||
import { WS_BASE_URL } from '../../config/api';
|
||||
|
||||
// Ticket Status Badge
|
||||
const StatusBadge = ({ status }) => {
|
||||
const statusConfig = {
|
||||
'open': { label: 'Open', color: colors.green, bgColor: `${colors.green}20` },
|
||||
'pending': { label: 'Pending', color: colors.yellow, bgColor: `${colors.yellow}20` },
|
||||
'in_progress': { label: 'In Progress', color: colors.blue, bgColor: `${colors.blue}20` },
|
||||
'resolved': { label: 'Resolved', color: colors.cyan, bgColor: `${colors.cyan}20` },
|
||||
'closed': { label: 'Closed', color: colors.magenta, bgColor: `${colors.magenta}20` }
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || statusConfig.open;
|
||||
|
||||
return (
|
||||
<span className="px-3 py-1 rounded-full text-xs font-medium" style={{
|
||||
backgroundColor: config.bgColor,
|
||||
color: config.color
|
||||
}}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Priority Badge
|
||||
const PriorityBadge = ({ priority }) => {
|
||||
const priorityConfig = {
|
||||
'low': { label: 'Low', color: colors.cyan, bgColor: `${colors.cyan}20` },
|
||||
'medium': { label: 'Medium', color: colors.blue, bgColor: `${colors.blue}20` },
|
||||
'high': { label: 'High', color: colors.yellow, bgColor: `${colors.yellow}20` },
|
||||
'urgent': { label: 'Urgent', color: colors.red, bgColor: `${colors.red}20` },
|
||||
'critical': { label: 'Critical', color: colors.brightRed, bgColor: `${colors.brightRed}20` }
|
||||
};
|
||||
|
||||
const config = priorityConfig[priority] || priorityConfig.medium;
|
||||
|
||||
return (
|
||||
<span className="px-3 py-1 rounded-full text-xs font-medium" style={{
|
||||
backgroundColor: config.bgColor,
|
||||
color: config.color
|
||||
}}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Ticket Card Component
|
||||
const TicketCard = ({ ticket, onSelect, onAction, isAdmin = false }) => {
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateString) => {
|
||||
return new Date(dateString).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group rounded-2xl border transition-all duration-300 hover:scale-105 hover:shadow-xl cursor-pointer overflow-hidden"
|
||||
style={{ backgroundColor: colors.backgroundSecondary, borderColor: colors.brightBlack }}
|
||||
onClick={() => onSelect(ticket)}
|
||||
>
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-lg mb-2" style={{ color: colors.brightWhite }}>
|
||||
{ticket.subject}
|
||||
</h4>
|
||||
<p className="text-sm line-clamp-2" style={{ color: colors.foreground }}>
|
||||
{ticket.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
<StatusBadge status={ticket.status} />
|
||||
<PriorityBadge priority={ticket.priority} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center space-x-4" style={{ color: colors.foreground }}>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Tag className="w-3 h-3" />
|
||||
<span>#{ticket.id}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>{formatDate(ticket.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<ClockIcon className="w-3 h-3" />
|
||||
<span>{formatTime(ticket.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-1 px-2 py-1 rounded-lg" style={{ backgroundColor: colors.brightBlack }}>
|
||||
<User className="w-3 h-3" style={{ color: colors.blue }} />
|
||||
<span className="text-xs" style={{ color: colors.foreground }}>{ticket.user_username}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t" style={{ borderColor: colors.brightBlack }}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-1 text-xs" style={{ color: colors.foreground }}>
|
||||
<MessageCircle className="w-3 h-3" />
|
||||
<span>{ticket.message_count || 0} messages</span>
|
||||
</div>
|
||||
{ticket.last_reply && (
|
||||
<div className="flex items-center space-x-1 text-xs" style={{ color: colors.foreground }}>
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>Last reply: {formatDate(ticket.last_reply)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
{isAdmin && (
|
||||
<>
|
||||
{ticket.status === 'open' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAction('claim', ticket.id);
|
||||
}}
|
||||
className="p-2 rounded-lg transition-all duration-200 hover:scale-105 border"
|
||||
style={{
|
||||
backgroundColor: `${colors.blue}20`,
|
||||
borderColor: colors.blue,
|
||||
color: colors.brightBlue
|
||||
}}
|
||||
>
|
||||
<UnifiedTooltip content="Claim ticket" multiline={false}>
|
||||
<User className="w-4 h-4" />
|
||||
</UnifiedTooltip>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAction('close', ticket.id);
|
||||
}}
|
||||
className="p-2 rounded-lg transition-all duration-200 hover:scale-105 border"
|
||||
style={{
|
||||
backgroundColor: `${colors.green}20`,
|
||||
borderColor: colors.green,
|
||||
color: colors.brightGreen
|
||||
}}
|
||||
>
|
||||
<UnifiedTooltip content="Close ticket" multiline={false}>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</UnifiedTooltip>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Create Ticket Modal
|
||||
const CreateTicketModal = ({ isOpen, onClose, onSubmit }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
subject: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
category: 'general'
|
||||
});
|
||||
|
||||
const categories = [
|
||||
{ value: 'general', label: 'General Support' },
|
||||
{ value: 'billing', label: 'Billing Issue' },
|
||||
{ value: 'technical', label: 'Technical Problem' },
|
||||
{ value: 'server', label: 'Server Issue' },
|
||||
{ value: 'feature', label: 'Feature Request' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
];
|
||||
|
||||
const priorities = [
|
||||
{ value: 'low', label: 'Low Priority' },
|
||||
{ value: 'medium', label: 'Medium Priority' },
|
||||
{ value: 'high', label: 'High Priority' },
|
||||
{ value: 'urgent', label: 'Urgent' },
|
||||
{ value: 'critical', label: 'Critical' }
|
||||
];
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (formData.subject.trim() && formData.description.trim()) {
|
||||
onSubmit(formData);
|
||||
setFormData({ subject: '', description: '', priority: 'medium', category: 'general' });
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-[9999] p-4">
|
||||
<div className="rounded-2xl w-full max-w-2xl border" style={{ backgroundColor: colors.backgroundSecondary, borderColor: colors.brightBlack }}>
|
||||
<div className="p-6 border-b flex justify-between items-center" style={{ borderColor: colors.brightBlack }}>
|
||||
<h3 className="text-xl font-bold" style={{ color: colors.brightWhite }}>Create New Ticket</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg transition-all duration-200 hover:scale-105 border group"
|
||||
style={{
|
||||
backgroundColor: colors.brightBlack,
|
||||
borderColor: colors.brightBlack,
|
||||
color: colors.foreground
|
||||
}}
|
||||
>
|
||||
<X className="w-5 h-5 group-hover:rotate-90 transition-transform duration-300" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: colors.foreground }}>Category</label>
|
||||
<Dropdown
|
||||
value={formData.category}
|
||||
options={categories}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, category: value }))}
|
||||
placeholder="Select category"
|
||||
icon={Tag}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: colors.foreground }}>Priority</label>
|
||||
<Dropdown
|
||||
value={formData.priority}
|
||||
options={priorities}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, priority: value }))}
|
||||
placeholder="Select priority"
|
||||
icon={Priority}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: colors.foreground }}>Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subject}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, subject: e.target.value }))}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm focus:outline-none transition-all duration-200 border"
|
||||
style={{
|
||||
backgroundColor: colors.brightBlack,
|
||||
borderColor: colors.brightBlack,
|
||||
color: colors.brightWhite
|
||||
}}
|
||||
placeholder="Brief description of your issue"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: colors.foreground }}>Description</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
rows={6}
|
||||
className="w-full rounded-xl px-4 py-3 text-sm focus:outline-none resize-none transition-all duration-200 border"
|
||||
style={{
|
||||
backgroundColor: colors.brightBlack,
|
||||
borderColor: colors.brightBlack,
|
||||
color: colors.brightWhite
|
||||
}}
|
||||
placeholder="Please provide detailed information about your issue..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 py-3 rounded-xl font-medium transition-all duration-200 hover:scale-105 border"
|
||||
style={{
|
||||
backgroundColor: colors.brightBlack,
|
||||
borderColor: colors.brightBlack,
|
||||
color: colors.brightWhite
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 py-3 rounded-xl font-medium transition-all duration-200 hover:scale-105 border"
|
||||
style={{
|
||||
backgroundColor: colors.blue,
|
||||
borderColor: colors.blue,
|
||||
color: colors.brightWhite
|
||||
}}
|
||||
>
|
||||
Create Ticket
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
// Ticket Detail Modal
|
||||
const TicketDetailModal = ({ ticket, isOpen, onClose, onSendMessage, onUpdateStatus, isAdmin = false }) => {
|
||||
const [message, setMessage] = useState('');
|
||||
const [currentTicket, setCurrentTicket] = useState(ticket);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentTicket(ticket);
|
||||
}, [ticket]);
|
||||
|
||||
const handleSendMessage = (e) => {
|
||||
e.preventDefault();
|
||||
if (message.trim()) {
|
||||
onSendMessage(currentTicket.id, message);
|
||||
setMessage('');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
return new Date(dateString).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4">
|
||||
<div className="rounded-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col border" style={{ backgroundColor: colors.backgroundSecondary, borderColor: colors.brightBlack }}>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b flex justify-between items-center" style={{ borderColor: colors.brightBlack }}>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-2 rounded-lg" style={{ backgroundColor: `${colors.blue}20` }}>
|
||||
<MessageCircle className="w-6 h-6" style={{ color: colors.blue }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold" style={{ color: colors.brightWhite }}>{currentTicket.subject}</h3>
|
||||
<div className="flex items-center space-x-3 mt-1">
|
||||
<StatusBadge status={currentTicket.status} />
|
||||
<PriorityBadge priority={currentTicket.priority} />
|
||||
<span className="text-sm" style={{ color: colors.foreground }}>#{currentTicket.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
{isAdmin && (
|
||||
<Dropdown
|
||||
value={currentTicket.status}
|
||||
options={[
|
||||
{ value: 'open', label: 'Open' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'in_progress', label: 'In Progress' },
|
||||
{ value: 'resolved', label: 'Resolved' },
|
||||
{ value: 'closed', label: 'Closed' }
|
||||
]}
|
||||
onChange={(value) => onUpdateStatus(currentTicket.id, value)}
|
||||
placeholder="Change status"
|
||||
icon={Edit}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-3 rounded-xl transition-all duration-200 hover:scale-105 border group"
|
||||
style={{
|
||||
backgroundColor: colors.brightBlack,
|
||||
borderColor: colors.brightBlack,
|
||||
color: colors.foreground
|
||||
}}
|
||||
>
|
||||
<X className="w-5 h-5 group-hover:rotate-90 transition-transform duration-300" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{currentTicket.messages && currentTicket.messages.map((msg, index) => (
|
||||
<div key={index} className={`flex ${msg.is_admin ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-2xl rounded-2xl p-4 ${msg.is_admin ? 'rounded-br-none' : 'rounded-bl-none'}`}
|
||||
style={{
|
||||
backgroundColor: msg.is_admin ? `${colors.blue}20` : colors.brightBlack,
|
||||
border: `1px solid ${msg.is_admin ? colors.blue : colors.brightBlack}`
|
||||
}}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: msg.is_admin ? colors.blue : colors.magenta,
|
||||
color: colors.brightWhite
|
||||
}}>
|
||||
{msg.is_admin ? 'A' : 'U'}
|
||||
</div>
|
||||
<span className="text-sm font-medium" style={{ color: colors.brightWhite }}>
|
||||
{msg.is_admin ? 'Support Agent' : (msg.user_username || 'User')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs" style={{ color: colors.foreground }}>
|
||||
{formatDateTime(msg.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm" style={{ color: colors.brightWhite }}>
|
||||
{msg.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Message Input */}
|
||||
{currentTicket.status !== 'closed' && (
|
||||
<div className="p-6 border-t" style={{ borderColor: colors.brightBlack }}>
|
||||
<form onSubmit={handleSendMessage} className="flex space-x-3">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="flex-1 rounded-xl px-4 py-3 text-sm focus:outline-none transition-all duration-200 border"
|
||||
style={{
|
||||
backgroundColor: colors.brightBlack,
|
||||
borderColor: colors.brightBlack,
|
||||
color: colors.brightWhite
|
||||
}}
|
||||
placeholder="Type your message..."
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-3 rounded-xl font-medium transition-all duration-200 hover:scale-105 border flex items-center space-x-2"
|
||||
style={{
|
||||
backgroundColor: colors.blue,
|
||||
borderColor: colors.blue,
|
||||
color: colors.brightWhite
|
||||
}}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
<span>Send</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== MAIN TICKET SYSTEM COMPONENT ====================
|
||||
function TicketSystem() {
|
||||
const { user, isAuthenticated } = useContext(AuthContext);
|
||||
const [activeTab, setActiveTab] = useState('my_tickets');
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [filteredTickets, setFilteredTickets] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState(null);
|
||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const token = localStorage.getItem('anchorhost_token');
|
||||
const WS_URL = WS_BASE_URL;
|
||||
|
||||
// Ref to store sendMessage and state for refreshing tickets
|
||||
const refreshTicketsRef = useRef({ sendMessage: null, activeTab, priorityFilter });
|
||||
|
||||
// WebSocket connection for real-time updates
|
||||
const handleWebSocketMessage = useCallback((data) => {
|
||||
console.log('TicketSystem received message:', data.type, data);
|
||||
|
||||
// Helper function to refresh tickets list
|
||||
const refreshTickets = () => {
|
||||
const { sendMessage: sm, activeTab: at, priorityFilter: pf } = refreshTicketsRef.current;
|
||||
if (sm) {
|
||||
setIsLoading(true);
|
||||
sm({
|
||||
type: 'get_tickets',
|
||||
status_filter: at === 'closed_tickets' ? 'closed' :
|
||||
at === 'open_tickets' ? 'open' : undefined,
|
||||
priority_filter: pf !== 'all' ? pf : undefined
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
switch (data.type) {
|
||||
case 'tickets_list':
|
||||
setTickets(data.tickets || []);
|
||||
setIsLoading(false);
|
||||
break;
|
||||
case 'ticket_created':
|
||||
// Refresh tickets list immediately after creation
|
||||
refreshTickets();
|
||||
break;
|
||||
case 'ticket_details':
|
||||
setSelectedTicket(data.ticket);
|
||||
break;
|
||||
case 'ticket_message_sent':
|
||||
// Update the ticket with new message
|
||||
setTickets(prev => prev.map(ticket => {
|
||||
if (ticket.id === data.message.ticket_id) {
|
||||
return {
|
||||
...ticket,
|
||||
messages: [...(ticket.messages || []), data.message],
|
||||
message_count: (ticket.message_count || 0) + 1,
|
||||
last_reply: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
return ticket;
|
||||
}));
|
||||
|
||||
// Update selected ticket if open
|
||||
if (selectedTicket && selectedTicket.id === data.message.ticket_id) {
|
||||
setSelectedTicket(prev => ({
|
||||
...prev,
|
||||
messages: [...(prev.messages || []), data.message],
|
||||
message_count: (prev.message_count || 0) + 1,
|
||||
last_reply: new Date().toISOString()
|
||||
}));
|
||||
}
|
||||
break;
|
||||
case 'ticket_status_updated':
|
||||
// Refresh tickets list after status update
|
||||
refreshTickets();
|
||||
break;
|
||||
case 'ticket_stats':
|
||||
// Handle stats update if needed
|
||||
break;
|
||||
case 'admin_notification_new_ticket':
|
||||
if (isAdmin) {
|
||||
// Refresh tickets list for admin
|
||||
refreshTickets();
|
||||
}
|
||||
break;
|
||||
case 'ticket_new_message':
|
||||
// Refresh tickets list for real-time updates
|
||||
refreshTickets();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [isAdmin, selectedTicket]);
|
||||
|
||||
const { sendMessage } = useWebSocket(
|
||||
`${WS_URL}?token=${token}`,
|
||||
handleWebSocketMessage
|
||||
);
|
||||
|
||||
// ==================== AUTO-RELOADING IMPLEMENTATION ====================
|
||||
|
||||
// Update ref when sendMessage or filters change
|
||||
useEffect(() => {
|
||||
refreshTicketsRef.current = { sendMessage, activeTab, priorityFilter };
|
||||
}, [sendMessage, activeTab, priorityFilter]);
|
||||
|
||||
// Load tickets function
|
||||
const loadTickets = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
sendMessage({
|
||||
type: 'get_tickets',
|
||||
status_filter: activeTab === 'closed_tickets' ? 'closed' :
|
||||
activeTab === 'open_tickets' ? 'open' : undefined,
|
||||
priority_filter: priorityFilter !== 'all' ? priorityFilter : undefined
|
||||
});
|
||||
}, [activeTab, priorityFilter, sendMessage]);
|
||||
|
||||
// Auto-refresh tickets every 30 seconds
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && sendMessage) {
|
||||
loadTickets();
|
||||
const interval = setInterval(loadTickets, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isAuthenticated, loadTickets, sendMessage]);
|
||||
|
||||
// ==================== END AUTO-RELOADING IMPLEMENTATION ====================
|
||||
|
||||
const filterTickets = useCallback(() => {
|
||||
let filtered = tickets;
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(ticket =>
|
||||
ticket.subject.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
ticket.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
ticket.id.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(ticket => ticket.status === statusFilter);
|
||||
}
|
||||
|
||||
// Filter by priority
|
||||
if (priorityFilter !== 'all') {
|
||||
filtered = filtered.filter(ticket => ticket.priority === priorityFilter);
|
||||
}
|
||||
|
||||
setFilteredTickets(filtered);
|
||||
}, [tickets, searchQuery, statusFilter, priorityFilter]);
|
||||
|
||||
// Filter tickets based on search and filters
|
||||
useEffect(() => {
|
||||
filterTickets();
|
||||
}, [filterTickets]);
|
||||
|
||||
const handleCreateTicket = (ticketData) => {
|
||||
sendMessage({
|
||||
type: 'create_ticket',
|
||||
subject: ticketData.subject,
|
||||
description: ticketData.description,
|
||||
priority: ticketData.priority,
|
||||
category: ticketData.category
|
||||
});
|
||||
setIsCreateModalOpen(false);
|
||||
};
|
||||
|
||||
const handleSendMessage = (ticketId, message) => {
|
||||
sendMessage({
|
||||
type: 'send_ticket_message',
|
||||
ticket_id: ticketId,
|
||||
message: message
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateStatus = (ticketId, newStatus) => {
|
||||
sendMessage({
|
||||
type: 'update_ticket_status',
|
||||
ticket_id: ticketId,
|
||||
status: newStatus
|
||||
});
|
||||
};
|
||||
|
||||
const handleTicketAction = (action, ticketId) => {
|
||||
switch (action) {
|
||||
case 'claim':
|
||||
// Implement claim logic
|
||||
break;
|
||||
case 'close':
|
||||
handleUpdateStatus(ticketId, 'closed');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectTicket = (ticket) => {
|
||||
setSelectedTicket(ticket);
|
||||
setIsDetailModalOpen(true);
|
||||
|
||||
// Load full ticket details
|
||||
sendMessage({
|
||||
type: 'get_ticket',
|
||||
ticket_id: ticket.id
|
||||
});
|
||||
};
|
||||
|
||||
const stats = {
|
||||
total: tickets.length,
|
||||
open: tickets.filter(t => t.status === 'open').length,
|
||||
pending: tickets.filter(t => t.status === 'pending').length,
|
||||
in_progress: tickets.filter(t => t.status === 'in_progress').length,
|
||||
resolved: tickets.filter(t => t.status === 'resolved').length,
|
||||
closed: tickets.filter(t => t.status === 'closed').length
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<DashboardViewShell>
|
||||
<PageHeaderBar
|
||||
accentColor={colors.magenta}
|
||||
icon={MessageCircle}
|
||||
title={isAdmin ? 'Support queue' : 'Support'}
|
||||
description={
|
||||
isAdmin ? 'Manage customer tickets' : 'Get help with your servers and account'
|
||||
}
|
||||
actions={
|
||||
!isAdmin ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ActionButton variant="secondary" onClick={() => loadTickets()} aria-label="Refresh tickets">
|
||||
<RefreshCw className="w-4 h-4 shrink-0" aria-hidden />
|
||||
<span>Refresh</span>
|
||||
</ActionButton>
|
||||
<ActionButton variant="primary" onClick={() => setIsCreateModalOpen(true)}>
|
||||
<Plus className="w-4 h-4 shrink-0" aria-hidden />
|
||||
<span>New ticket</span>
|
||||
</ActionButton>
|
||||
</div>
|
||||
) : (
|
||||
<ActionButton variant="secondary" onClick={() => loadTickets()} aria-label="Refresh tickets">
|
||||
<RefreshCw className="w-4 h-4 shrink-0" aria-hidden />
|
||||
<span>Refresh</span>
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={dashLayout.bodyPadding}>
|
||||
{/* Stats Overview */}
|
||||
<SectionCard title="Ticket overview" icon={MessageCircle} color={colors.blue}>
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
|
||||
<StatTile label="Total" value={stats.total} accentColor={colors.blue} />
|
||||
<StatTile label="Open" value={stats.open} accentColor={colors.green} />
|
||||
<StatTile label="Pending" value={stats.pending} accentColor={colors.yellow} />
|
||||
<StatTile label="In progress" value={stats.in_progress} accentColor={colors.blue} />
|
||||
<StatTile label="Resolved" value={stats.resolved} accentColor={colors.cyan} />
|
||||
<StatTile label="Closed" value={stats.closed} accentColor={colors.magenta} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* Tabs and Filters */}
|
||||
<SectionCard title="Ticket Management" icon={Filter} color={colors.magenta}>
|
||||
<div className="space-y-6">
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-white/5" style={{ borderColor: colors.brightBlack }}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{[
|
||||
{ id: 'my_tickets', label: 'My Tickets', icon: User },
|
||||
...(isAdmin ? [
|
||||
{ id: 'open_tickets', label: 'Open Tickets', icon: AlertCircle },
|
||||
{ id: 'all_tickets', label: 'All Tickets', icon: Users }
|
||||
] : []),
|
||||
{ id: 'closed_tickets', label: 'Closed Tickets', icon: CheckCircle }
|
||||
].map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className="flex items-center space-x-2 px-4 py-3 text-sm font-medium rounded-t-lg border-b-2 transition-all duration-200"
|
||||
style={{
|
||||
color: isActive ? colors.blue : colors.foreground,
|
||||
borderBottomColor: isActive ? colors.blue : 'transparent',
|
||||
backgroundColor: isActive ? `${colors.blue}08` : 'transparent',
|
||||
opacity: isActive ? 1 : 0.75,
|
||||
}}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2" style={{ color: colors.foreground }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tickets..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 rounded-xl text-sm focus:outline-none transition-all duration-200 border border-white/10 focus:bg-black/40"
|
||||
style={{
|
||||
backgroundColor: colors.black,
|
||||
borderColor: colors.brightBlack,
|
||||
color: colors.brightWhite,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="w-32">
|
||||
<Dropdown
|
||||
value={statusFilter}
|
||||
options={[
|
||||
{ value: 'all', label: 'All Status' },
|
||||
{ value: 'open', label: 'Open' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'in_progress', label: 'In Progress' },
|
||||
{ value: 'resolved', label: 'Resolved' },
|
||||
{ value: 'closed', label: 'Closed' }
|
||||
]}
|
||||
onChange={setStatusFilter}
|
||||
placeholder="Status"
|
||||
icon={Filter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-32">
|
||||
<Dropdown
|
||||
value={priorityFilter}
|
||||
options={[
|
||||
{ value: 'all', label: 'All Priority' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'urgent', label: 'Urgent' },
|
||||
{ value: 'critical', label: 'Critical' }
|
||||
]}
|
||||
onChange={setPriorityFilter}
|
||||
placeholder="Priority"
|
||||
icon={Priority}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* Tickets List */}
|
||||
<SectionCard title={`${filteredTickets.length} Tickets Found`} icon={FileText} color={colors.cyan}>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center h-32">
|
||||
<RefreshCw className="w-8 h-8 animate-spin" style={{ color: colors.blue }} />
|
||||
</div>
|
||||
) : filteredTickets.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<MessageCircle className="w-16 h-16 mx-auto mb-4" style={{ color: colors.foreground, opacity: 0.5 }} />
|
||||
<p className="text-lg mb-4" style={{ color: colors.foreground }}>No tickets found</p>
|
||||
{activeTab === 'my_tickets' && (
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="px-8 py-3 rounded-xl transition-all duration-200 hover:scale-105 border"
|
||||
style={{
|
||||
backgroundColor: colors.blue,
|
||||
borderColor: colors.blue,
|
||||
color: colors.brightWhite
|
||||
}}
|
||||
>
|
||||
Create Your First Ticket
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{filteredTickets.map(ticket => (
|
||||
<TicketCard
|
||||
key={ticket.id}
|
||||
ticket={ticket}
|
||||
onSelect={handleSelectTicket}
|
||||
onAction={handleTicketAction}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
</DashboardViewShell>
|
||||
|
||||
{/* Modals */}
|
||||
<CreateTicketModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSubmit={handleCreateTicket}
|
||||
/>
|
||||
|
||||
{selectedTicket && (
|
||||
<TicketDetailModal
|
||||
ticket={selectedTicket}
|
||||
isOpen={isDetailModalOpen}
|
||||
onClose={() => {
|
||||
setIsDetailModalOpen(false);
|
||||
setSelectedTicket(null);
|
||||
}}
|
||||
onSendMessage={handleSendMessage}
|
||||
onUpdateStatus={handleUpdateStatus}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TicketSystem;
|
||||
Reference in new issue
Block a user