erp-construccion-frontend-v2/web/src/pages/admin/calidad/TicketsPage.tsx
Adrian Flores Cortes 55261598a2 [FIX] fix: Resolve TypeScript errors for successful build
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>
2026-02-04 11:36:21 -06:00

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