272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useState } from "react"
|
|
import { useMutation } from "convex/react"
|
|
import { useRouter } from "next/navigation"
|
|
import { Star } from "lucide-react"
|
|
import { formatDistanceToNowStrict } from "date-fns"
|
|
import { ptBR } from "date-fns/locale"
|
|
import { toast } from "sonner"
|
|
|
|
import { api } from "@/convex/_generated/api"
|
|
import type { Id } from "@/convex/_generated/dataModel"
|
|
import { useAuth } from "@/lib/auth-client"
|
|
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
|
import { cn } from "@/lib/utils"
|
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
|
|
type TicketCsatCardProps = {
|
|
ticket: TicketWithDetails
|
|
}
|
|
|
|
function formatRelative(timestamp: Date | null | undefined) {
|
|
if (!timestamp) return null
|
|
try {
|
|
return formatDistanceToNowStrict(timestamp, { locale: ptBR, addSuffix: true })
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
|
|
const router = useRouter()
|
|
const { session, convexUserId, role: authRole, machineContext } = useAuth()
|
|
const submitCsat = useMutation(api.tickets.submitCsat)
|
|
|
|
const deriveViewerRole = () => {
|
|
const authRoleNormalized = authRole?.toLowerCase()?.trim()
|
|
const machinePersona = machineContext?.persona ?? session?.user.machinePersona ?? null
|
|
const assignedRole = machineContext?.assignedUserRole ?? null
|
|
const sessionRole = session?.user.role?.toLowerCase()?.trim()
|
|
|
|
if (authRoleNormalized && authRoleNormalized !== "machine") {
|
|
return authRoleNormalized.toUpperCase()
|
|
}
|
|
|
|
if (authRoleNormalized === "machine" && machinePersona) {
|
|
return machinePersona.toUpperCase()
|
|
}
|
|
|
|
if (machinePersona) {
|
|
return machinePersona.toUpperCase()
|
|
}
|
|
|
|
if (assignedRole) {
|
|
return assignedRole.toUpperCase()
|
|
}
|
|
|
|
if (sessionRole && sessionRole !== "machine") {
|
|
return sessionRole.toUpperCase()
|
|
}
|
|
|
|
if (sessionRole === "machine") {
|
|
return "COLLABORATOR"
|
|
}
|
|
|
|
return "COLLABORATOR"
|
|
}
|
|
|
|
const viewerRole = deriveViewerRole()
|
|
|
|
const viewerEmail = session?.user.email?.trim().toLowerCase() ?? ""
|
|
const viewerId = convexUserId as Id<"users"> | undefined
|
|
|
|
const requesterEmail = ticket.requester.email.trim().toLowerCase()
|
|
const isRequesterById = viewerId ? ticket.requester.id === viewerId : false
|
|
const isRequesterByEmail = viewerEmail && requesterEmail ? viewerEmail === requesterEmail : false
|
|
const isRequester = isRequesterById || isRequesterByEmail
|
|
const isResolved = ticket.status === "RESOLVED"
|
|
|
|
const initialScore = typeof ticket.csatScore === "number" ? ticket.csatScore : 0
|
|
const initialComment = ticket.csatComment ?? ""
|
|
const maxScore = typeof ticket.csatMaxScore === "number" && ticket.csatMaxScore > 0 ? ticket.csatMaxScore : 5
|
|
|
|
const [score, setScore] = useState<number>(initialScore)
|
|
const [comment, setComment] = useState<string>(initialComment)
|
|
const [hasSubmitted, setHasSubmitted] = useState<boolean>(initialScore > 0)
|
|
const [ratedAt, setRatedAt] = useState<Date | null>(ticket.csatRatedAt ?? null)
|
|
const [hoverScore, setHoverScore] = useState<number | null>(null)
|
|
const [submitting, setSubmitting] = useState(false)
|
|
|
|
useEffect(() => {
|
|
setScore(initialScore)
|
|
setComment(initialComment)
|
|
setRatedAt(ticket.csatRatedAt ?? null)
|
|
setHasSubmitted(initialScore > 0)
|
|
}, [initialScore, initialComment, ticket.csatRatedAt])
|
|
|
|
const effectiveScore = hasSubmitted ? score : hoverScore ?? score
|
|
const viewerIsStaff = viewerRole === "ADMIN" || viewerRole === "AGENT" || viewerRole === "MANAGER"
|
|
const staffCanInspect = viewerIsStaff && ticket.status !== "PENDING"
|
|
const canSubmit =
|
|
Boolean(viewerId && viewerRole === "COLLABORATOR" && isRequester && isResolved && !hasSubmitted)
|
|
const hasRating = hasSubmitted
|
|
const showCard = staffCanInspect || isRequester || hasSubmitted
|
|
|
|
const ratedAtRelative = useMemo(() => formatRelative(ratedAt), [ratedAt])
|
|
|
|
if (!showCard) {
|
|
return null
|
|
}
|
|
|
|
const handleSubmit = async () => {
|
|
if (!viewerId) {
|
|
toast.error("Sessão não autenticada.")
|
|
return
|
|
}
|
|
if (!canSubmit) {
|
|
toast.error("Você não pode avaliar este chamado.")
|
|
return
|
|
}
|
|
if (score < 1) {
|
|
toast.error("Selecione uma nota de 1 a 5 estrelas.")
|
|
return
|
|
}
|
|
if (comment.length > 2000) {
|
|
toast.error("Reduza o comentário para no máximo 2000 caracteres.")
|
|
return
|
|
}
|
|
try {
|
|
setSubmitting(true)
|
|
const result = await submitCsat({
|
|
ticketId: ticket.id as Id<"tickets">,
|
|
actorId: viewerId,
|
|
score,
|
|
maxScore,
|
|
comment: comment.trim() ? comment.trim() : undefined,
|
|
})
|
|
if (result?.score) {
|
|
setScore(result.score)
|
|
}
|
|
if (typeof result?.comment === "string") {
|
|
setComment(result.comment)
|
|
}
|
|
if (result?.ratedAt) {
|
|
const ratedAtDate = new Date(result.ratedAt)
|
|
if (!Number.isNaN(ratedAtDate.getTime())) {
|
|
setRatedAt(ratedAtDate)
|
|
}
|
|
}
|
|
setHasSubmitted(true)
|
|
toast.success("Avaliação registrada. Obrigado pelo feedback!")
|
|
router.refresh()
|
|
} catch (error) {
|
|
console.error("Failed to submit CSAT", error)
|
|
toast.error("Não foi possível registrar a avaliação. Tente novamente.")
|
|
} finally {
|
|
setSubmitting(false)
|
|
setHoverScore(null)
|
|
}
|
|
}
|
|
|
|
const stars = Array.from({ length: maxScore }, (_, index) => index + 1)
|
|
|
|
return (
|
|
<Card id="csat" className="rounded-2xl border border-slate-200 bg-gradient-to-br from-white via-white to-slate-50 shadow-sm">
|
|
<CardHeader className="px-4 pt-5 pb-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="space-y-1">
|
|
<CardTitle className="text-lg font-semibold text-neutral-900">Avaliação do atendimento</CardTitle>
|
|
<CardDescription className="text-sm text-neutral-600">
|
|
Conte como foi sua experiência com este chamado.
|
|
</CardDescription>
|
|
</div>
|
|
{hasRating ? (
|
|
<div className="flex items-center gap-1 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
|
|
Obrigado pelo feedback!
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4 px-4 pb-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{stars.map((value) => {
|
|
const filled = value <= effectiveScore
|
|
return (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
className={cn(
|
|
"flex size-10 items-center justify-center rounded-full border transition",
|
|
canSubmit
|
|
? filled
|
|
? "border-amber-300 bg-amber-50 text-amber-500 hover:border-amber-400 hover:bg-amber-100"
|
|
: "border-slate-200 bg-white text-slate-300 hover:border-amber-200 hover:bg-amber-50 hover:text-amber-400"
|
|
: filled
|
|
? "border-amber-200 bg-amber-50 text-amber-500"
|
|
: "border-slate-200 bg-white text-slate-300"
|
|
)}
|
|
onMouseEnter={() => (canSubmit ? setHoverScore(value) : undefined)}
|
|
onMouseLeave={() => (canSubmit ? setHoverScore(null) : undefined)}
|
|
onClick={() => (canSubmit ? setScore(value) : undefined)}
|
|
disabled={!canSubmit}
|
|
aria-label={`${value} estrela${value > 1 ? "s" : ""}`}
|
|
>
|
|
<Star
|
|
className="size-5"
|
|
strokeWidth={1.5}
|
|
fill={(canSubmit && value <= (hoverScore ?? score)) || (!canSubmit && value <= score) ? "currentColor" : "none"}
|
|
/>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
{hasRating ? (
|
|
<p className="text-sm text-neutral-600">
|
|
Nota final:{" "}
|
|
<span className="font-semibold text-neutral-900">
|
|
{score}/{maxScore}
|
|
</span>
|
|
{ratedAtRelative ? ` • ${ratedAtRelative}` : null}
|
|
</p>
|
|
) : viewerIsStaff ? (
|
|
<div className="flex items-center gap-2 rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-xs text-neutral-600">
|
|
Nenhuma avaliação registrada ainda.
|
|
</div>
|
|
) : null}
|
|
{canSubmit ? (
|
|
<div className="space-y-2">
|
|
<label htmlFor="csat-comment" className="text-sm font-medium text-neutral-800">
|
|
Deixe um comentário (opcional)
|
|
</label>
|
|
<Textarea
|
|
id="csat-comment"
|
|
placeholder="O que funcionou bem? Algo poderia ser melhor?"
|
|
value={comment}
|
|
onChange={(event) => setComment(event.target.value)}
|
|
maxLength={2000}
|
|
className="min-h-[90px] resize-y"
|
|
/>
|
|
<div className="flex justify-end text-xs text-neutral-500">{comment.length}/2000</div>
|
|
</div>
|
|
) : hasRating && comment ? (
|
|
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700">
|
|
<p className="whitespace-pre-line">{comment}</p>
|
|
</div>
|
|
) : null}
|
|
{viewerIsStaff && !hasRating ? (
|
|
<p className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-sm text-neutral-500">
|
|
Nenhuma avaliação registrada para este chamado até o momento.
|
|
</p>
|
|
) : null}
|
|
{!isResolved && viewerRole === "COLLABORATOR" && isRequester && !hasSubmitted ? (
|
|
<p className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-sm text-neutral-500">
|
|
Assim que o chamado for encerrado, você poderá registrar sua avaliação aqui.
|
|
</p>
|
|
) : null}
|
|
</CardContent>
|
|
{canSubmit ? (
|
|
<CardFooter className="flex flex-col gap-2 px-4 pb-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<p className="text-xs text-neutral-500">
|
|
Sua avaliação ajuda a equipe a melhorar continuamente o atendimento.
|
|
</p>
|
|
<Button type="button" onClick={handleSubmit} disabled={submitting || score < 1}>
|
|
{submitting ? "Enviando..." : "Enviar avaliação"}
|
|
</Button>
|
|
</CardFooter>
|
|
) : null}
|
|
</Card>
|
|
)
|
|
}
|