- Altera typePreferences e categoryPreferences de Json para String no Prisma - Atualiza API de preferências para fazer parse/stringify de JSON - Corrige todos os textos sem acentuação nos componentes de notificação 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState, useCallback } from "react"
|
|
import { useParams } from "next/navigation"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Loader2, ExternalLink, CheckCircle, AlertCircle, Clock, MessageSquare } from "lucide-react"
|
|
import { getTicketStatusMeta } from "@/lib/ticket-status-style"
|
|
import { getTicketPriorityMeta } from "@/lib/ticket-priority-style"
|
|
|
|
interface TicketData {
|
|
id: string
|
|
reference: number
|
|
subject: string
|
|
summary: string | null
|
|
status: string
|
|
priority: string
|
|
channel: string
|
|
createdAt: string
|
|
resolvedAt: string | null
|
|
requester: {
|
|
id: string
|
|
name: string
|
|
}
|
|
assignee: {
|
|
id: string
|
|
name: string
|
|
} | null
|
|
company: {
|
|
id: string
|
|
name: string
|
|
} | null
|
|
comments: Array<{
|
|
id: string
|
|
body: string
|
|
createdAt: string
|
|
author: {
|
|
id: string
|
|
name: string
|
|
}
|
|
}>
|
|
rating: {
|
|
rating: number
|
|
comment: string | null
|
|
createdAt: string
|
|
} | null
|
|
}
|
|
|
|
interface TokenData {
|
|
id: string
|
|
scope: string
|
|
expiresAt: string
|
|
used: boolean
|
|
}
|
|
|
|
export default function TicketViewPage() {
|
|
const params = useParams()
|
|
const token = params.token as string
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [ticket, setTicket] = useState<TicketData | null>(null)
|
|
const [tokenData, setTokenData] = useState<TokenData | null>(null)
|
|
const [tryingRaven, setTryingRaven] = useState(true)
|
|
const [ravenAvailable, setRavenAvailable] = useState(false)
|
|
|
|
const loadTicket = useCallback(async () => {
|
|
try {
|
|
setLoading(true)
|
|
const response = await fetch(`/api/ticket-access/${token}`)
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json()
|
|
throw new Error(data.error || "Erro ao carregar chamado")
|
|
}
|
|
|
|
const data = await response.json()
|
|
setTicket(data.ticket)
|
|
setTokenData(data.token)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Erro desconhecido")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [token])
|
|
|
|
// Tenta abrir o protocolo raven://
|
|
useEffect(() => {
|
|
if (!token) return
|
|
|
|
const tryRavenProtocol = async () => {
|
|
setTryingRaven(true)
|
|
|
|
// Tenta abrir o protocolo raven://
|
|
const ravenUrl = `raven://ticket/${token}`
|
|
|
|
// Cria um iframe oculto para detectar se o protocolo foi aceito
|
|
const iframe = document.createElement("iframe")
|
|
iframe.style.display = "none"
|
|
document.body.appendChild(iframe)
|
|
|
|
let protocolHandled = false
|
|
|
|
// Listener para detectar se o navegador aceitou o protocolo
|
|
const handleBlur = () => {
|
|
protocolHandled = true
|
|
setRavenAvailable(true)
|
|
}
|
|
|
|
window.addEventListener("blur", handleBlur)
|
|
|
|
// Tenta navegar para o protocolo
|
|
try {
|
|
window.location.href = ravenUrl
|
|
} catch {
|
|
// Protocolo não suportado
|
|
}
|
|
|
|
// Aguarda 2 segundos para ver se o protocolo foi aceito
|
|
setTimeout(() => {
|
|
window.removeEventListener("blur", handleBlur)
|
|
document.body.removeChild(iframe)
|
|
|
|
if (!protocolHandled) {
|
|
// Protocolo não foi aceito, carrega o ticket no navegador
|
|
setTryingRaven(false)
|
|
loadTicket()
|
|
}
|
|
}, 2000)
|
|
}
|
|
|
|
tryRavenProtocol()
|
|
}, [token, loadTicket])
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
return new Date(dateStr).toLocaleString("pt-BR", {
|
|
timeZone: "America/Sao_Paulo",
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
year: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})
|
|
}
|
|
|
|
// Mostra tela de "Abrindo no Raven..."
|
|
if (tryingRaven) {
|
|
return (
|
|
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
|
<Card className="w-full max-w-md">
|
|
<CardContent className="pt-6 text-center">
|
|
<Loader2 className="h-12 w-12 animate-spin mx-auto mb-4 text-primary" />
|
|
<h2 className="text-xl font-semibold mb-2">Abrindo no Raven...</h2>
|
|
<p className="text-muted-foreground text-sm">
|
|
Se o aplicativo Raven estiver instalado, ele abrirá automaticamente.
|
|
</p>
|
|
<p className="text-muted-foreground text-sm mt-2">
|
|
Caso contrário, o chamado será exibido aqui em instantes.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Mostra loading
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
|
<Card className="w-full max-w-md">
|
|
<CardContent className="pt-6 text-center">
|
|
<Loader2 className="h-12 w-12 animate-spin mx-auto mb-4 text-primary" />
|
|
<h2 className="text-xl font-semibold">Carregando chamado...</h2>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Mostra erro
|
|
if (error) {
|
|
return (
|
|
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
|
<Card className="w-full max-w-md">
|
|
<CardContent className="pt-6 text-center">
|
|
<AlertCircle className="h-12 w-12 mx-auto mb-4 text-destructive" />
|
|
<h2 className="text-xl font-semibold mb-2">Erro ao carregar</h2>
|
|
<p className="text-muted-foreground">{error}</p>
|
|
<Button className="mt-4" onClick={() => loadTicket()}>
|
|
Tentar novamente
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!ticket) return null
|
|
|
|
const statusMeta = getTicketStatusMeta(ticket.status)
|
|
const priorityMeta = getTicketPriorityMeta(ticket.priority)
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background p-4">
|
|
<div className="max-w-3xl mx-auto space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center">
|
|
<span className="text-primary-foreground font-bold text-lg">R</span>
|
|
</div>
|
|
<span className="text-xl font-semibold">Raven</span>
|
|
</div>
|
|
{ravenAvailable && (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<a href={`raven://ticket/${token}`}>
|
|
<ExternalLink className="h-4 w-4 mr-2" />
|
|
Abrir no Raven
|
|
</a>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Ticket Card */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Badge variant="outline" className="font-mono">
|
|
#{ticket.reference}
|
|
</Badge>
|
|
<Badge className={statusMeta.badgeClass}>
|
|
{statusMeta.label}
|
|
</Badge>
|
|
<Badge className={priorityMeta.badgeClass}>
|
|
{priorityMeta.label}
|
|
</Badge>
|
|
</div>
|
|
<CardTitle className="text-xl">{ticket.subject}</CardTitle>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* Informações */}
|
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<span className="text-muted-foreground">Solicitante</span>
|
|
<p className="font-medium">{ticket.requester.name}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">Responsável</span>
|
|
<p className="font-medium">{ticket.assignee?.name ?? "Não atribuído"}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">Criado em</span>
|
|
<p className="font-medium">{formatDate(ticket.createdAt)}</p>
|
|
</div>
|
|
{ticket.resolvedAt && (
|
|
<div>
|
|
<span className="text-muted-foreground">Resolvido em</span>
|
|
<p className="font-medium">{formatDate(ticket.resolvedAt)}</p>
|
|
</div>
|
|
)}
|
|
{ticket.company && (
|
|
<div>
|
|
<span className="text-muted-foreground">Empresa</span>
|
|
<p className="font-medium">{ticket.company.name}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Resumo */}
|
|
{ticket.summary && (
|
|
<div>
|
|
<h3 className="text-sm font-medium text-muted-foreground mb-2">Descrição</h3>
|
|
<p className="text-sm whitespace-pre-wrap">{ticket.summary}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Avaliação */}
|
|
{ticket.rating && (
|
|
<div className="bg-muted/50 rounded-lg p-4">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<CheckCircle className="h-4 w-4 text-emerald-500" />
|
|
<span className="text-sm font-medium">Avaliação</span>
|
|
</div>
|
|
<div className="flex items-center gap-1 mb-2">
|
|
{[1, 2, 3, 4, 5].map((star) => (
|
|
<span
|
|
key={star}
|
|
className={`text-xl ${star <= ticket.rating!.rating ? "text-amber-400" : "text-muted"}`}
|
|
>
|
|
★
|
|
</span>
|
|
))}
|
|
</div>
|
|
{ticket.rating.comment && (
|
|
<p className="text-sm text-muted-foreground">{ticket.rating.comment}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Comentários */}
|
|
{ticket.comments.length > 0 && (
|
|
<div>
|
|
<h3 className="text-sm font-medium text-muted-foreground mb-4 flex items-center gap-2">
|
|
<MessageSquare className="h-4 w-4" />
|
|
Últimas atualizações
|
|
</h3>
|
|
<div className="space-y-4">
|
|
{ticket.comments.map((comment) => (
|
|
<div key={comment.id} className="border-l-2 border-primary pl-4">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-sm font-medium">{comment.author.name}</span>
|
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
|
<Clock className="h-3 w-3" />
|
|
{formatDate(comment.createdAt)}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm whitespace-pre-wrap">{comment.body}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Footer */}
|
|
<p className="text-center text-xs text-muted-foreground">
|
|
Este link expira em {tokenData ? formatDate(tokenData.expiresAt) : "breve"}.
|
|
<br />
|
|
Para acesso completo, utilize o aplicativo Raven ou acesse o portal.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|