feat(tickets): add pagination, skeleton loading, create modal, i18n support

This commit is contained in:
Yamamoto 2026-01-05 11:26:45 -03:00
parent 0c38ce1b5f
commit 1b9bd81687
4 changed files with 371 additions and 38 deletions

View file

@ -15,7 +15,7 @@ import { Badge } from "@/components/ui/badge"
import { Ticket, ticketsApi } from "@/lib/api"
import { toast } from "sonner"
import Link from "next/link"
import { MessageSquare, Trash2, Eye } from "lucide-react"
import { Plus, Trash2, Eye, ChevronLeft, ChevronRight } from "lucide-react"
import { format } from "date-fns"
import {
Dialog,
@ -25,11 +25,31 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { useTranslation } from "@/lib/i18n"
const ITEMS_PER_PAGE = 10
export default function AdminTicketsPage() {
const { t } = useTranslation()
const [tickets, setTickets] = useState<Ticket[]>([])
const [loading, setLoading] = useState(true)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [creating, setCreating] = useState(false)
const [newTicket, setNewTicket] = useState({ subject: "", message: "", priority: "medium" })
// Pagination state
const [currentPage, setCurrentPage] = useState(1)
const totalPages = Math.ceil(tickets.length / ITEMS_PER_PAGE)
const paginatedTickets = tickets.slice(
(currentPage - 1) * ITEMS_PER_PAGE,
currentPage * ITEMS_PER_PAGE
)
const fetchTickets = async () => {
try {
@ -38,7 +58,7 @@ export default function AdminTicketsPage() {
setTickets(data)
} catch (error) {
console.error("Failed to fetch tickets", error)
toast.error("Failed to fetch tickets")
toast.error(t("ticketsPage.errors.fetchFailed"))
} finally {
setLoading(false)
}
@ -52,91 +72,150 @@ export default function AdminTicketsPage() {
if (!deleteId) return
try {
await ticketsApi.delete(deleteId)
toast.success("Ticket deleted")
toast.success(t("ticketsPage.messages.deleted"))
setDeleteId(null)
fetchTickets()
} catch (error) {
console.error("Failed to delete ticket", error)
toast.error("Failed to delete ticket")
toast.error(t("ticketsPage.errors.deleteFailed"))
}
}
const handleCreate = async () => {
if (!newTicket.subject.trim()) {
toast.error(t("ticketsPage.errors.subjectRequired"))
return
}
try {
setCreating(true)
await ticketsApi.create(newTicket.subject, newTicket.priority)
toast.success(t("ticketsPage.messages.created"))
setCreateOpen(false)
setNewTicket({ subject: "", message: "", priority: "medium" })
fetchTickets()
} catch (error) {
console.error("Failed to create ticket", error)
toast.error(t("ticketsPage.errors.createFailed"))
} finally {
setCreating(false)
}
}
const getStatusBadge = (status: string) => {
const labels: Record<string, string> = {
open: t("ticketsPage.status.open"),
in_progress: t("ticketsPage.status.inProgress"),
closed: t("ticketsPage.status.closed"),
}
switch (status) {
case "open": return <Badge variant="default" className="bg-green-500">Open</Badge>
case "in_progress": return <Badge variant="secondary" className="bg-yellow-500 text-white">In Progress</Badge>
case "closed": return <Badge variant="outline">Closed</Badge>
case "open": return <Badge variant="default" className="bg-green-500">{labels.open}</Badge>
case "in_progress": return <Badge variant="secondary" className="bg-yellow-500 text-white">{labels.in_progress}</Badge>
case "closed": return <Badge variant="outline">{labels.closed}</Badge>
default: return <Badge variant="outline">{status}</Badge>
}
}
const getPriorityBadge = (priority: string) => {
const labels: Record<string, string> = {
high: t("ticketsPage.priority.high"),
medium: t("ticketsPage.priority.medium"),
low: t("ticketsPage.priority.low"),
}
switch (priority) {
case "high": return <Badge variant="destructive">High</Badge>
case "medium": return <Badge variant="secondary">Medium</Badge>
case "low": return <Badge variant="outline">Low</Badge>
case "high": return <Badge variant="destructive">{labels.high}</Badge>
case "medium": return <Badge variant="secondary">{labels.medium}</Badge>
case "low": return <Badge variant="outline">{labels.low}</Badge>
default: return <Badge variant="outline">{priority}</Badge>
}
}
// Skeleton loader component
const SkeletonRow = () => (
<TableRow>
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
<TableCell><Skeleton className="h-4 w-48" /></TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-5 w-16" /></TableCell>
<TableCell><Skeleton className="h-5 w-14" /></TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-8 w-16 ml-auto" /></TableCell>
</TableRow>
)
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Support Tickets (Admin)</h1>
<p className="text-muted-foreground">Manage all user support tickets.</p>
<h1 className="text-3xl font-bold tracking-tight">{t("ticketsPage.title")}</h1>
<p className="text-muted-foreground">{t("ticketsPage.description")}</p>
</div>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
{t("ticketsPage.newTicket")}
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>All Tickets</CardTitle>
<CardDescription>A list of all support tickets from users.</CardDescription>
<CardTitle>{t("ticketsPage.allTickets")}</CardTitle>
<CardDescription>{t("ticketsPage.allTicketsDescription")}</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Subject</TableHead>
<TableHead>Status</TableHead>
<TableHead>Priority</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead>{t("ticketsPage.table.id")}</TableHead>
<TableHead>{t("ticketsPage.table.subject")}</TableHead>
<TableHead>{t("ticketsPage.table.user")}</TableHead>
<TableHead>{t("ticketsPage.table.status")}</TableHead>
<TableHead>{t("ticketsPage.table.priority")}</TableHead>
<TableHead>{t("ticketsPage.table.created")}</TableHead>
<TableHead className="text-right">{t("ticketsPage.table.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<>
<SkeletonRow />
<SkeletonRow />
<SkeletonRow />
<SkeletonRow />
<SkeletonRow />
</>
) : paginatedTickets.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-4">Loading...</TableCell>
</TableRow>
) : tickets.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-4">No tickets found</TableCell>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
{t("ticketsPage.noTickets")}
</TableCell>
</TableRow>
) : (
tickets.map((ticket) => (
paginatedTickets.map((ticket) => (
<TableRow key={ticket.id}>
<TableCell className="font-mono text-xs max-w-[80px] truncate" title={ticket.id}>
{ticket.id.substring(0, 8)}...
</TableCell>
<TableCell className="font-medium">{ticket.subject}</TableCell>
<TableCell className="font-medium max-w-[200px] truncate" title={ticket.subject}>
{ticket.subject}
</TableCell>
<TableCell className="text-sm" title={ticket.userId}>
{(ticket as any).userFullName || (ticket as any).userName || ticket.userId?.substring(0, 8) + "..."}
</TableCell>
<TableCell>{getStatusBadge(ticket.status)}</TableCell>
<TableCell>{getPriorityBadge(ticket.priority)}</TableCell>
<TableCell>{format(new Date(ticket.createdAt), "MMM d, yyyy")}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Link href={`./tickets/${ticket.id}`}>
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4 mr-2" />
View
<Button variant="ghost" size="icon" title={t("ticketsPage.view")}>
<Eye className="h-4 w-4" />
</Button>
</Link>
<Button
variant="ghost"
size="sm"
size="icon"
onClick={() => setDeleteId(ticket.id)}
className="text-destructive hover:text-destructive"
title={t("ticketsPage.delete")}
>
<Trash2 className="h-4 w-4" />
</Button>
@ -147,20 +226,100 @@ export default function AdminTicketsPage() {
)}
</TableBody>
</Table>
{/* Pagination */}
{!loading && totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<p className="text-sm text-muted-foreground">
{t("ticketsPage.pagination.showing", {
start: (currentPage - 1) * ITEMS_PER_PAGE + 1,
end: Math.min(currentPage * ITEMS_PER_PAGE, tickets.length),
total: tickets.length
})}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="flex items-center px-3 text-sm">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Delete Dialog */}
<Dialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Ticket</DialogTitle>
<DialogTitle>{t("ticketsPage.deleteDialog.title")}</DialogTitle>
<DialogDescription>
Are you sure you want to delete this ticket? This action cannot be undone.
{t("ticketsPage.deleteDialog.description")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button>
<Button variant="destructive" onClick={handleDelete}>Delete</Button>
<Button variant="outline" onClick={() => setDeleteId(null)}>{t("ticketsPage.deleteDialog.cancel")}</Button>
<Button variant="destructive" onClick={handleDelete}>{t("ticketsPage.deleteDialog.confirm")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("ticketsPage.createDialog.title")}</DialogTitle>
<DialogDescription>
{t("ticketsPage.createDialog.description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="subject">{t("ticketsPage.createDialog.subject")}</Label>
<Input
id="subject"
value={newTicket.subject}
onChange={(e) => setNewTicket(prev => ({ ...prev, subject: e.target.value }))}
placeholder={t("ticketsPage.createDialog.subjectPlaceholder")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="priority">{t("ticketsPage.createDialog.priority")}</Label>
<Select
value={newTicket.priority}
onValueChange={(value) => setNewTicket(prev => ({ ...prev, priority: value }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{t("ticketsPage.priority.low")}</SelectItem>
<SelectItem value="medium">{t("ticketsPage.priority.medium")}</SelectItem>
<SelectItem value="high">{t("ticketsPage.priority.high")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>{t("ticketsPage.createDialog.cancel")}</Button>
<Button onClick={handleCreate} disabled={creating}>
{creating ? t("ticketsPage.createDialog.creating") : t("ticketsPage.createDialog.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -875,5 +875,63 @@
"rejected": "Rejected"
}
}
},
"ticketsPage": {
"title": "Support Tickets (Admin)",
"description": "Manage all user support tickets.",
"allTickets": "All Tickets",
"allTicketsDescription": "A list of all support tickets from users.",
"newTicket": "New Ticket",
"noTickets": "No tickets found",
"view": "View",
"delete": "Delete",
"table": {
"id": "ID",
"subject": "Subject",
"user": "User",
"status": "Status",
"priority": "Priority",
"created": "Created",
"actions": "Actions"
},
"status": {
"open": "Open",
"inProgress": "In Progress",
"closed": "Closed"
},
"priority": {
"high": "High",
"medium": "Medium",
"low": "Low"
},
"pagination": {
"showing": "Showing {{start}} to {{end}} of {{total}} tickets"
},
"deleteDialog": {
"title": "Delete Ticket",
"description": "Are you sure you want to delete this ticket? This action cannot be undone.",
"cancel": "Cancel",
"confirm": "Delete"
},
"createDialog": {
"title": "Create New Ticket",
"description": "Fill in the details to create a new support ticket.",
"subject": "Subject",
"subjectPlaceholder": "Describe your issue briefly",
"priority": "Priority",
"cancel": "Cancel",
"create": "Create Ticket",
"creating": "Creating..."
},
"messages": {
"created": "Ticket created successfully",
"deleted": "Ticket deleted successfully"
},
"errors": {
"fetchFailed": "Failed to fetch tickets",
"deleteFailed": "Failed to delete ticket",
"createFailed": "Failed to create ticket",
"subjectRequired": "Subject is required"
}
}
}
}

View file

@ -876,5 +876,63 @@
"rejected": "Rechazado"
}
}
},
"ticketsPage": {
"title": "Tickets de Soporte (Admin)",
"description": "Gestiona todos los tickets de soporte de usuarios.",
"allTickets": "Todos los Tickets",
"allTicketsDescription": "Una lista de todos los tickets de soporte de usuarios.",
"newTicket": "Nuevo Ticket",
"noTickets": "No se encontraron tickets",
"view": "Ver",
"delete": "Eliminar",
"table": {
"id": "ID",
"subject": "Asunto",
"user": "Usuario",
"status": "Estado",
"priority": "Prioridad",
"created": "Creado",
"actions": "Acciones"
},
"status": {
"open": "Abierto",
"inProgress": "En Progreso",
"closed": "Cerrado"
},
"priority": {
"high": "Alta",
"medium": "Media",
"low": "Baja"
},
"pagination": {
"showing": "Mostrando {{start}} a {{end}} de {{total}} tickets"
},
"deleteDialog": {
"title": "Eliminar Ticket",
"description": "¿Estás seguro de que quieres eliminar este ticket? Esta acción no se puede deshacer.",
"cancel": "Cancelar",
"confirm": "Eliminar"
},
"createDialog": {
"title": "Crear Nuevo Ticket",
"description": "Completa los detalles para crear un nuevo ticket de soporte.",
"subject": "Asunto",
"subjectPlaceholder": "Describe brevemente tu problema",
"priority": "Prioridad",
"cancel": "Cancelar",
"create": "Crear Ticket",
"creating": "Creando..."
},
"messages": {
"created": "Ticket creado exitosamente",
"deleted": "Ticket eliminado exitosamente"
},
"errors": {
"fetchFailed": "Error al obtener tickets",
"deleteFailed": "Error al eliminar ticket",
"createFailed": "Error al crear ticket",
"subjectRequired": "El asunto es requerido"
}
}
}
}

View file

@ -875,5 +875,63 @@
"rejected": "Reprovado"
}
}
},
"ticketsPage": {
"title": "Tickets de Suporte (Admin)",
"description": "Gerencie todos os tickets de suporte dos usuários.",
"allTickets": "Todos os Tickets",
"allTicketsDescription": "Uma lista de todos os tickets de suporte dos usuários.",
"newTicket": "Novo Ticket",
"noTickets": "Nenhum ticket encontrado",
"view": "Ver",
"delete": "Excluir",
"table": {
"id": "ID",
"subject": "Assunto",
"user": "Usuário",
"status": "Status",
"priority": "Prioridade",
"created": "Criado",
"actions": "Ações"
},
"status": {
"open": "Aberto",
"inProgress": "Em Andamento",
"closed": "Fechado"
},
"priority": {
"high": "Alta",
"medium": "Média",
"low": "Baixa"
},
"pagination": {
"showing": "Mostrando {{start}} a {{end}} de {{total}} tickets"
},
"deleteDialog": {
"title": "Excluir Ticket",
"description": "Tem certeza de que deseja excluir este ticket? Esta ação não pode ser desfeita.",
"cancel": "Cancelar",
"confirm": "Excluir"
},
"createDialog": {
"title": "Criar Novo Ticket",
"description": "Preencha os detalhes para criar um novo ticket de suporte.",
"subject": "Assunto",
"subjectPlaceholder": "Descreva brevemente seu problema",
"priority": "Prioridade",
"cancel": "Cancelar",
"create": "Criar Ticket",
"creating": "Criando..."
},
"messages": {
"created": "Ticket criado com sucesso",
"deleted": "Ticket excluído com sucesso"
},
"errors": {
"fetchFailed": "Erro ao buscar tickets",
"deleteFailed": "Erro ao excluir ticket",
"createFailed": "Erro ao criar ticket",
"subjectRequired": "O assunto é obrigatório"
}
}
}
}