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:
poduck
2025-11-28 05:32:36 -05:00
parent 512d95ca2d
commit 200a6b3dd4
22 changed files with 1782 additions and 425 deletions

View File

@@ -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>
);
};