feat: Enhance ticketing system with categories, templates, SLA tracking, and fix frontend integration
- Add ticket categories (billing, technical, feature_request, etc.) with type-specific options - Add TicketTemplate and CannedResponse models for quick ticket creation - Implement SLA tracking with due_at and first_response_at fields - Add is_platform_admin and is_customer helper functions to fix permission checks - Register models in Django admin with filters and fieldsets - Enhance signals with error handling for WebSocket notifications - Fix frontend API URLs for templates and canned responses - Update PlatformSupport page to use real ticketing API - Add comprehensive i18n translations for all ticket fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,79 @@
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SUPPORT_TICKETS } from '../../mockData';
|
||||
import { Ticket as TicketIcon, AlertCircle, CheckCircle2, Clock } from 'lucide-react';
|
||||
import { Ticket as TicketIcon, AlertCircle, CheckCircle2, Clock, Circle, Loader2, XCircle, Plus } from 'lucide-react';
|
||||
import { useTickets } from '../../hooks/useTickets';
|
||||
import { Ticket, TicketStatus } from '../../types';
|
||||
import TicketModal from '../../components/TicketModal';
|
||||
import Portal from '../../components/Portal';
|
||||
|
||||
const PlatformSupport: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<TicketStatus | 'ALL'>('ALL');
|
||||
|
||||
// Fetch all tickets (platform admins see all)
|
||||
const { data: tickets = [], isLoading, error } = useTickets(
|
||||
statusFilter !== 'ALL' ? { status: statusFilter } : undefined
|
||||
);
|
||||
|
||||
// Filter to show PLATFORM tickets primarily, but also show all for platform admins
|
||||
const platformTickets = tickets;
|
||||
|
||||
const getStatusIcon = (status: TicketStatus) => {
|
||||
switch (status) {
|
||||
case 'OPEN': return <Circle size={14} className="text-green-500" />;
|
||||
case 'IN_PROGRESS': return <Loader2 size={14} className="text-blue-500 animate-spin" />;
|
||||
case 'AWAITING_RESPONSE': return <Clock size={14} className="text-orange-500" />;
|
||||
case 'RESOLVED': return <CheckCircle2 size={14} className="text-gray-500" />;
|
||||
case 'CLOSED': return <XCircle size={14} className="text-gray-400" />;
|
||||
default: return <AlertCircle size={14} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'URGENT': return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
|
||||
case 'HIGH': return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400';
|
||||
case 'MEDIUM': return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400';
|
||||
case 'LOW': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
|
||||
default: return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const statusTabs: { key: TicketStatus | 'ALL'; label: string }[] = [
|
||||
{ key: 'ALL', label: t('tickets.tabs.all', 'All') },
|
||||
{ key: 'OPEN', label: t('tickets.tabs.open', 'Open') },
|
||||
{ key: 'IN_PROGRESS', label: t('tickets.tabs.inProgress', 'In Progress') },
|
||||
{ key: 'AWAITING_RESPONSE', label: t('tickets.tabs.awaitingResponse', 'Awaiting') },
|
||||
{ key: 'RESOLVED', label: t('tickets.tabs.resolved', 'Resolved') },
|
||||
{ key: 'CLOSED', label: t('tickets.tabs.closed', 'Closed') },
|
||||
];
|
||||
|
||||
const handleTicketClick = (ticket: Ticket) => {
|
||||
setSelectedTicket(ticket);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleNewTicket = () => {
|
||||
setSelectedTicket(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedTicket(null);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<AlertCircle className="mx-auto h-12 w-12 text-red-400" />
|
||||
<p className="mt-4 text-red-600 dark:text-red-400">{t('tickets.errorLoading')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
@@ -13,54 +81,119 @@ const PlatformSupport: React.FC = () => {
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('platform.supportTickets')}</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400">{t('platform.supportDescription')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleNewTicket}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{t('tickets.newTicket')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{SUPPORT_TICKETS.map((ticket) => (
|
||||
<div key={ticket.id} className="bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow cursor-pointer">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-2 rounded-lg shrink-0 ${
|
||||
ticket.priority === 'High' || ticket.priority === 'Critical' ? 'bg-red-100 text-red-600 dark:bg-red-900/20 dark:text-red-400' :
|
||||
ticket.priority === 'Medium' ? 'bg-orange-100 text-orange-600 dark:bg-orange-900/20 dark:text-orange-400' :
|
||||
'bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400'
|
||||
}`}>
|
||||
<TicketIcon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{ticket.subject}</h3>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-600">
|
||||
{ticket.id}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">{t('platform.reportedBy')} <span className="font-medium text-gray-900 dark:text-white">{ticket.businessName}</span></p>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={12} /> {ticket.createdAt.toLocaleDateString()}
|
||||
</span>
|
||||
<span className={`flex items-center gap-1 font-medium ${
|
||||
ticket.status === 'Open' ? 'text-green-600' :
|
||||
ticket.status === 'In Progress' ? 'text-blue-600' : 'text-gray-500'
|
||||
}`}>
|
||||
{ticket.status === 'Open' && <AlertCircle size={12} />}
|
||||
{ticket.status === 'Resolved' && <CheckCircle2 size={12} />}
|
||||
{ticket.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`inline-block px-2 py-1 rounded text-xs font-medium ${
|
||||
ticket.priority === 'High' ? 'bg-red-50 text-red-700 dark:bg-red-900/30' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{ticket.priority} {t('platform.priority')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Status Tabs */}
|
||||
<div className="flex flex-wrap gap-2 border-b border-gray-200 dark:border-gray-700 pb-2">
|
||||
{statusTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setStatusFilter(tab.key)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
statusFilter === tab.key
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-brand-600" />
|
||||
<p className="mt-4 text-gray-500">{t('common.loading')}</p>
|
||||
</div>
|
||||
) : platformTickets.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<TicketIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<p className="mt-4 text-gray-500 dark:text-gray-400">{t('tickets.noTicketsFound')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{platformTickets.map((ticket) => (
|
||||
<div
|
||||
key={ticket.id}
|
||||
onClick={() => handleTicketClick(ticket)}
|
||||
className="bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-2 rounded-lg shrink-0 ${getPriorityColor(ticket.priority)}`}>
|
||||
<TicketIcon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{ticket.subject}</h3>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-600">
|
||||
#{ticket.id}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
ticket.ticketType === 'PLATFORM' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' :
|
||||
ticket.ticketType === 'CUSTOMER' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
}`}>
|
||||
{t(`tickets.types.${ticket.ticketType.toLowerCase()}`)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
{t('platform.reportedBy')} <span className="font-medium text-gray-900 dark:text-white">{ticket.creatorFullName || ticket.creatorEmail}</span>
|
||||
{ticket.category && (
|
||||
<span className="ml-2 text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700">
|
||||
{t(`tickets.categories.${ticket.category.toLowerCase()}`)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={12} /> {new Date(ticket.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 font-medium">
|
||||
{getStatusIcon(ticket.status)}
|
||||
{t(`tickets.statuses.${ticket.status.toLowerCase()}`)}
|
||||
</span>
|
||||
{ticket.assigneeFullName && (
|
||||
<span className="text-gray-400">
|
||||
{t('tickets.assignedTo')}: {ticket.assigneeFullName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`inline-block px-2 py-1 rounded text-xs font-medium ${getPriorityColor(ticket.priority)}`}>
|
||||
{t(`tickets.priorities.${ticket.priority.toLowerCase()}`)}
|
||||
</span>
|
||||
{ticket.isOverdue && (
|
||||
<span className="block mt-1 text-xs text-red-600 dark:text-red-400 font-medium">
|
||||
Overdue
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ticket Modal */}
|
||||
{isModalOpen && (
|
||||
<Portal>
|
||||
<TicketModal
|
||||
ticket={selectedTicket}
|
||||
onClose={handleCloseModal}
|
||||
defaultTicketType="PLATFORM"
|
||||
/>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user