feat(visits): concluir/reabrir visita sem poluir agenda

This commit is contained in:
Esdras Renan 2025-11-26 14:21:31 -03:00
parent 8f2c00a75a
commit 66559eafbf
9 changed files with 264 additions and 31 deletions

View file

@ -16,7 +16,7 @@ interface TicketQueueSummaryProps {
function resolveSlaTone(percent: number) {
if (percent < 25) {
return { indicatorClass: "bg-[#00e8ff]", textClass: "text-[#00e8ff]" }
return { indicatorClass: "bg-[#00e8ff]", textClass: "text-neutral-500" }
}
if (percent < 50) {
return { indicatorClass: "bg-emerald-400", textClass: "text-emerald-400" }

View file

@ -1,6 +1,7 @@
"use client"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { format, formatDistanceToNow, parseISO } from "date-fns"
import { ptBR } from "date-fns/locale"
@ -108,6 +109,22 @@ const PAUSE_REASONS = [
{ value: "IN_PROCEDURE", label: "Em procedimento" },
{ value: "LUNCH_BREAK", label: "Intervalo de almoço" },
]
const VISIT_STATUS_LABELS: Record<string, string> = {
scheduled: "Agendada",
en_route: "Em deslocamento",
in_service: "Em atendimento",
done: "Concluída",
no_show: "No-show",
canceled: "Cancelada",
}
const VISIT_STATUS_TONES: Record<string, string> = {
scheduled: "bg-slate-100 text-neutral-700 border-slate-200",
en_route: "bg-sky-100 text-sky-800 border-sky-200",
in_service: "bg-amber-100 text-amber-800 border-amber-200",
done: "bg-emerald-100 text-emerald-800 border-emerald-200",
no_show: "bg-rose-100 text-rose-800 border-rose-200",
canceled: "bg-slate-100 text-neutral-600 border-slate-200",
}
type CustomerOption = {
id: string
@ -140,6 +157,7 @@ function formatDuration(durationMs: number) {
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const { convexUserId, role, isStaff, session, machineContext } = useAuth()
const router = useRouter()
const normalizedRole = (role ?? "").toLowerCase()
const isManager = normalizedRole === "manager"
const isAdmin = normalizedRole === "admin"
@ -157,6 +175,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const viewerEmailRaw = session?.user?.email ?? machineContext?.assignedUserEmail ?? null
const viewerEmail = (viewerEmailRaw ?? "").trim().toLowerCase()
const [status, setStatus] = useState<TicketStatus>(ticket.status)
const [visitStatusLoading, setVisitStatusLoading] = useState(false)
const rawReopenDeadline = ticket.reopenDeadline ?? null
const fallbackClosedMs = ticket.closedAt?.getTime() ?? ticket.resolvedAt?.getTime() ?? null
const DEFAULT_REOPEN_DAYS = 7
@ -215,6 +234,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const updateCategories = useMutation(api.tickets.updateCategories)
const reopenTicket = useMutation(api.tickets.reopenTicket)
const updateVisitSchedule = useMutation(api.tickets.updateVisitSchedule)
const updateVisitStatus = useMutation(api.tickets.updateVisitStatus)
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const normalizedTicketQueue = useMemo(() => (ticket.queue ?? "").toLowerCase(), [ticket.queue])
const isVisitQueueTicket = useMemo(
@ -379,6 +399,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
[normalizedQueueSelection],
)
const visitSectionEnabled = editing ? isVisitQueueSelected : isVisitQueueTicket
const visitStatusValue = (ticket.visitStatus as string | null) ?? (ticket.dueAt ? "scheduled" : null)
const visitPerformedAt = ticket.visitPerformedAt ?? null
const visitDirty = useMemo(() => {
if (!visitSectionEnabled) {
return false
@ -469,6 +491,35 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const normalizedAssigneeReason = assigneeChangeReason.trim()
const assigneeReasonValid = normalizedAssigneeReason.length === 0 || normalizedAssigneeReason.length >= 5
const saveDisabled = !formDirty || saving || !assigneeReasonValid || visitHasInvalid
const handleVisitStatusChange = useCallback(
async (nextStatus: string) => {
if (!visitSectionEnabled) return
if (!viewerId) {
toast.error("É necessário estar autenticado para atualizar a visita.")
return
}
const normalizedStatus = nextStatus.toLowerCase()
setVisitStatusLoading(true)
const toastId = "visit-status"
toast.loading("Atualizando status da visita...", { id: toastId })
try {
await updateVisitStatus({
ticketId: ticket.id as Id<"tickets">,
actorId: viewerId as Id<"users">,
status: normalizedStatus,
performedAt: Date.now(),
})
toast.success("Visita atualizada com sucesso.", { id: toastId })
router.refresh()
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar o status da visita.", { id: toastId })
} finally {
setVisitStatusLoading(false)
}
},
[router, ticket.id, updateVisitStatus, viewerId, visitSectionEnabled],
)
const companyLabel = useMemo(() => {
if (ticket.company?.name) return ticket.company.name
if (isAvulso) return "Cliente avulso"
@ -1810,9 +1861,49 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
) : null}
</div>
) : (
<span className={sectionValueClass}>
{ticket.dueAt ? format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR }) : "Sem data definida"}
</span>
<div className="flex flex-col gap-1">
<span className={sectionValueClass}>
{ticket.dueAt ? format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR }) : "Sem data definida"}
</span>
<div className="flex flex-wrap items-center gap-2">
{visitStatusValue ? (
<Badge
variant="outline"
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
VISIT_STATUS_TONES[visitStatusValue] ?? "bg-slate-100 text-neutral-700 border-slate-200",
)}
>
{VISIT_STATUS_LABELS[visitStatusValue] ?? visitStatusValue}
</Badge>
) : null}
{!isManager && visitStatusValue !== "done" ? (
<Button
size="sm"
variant="outline"
onClick={() => handleVisitStatusChange("done")}
disabled={visitStatusLoading}
>
Marcar visita realizada
</Button>
) : null}
{!isManager && visitStatusValue && visitStatusValue !== "scheduled" && visitStatusValue !== "in_service" ? (
<Button
size="sm"
variant="ghost"
onClick={() => handleVisitStatusChange("scheduled")}
disabled={visitStatusLoading}
>
Reabrir visita
</Button>
) : null}
</div>
{visitStatusValue === "done" && visitPerformedAt ? (
<span className="text-xs text-muted-foreground">
Concluída em {format(visitPerformedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
) : null}
</div>
)}
</div>
) : slaDueDate ? (