feat(tickets): add pagination, skeleton loading, create modal, i18n support
This commit is contained in:
parent
0c38ce1b5f
commit
1b9bd81687
4 changed files with 371 additions and 38 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue