Fixes: - Add teal, cyan, slate colors to StatusColor type and StatusBadge - Create StatsCard component with color prop for backward compatibility - Add label/required props to FormGroup component - Fix Pagination to accept both currentPage and page props - Fix unused imports in quality and contracts pages - Add missing Plus, Trash2, User icon imports in contracts pages - Remove duplicate formatDate function in ContratoDetailPage New components: - StatsCard, StatsCardGrid for statistics display Build: Success (npm run build passes) Dev: Success (npm run dev starts on port 3020) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
/**
|
|
* TicketsPage - Lista de tickets de postventa
|
|
*/
|
|
|
|
import { useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
Plus,
|
|
Eye,
|
|
Trash2,
|
|
Ticket,
|
|
Clock,
|
|
AlertTriangle,
|
|
User,
|
|
} from 'lucide-react';
|
|
import {
|
|
useTickets,
|
|
useDeleteTicket,
|
|
useTicketStats,
|
|
} from '../../../hooks/useQuality';
|
|
import type {
|
|
TicketCategory,
|
|
TicketPriority,
|
|
TicketStatus,
|
|
TicketFilters,
|
|
} from '../../../types/quality.types';
|
|
import {
|
|
TICKET_CATEGORY_OPTIONS,
|
|
TICKET_PRIORITY_OPTIONS,
|
|
TICKET_STATUS_OPTIONS,
|
|
} from '../../../types/quality.types';
|
|
import {
|
|
PageHeader,
|
|
PageHeaderAction,
|
|
SearchInput,
|
|
SelectField,
|
|
StatusBadgeFromOptions,
|
|
ConfirmDialog,
|
|
LoadingOverlay,
|
|
EmptyState,
|
|
Pagination,
|
|
StatsCard,
|
|
} from '../../../components/common';
|
|
|
|
export function TicketsPage() {
|
|
const navigate = useNavigate();
|
|
const [search, setSearch] = useState('');
|
|
const [categoryFilter, setCategoryFilter] = useState<TicketCategory | ''>('');
|
|
const [priorityFilter, setPriorityFilter] = useState<TicketPriority | ''>('');
|
|
const [statusFilter, setStatusFilter] = useState<TicketStatus | ''>('');
|
|
const [page, setPage] = useState(1);
|
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
|
|
|
const filters: TicketFilters = {
|
|
search: search || undefined,
|
|
category: categoryFilter || undefined,
|
|
priority: priorityFilter || undefined,
|
|
status: statusFilter || undefined,
|
|
page,
|
|
limit: 10,
|
|
};
|
|
|
|
const { data, isLoading, error } = useTickets(filters);
|
|
const { data: stats } = useTicketStats();
|
|
const deleteMutation = useDeleteTicket();
|
|
|
|
const tickets = data?.items || [];
|
|
const total = data?.total || 0;
|
|
const totalPages = Math.ceil(total / 10);
|
|
|
|
const handleDelete = async () => {
|
|
if (deleteId) {
|
|
await deleteMutation.mutateAsync(deleteId);
|
|
setDeleteId(null);
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
return new Date(dateStr).toLocaleDateString('es-MX', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
});
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <LoadingOverlay message="Cargando tickets..." />;
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<EmptyState
|
|
title="Error al cargar"
|
|
description="No se pudieron cargar los tickets. Intente de nuevo."
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader
|
|
title="Tickets de Postventa"
|
|
description="Gestion de tickets de garantia y servicio postventa"
|
|
actions={
|
|
<PageHeaderAction onClick={() => navigate('/admin/calidad/tickets/nuevo')}>
|
|
<Plus className="w-5 h-5 mr-2" />
|
|
Nuevo Ticket
|
|
</PageHeaderAction>
|
|
}
|
|
/>
|
|
|
|
{/* Stats */}
|
|
{stats && (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
|
|
<StatsCard
|
|
title="Total"
|
|
value={stats.totalTickets}
|
|
icon={<Ticket className="w-5 h-5" />}
|
|
color="blue"
|
|
/>
|
|
<StatsCard
|
|
title="Abiertos"
|
|
value={stats.openTickets}
|
|
icon={<Clock className="w-5 h-5" />}
|
|
color="yellow"
|
|
/>
|
|
<StatsCard
|
|
title="En Progreso"
|
|
value={stats.inProgressTickets}
|
|
icon={<User className="w-5 h-5" />}
|
|
color="blue"
|
|
/>
|
|
<StatsCard
|
|
title="Resueltos"
|
|
value={stats.resolvedTickets}
|
|
icon={<Ticket className="w-5 h-5" />}
|
|
color="green"
|
|
/>
|
|
<StatsCard
|
|
title="SLA Incumplido"
|
|
value={stats.slaBreach}
|
|
icon={<AlertTriangle className="w-5 h-5" />}
|
|
color="red"
|
|
/>
|
|
<StatsCard
|
|
title="Satisfaccion"
|
|
value={`${stats.avgSatisfaction?.toFixed(1) || '-'}/5`}
|
|
icon={<Ticket className="w-5 h-5" />}
|
|
color="purple"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
|
<SearchInput
|
|
value={search}
|
|
onChange={setSearch}
|
|
placeholder="Buscar por numero o titulo..."
|
|
className="lg:col-span-2"
|
|
/>
|
|
<SelectField
|
|
options={[
|
|
{ value: '', label: 'Todas las categorias' },
|
|
...TICKET_CATEGORY_OPTIONS.map(o => ({ value: o.value, label: o.label })),
|
|
]}
|
|
value={categoryFilter}
|
|
onChange={(e) => setCategoryFilter(e.target.value as TicketCategory | '')}
|
|
/>
|
|
<SelectField
|
|
options={[
|
|
{ value: '', label: 'Todas las prioridades' },
|
|
...TICKET_PRIORITY_OPTIONS.map(o => ({ value: o.value, label: o.label })),
|
|
]}
|
|
value={priorityFilter}
|
|
onChange={(e) => setPriorityFilter(e.target.value as TicketPriority | '')}
|
|
/>
|
|
<SelectField
|
|
options={[
|
|
{ value: '', label: 'Todos los estados' },
|
|
...TICKET_STATUS_OPTIONS.map(o => ({ value: o.value, label: o.label })),
|
|
]}
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value as TicketStatus | '')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden">
|
|
{tickets.length === 0 ? (
|
|
<EmptyState
|
|
icon={<Ticket className="w-12 h-12 text-gray-400" />}
|
|
title="No hay tickets"
|
|
description="No se encontraron tickets con los filtros seleccionados."
|
|
/>
|
|
) : (
|
|
<>
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Ticket
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Categoria
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Prioridad
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Estado
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
SLA
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Creado
|
|
</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Acciones
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
{tickets.map((ticket) => (
|
|
<tr key={ticket.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center">
|
|
<Ticket className="w-5 h-5 text-gray-400 mr-3" />
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
{ticket.ticketNumber}
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400 max-w-xs truncate">
|
|
{ticket.title}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<StatusBadgeFromOptions
|
|
value={ticket.category}
|
|
options={[...TICKET_CATEGORY_OPTIONS]}
|
|
/>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<StatusBadgeFromOptions
|
|
value={ticket.priority}
|
|
options={[...TICKET_PRIORITY_OPTIONS]}
|
|
/>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<StatusBadgeFromOptions
|
|
value={ticket.status}
|
|
options={[...TICKET_STATUS_OPTIONS]}
|
|
/>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
{ticket.slaBreached ? (
|
|
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
|
<AlertTriangle className="w-3 h-3" />
|
|
Incumplido
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
|
<Clock className="w-3 h-3" />
|
|
{ticket.slaHours}h
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
|
{formatDate(ticket.createdAt)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<button
|
|
onClick={() => navigate(`/admin/calidad/tickets/${ticket.id}`)}
|
|
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
|
|
title="Ver detalle"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => setDeleteId(ticket.id)}
|
|
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
|
|
title="Eliminar"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{totalPages > 1 && (
|
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
|
<Pagination
|
|
page={page}
|
|
totalPages={totalPages}
|
|
onPageChange={setPage}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Delete Confirmation */}
|
|
<ConfirmDialog
|
|
isOpen={!!deleteId}
|
|
onClose={() => setDeleteId(null)}
|
|
onConfirm={handleDelete}
|
|
title="Eliminar Ticket"
|
|
message="Esta seguro de eliminar este ticket? Esta accion no se puede deshacer."
|
|
confirmLabel="Eliminar"
|
|
variant="danger"
|
|
isLoading={deleteMutation.isPending}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|