feat(visits): concluir/reabrir visita sem poluir agenda
This commit is contained in:
parent
8f2c00a75a
commit
66559eafbf
9 changed files with 264 additions and 31 deletions
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue