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
|
|
@ -273,6 +273,8 @@ export default defineSchema({
|
||||||
slaPausedBy: v.optional(v.string()),
|
slaPausedBy: v.optional(v.string()),
|
||||||
slaPausedMs: v.optional(v.number()),
|
slaPausedMs: v.optional(v.number()),
|
||||||
dueAt: v.optional(v.number()), // ms since epoch
|
dueAt: v.optional(v.number()), // ms since epoch
|
||||||
|
visitStatus: v.optional(v.string()),
|
||||||
|
visitPerformedAt: v.optional(v.number()),
|
||||||
firstResponseAt: v.optional(v.number()),
|
firstResponseAt: v.optional(v.number()),
|
||||||
resolvedAt: v.optional(v.number()),
|
resolvedAt: v.optional(v.number()),
|
||||||
closedAt: v.optional(v.number()),
|
closedAt: v.optional(v.number()),
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,8 @@ const MAX_COMMENT_CHARS = 20000;
|
||||||
const DEFAULT_REOPEN_DAYS = 7;
|
const DEFAULT_REOPEN_DAYS = 7;
|
||||||
const MAX_REOPEN_DAYS = 14;
|
const MAX_REOPEN_DAYS = 14;
|
||||||
const VISIT_QUEUE_KEYWORDS = ["visita", "visitas", "in loco", "laboratório", "laboratorio", "lab"];
|
const VISIT_QUEUE_KEYWORDS = ["visita", "visitas", "in loco", "laboratório", "laboratorio", "lab"];
|
||||||
|
const VISIT_STATUSES = new Set(["scheduled", "en_route", "in_service", "done", "no_show", "canceled"]);
|
||||||
|
const VISIT_COMPLETED_STATUSES = new Set(["done", "no_show", "canceled"]);
|
||||||
|
|
||||||
type AnyCtx = QueryCtx | MutationCtx;
|
type AnyCtx = QueryCtx | MutationCtx;
|
||||||
|
|
||||||
|
|
@ -2003,6 +2005,8 @@ export const getById = query({
|
||||||
resolvedWithTicketId: t.resolvedWithTicketId ? String(t.resolvedWithTicketId) : null,
|
resolvedWithTicketId: t.resolvedWithTicketId ? String(t.resolvedWithTicketId) : null,
|
||||||
reopenDeadline: t.reopenDeadline ?? null,
|
reopenDeadline: t.reopenDeadline ?? null,
|
||||||
reopenedAt: t.reopenedAt ?? null,
|
reopenedAt: t.reopenedAt ?? null,
|
||||||
|
visitStatus: t.visitStatus ?? null,
|
||||||
|
visitPerformedAt: t.visitPerformedAt ?? null,
|
||||||
description: undefined,
|
description: undefined,
|
||||||
customFields: customFieldsRecord,
|
customFields: customFieldsRecord,
|
||||||
timeline: timelineRecords.map((ev) => {
|
timeline: timelineRecords.map((ev) => {
|
||||||
|
|
@ -2247,6 +2251,8 @@ export const create = mutation({
|
||||||
slaPolicyId: undefined,
|
slaPolicyId: undefined,
|
||||||
dueAt: visitDueAt && isVisitQueue ? visitDueAt : undefined,
|
dueAt: visitDueAt && isVisitQueue ? visitDueAt : undefined,
|
||||||
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
|
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
|
||||||
|
visitStatus: isVisitQueue ? "scheduled" : undefined,
|
||||||
|
visitPerformedAt: undefined,
|
||||||
...slaFields,
|
...slaFields,
|
||||||
});
|
});
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
|
@ -3546,6 +3552,8 @@ export const updateVisitSchedule = mutation({
|
||||||
const actor = viewer.user
|
const actor = viewer.user
|
||||||
await ctx.db.patch(ticketId, {
|
await ctx.db.patch(ticketId, {
|
||||||
dueAt: visitDate,
|
dueAt: visitDate,
|
||||||
|
visitStatus: "scheduled",
|
||||||
|
visitPerformedAt: undefined,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
|
@ -3564,6 +3572,69 @@ export const updateVisitSchedule = mutation({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const updateVisitStatus = mutation({
|
||||||
|
args: {
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
actorId: v.id("users"),
|
||||||
|
status: v.string(),
|
||||||
|
performedAt: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { ticketId, actorId, status, performedAt }) => {
|
||||||
|
const ticket = await ctx.db.get(ticketId)
|
||||||
|
if (!ticket) {
|
||||||
|
throw new ConvexError("Ticket não encontrado")
|
||||||
|
}
|
||||||
|
const ticketDoc = ticket as Doc<"tickets">
|
||||||
|
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
|
||||||
|
if (viewer.role === "MANAGER") {
|
||||||
|
throw new ConvexError("Gestores não podem alterar o status da visita")
|
||||||
|
}
|
||||||
|
const normalizedStatus = (status ?? "").toLowerCase()
|
||||||
|
if (!VISIT_STATUSES.has(normalizedStatus)) {
|
||||||
|
throw new ConvexError("Status da visita inválido")
|
||||||
|
}
|
||||||
|
if (!ticketDoc.queueId) {
|
||||||
|
throw new ConvexError("Este ticket não possui fila configurada")
|
||||||
|
}
|
||||||
|
const queue = (await ctx.db.get(ticketDoc.queueId)) as Doc<"queues"> | null
|
||||||
|
if (!queue) {
|
||||||
|
throw new ConvexError("Fila não encontrada para este ticket")
|
||||||
|
}
|
||||||
|
const queueLabel = (normalizeQueueName(queue) ?? queue.name ?? "").toLowerCase()
|
||||||
|
const isVisitQueue = VISIT_QUEUE_KEYWORDS.some((keyword) => queueLabel.includes(keyword))
|
||||||
|
if (!isVisitQueue) {
|
||||||
|
throw new ConvexError("Somente tickets da fila de visitas possuem status de visita")
|
||||||
|
}
|
||||||
|
const now = Date.now()
|
||||||
|
const completed = VISIT_COMPLETED_STATUSES.has(normalizedStatus)
|
||||||
|
const resolvedPerformedAt =
|
||||||
|
completed && typeof performedAt === "number" && Number.isFinite(performedAt) ? performedAt : completed ? now : undefined
|
||||||
|
|
||||||
|
await ctx.db.patch(ticketId, {
|
||||||
|
visitStatus: normalizedStatus,
|
||||||
|
visitPerformedAt: completed ? resolvedPerformedAt : undefined,
|
||||||
|
// Mantemos dueAt para não perder o agendamento original.
|
||||||
|
dueAt: ticketDoc.dueAt,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId,
|
||||||
|
type: "VISIT_STATUS_CHANGED",
|
||||||
|
payload: {
|
||||||
|
status: normalizedStatus,
|
||||||
|
performedAt: resolvedPerformedAt,
|
||||||
|
actorId,
|
||||||
|
actorName: viewer.user.name,
|
||||||
|
actorAvatar: viewer.user.avatarUrl ?? undefined,
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { status: normalizedStatus }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const changeQueue = mutation({
|
export const changeQueue = mutation({
|
||||||
args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") },
|
args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") },
|
||||||
handler: async (ctx, { ticketId, queueId, actorId }) => {
|
handler: async (ctx, { ticketId, queueId, actorId }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
import type { Prisma, UserRole } from "@/lib/prisma"
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { ConvexHttpClient } from "convex/browser"
|
import { ConvexHttpClient } from "convex/browser"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
@ -14,7 +13,8 @@ function normalizeRole(input: string | null | undefined): RoleOption {
|
||||||
return ((ROLE_OPTIONS as readonly string[]).includes(candidate) ? candidate : "agent") as RoleOption
|
return ((ROLE_OPTIONS as readonly string[]).includes(candidate) ? candidate : "agent") as RoleOption
|
||||||
}
|
}
|
||||||
|
|
||||||
const USER_ROLE_OPTIONS: ReadonlyArray<UserRole> = ["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]
|
const USER_ROLE_OPTIONS = ["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"] as const
|
||||||
|
type UserRole = (typeof USER_ROLE_OPTIONS)[number]
|
||||||
|
|
||||||
function mapToUserRole(role: RoleOption): UserRole {
|
function mapToUserRole(role: RoleOption): UserRole {
|
||||||
const candidate = role.toUpperCase() as UserRole
|
const candidate = role.toUpperCase() as UserRole
|
||||||
|
|
@ -207,7 +207,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
|
||||||
}
|
}
|
||||||
|
|
||||||
if (domainUser) {
|
if (domainUser) {
|
||||||
const updateData: Prisma.UserUncheckedUpdateInput = {
|
const updateData: Record<string, unknown> = {
|
||||||
email: nextEmail,
|
email: nextEmail,
|
||||||
name: nextName || domainUser.name,
|
name: nextName || domainUser.name,
|
||||||
role: mapToUserRole(nextRole),
|
role: mapToUserRole(nextRole),
|
||||||
|
|
@ -225,30 +225,34 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
|
||||||
data: updateData,
|
data: updateData,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const upsertUpdate: Prisma.UserUncheckedUpdateInput = {
|
const upsertUpdate = {
|
||||||
name: nextName || nextEmail,
|
name: nextName || nextEmail,
|
||||||
role: mapToUserRole(nextRole),
|
role: mapToUserRole(nextRole),
|
||||||
tenantId: nextTenant,
|
tenantId: nextTenant,
|
||||||
companyId: companyId ?? null,
|
companyId: companyId ?? null,
|
||||||
|
jobTitle: hasJobTitleField ? jobTitle ?? null : undefined,
|
||||||
|
managerId: hasManagerField ? managerRecord?.id ?? null : undefined,
|
||||||
}
|
}
|
||||||
if (hasJobTitleField) {
|
if (hasJobTitleField) {
|
||||||
upsertUpdate.jobTitle = jobTitle ?? null
|
// noop: já definido acima
|
||||||
}
|
}
|
||||||
if (hasManagerField) {
|
if (hasManagerField) {
|
||||||
upsertUpdate.managerId = managerRecord?.id ?? null
|
// noop: já definido acima
|
||||||
}
|
}
|
||||||
const upsertCreate: Prisma.UserUncheckedCreateInput = {
|
const upsertCreate = {
|
||||||
email: nextEmail,
|
email: nextEmail,
|
||||||
name: nextName || nextEmail,
|
name: nextName || nextEmail,
|
||||||
role: mapToUserRole(nextRole),
|
role: mapToUserRole(nextRole),
|
||||||
tenantId: nextTenant,
|
tenantId: nextTenant,
|
||||||
companyId: companyId ?? null,
|
companyId: companyId ?? null,
|
||||||
|
jobTitle: hasJobTitleField ? jobTitle ?? null : undefined,
|
||||||
|
managerId: hasManagerField ? managerRecord?.id ?? null : undefined,
|
||||||
}
|
}
|
||||||
if (hasJobTitleField) {
|
if (hasJobTitleField) {
|
||||||
upsertCreate.jobTitle = jobTitle ?? null
|
// noop: já definido acima
|
||||||
}
|
}
|
||||||
if (hasManagerField) {
|
if (hasManagerField) {
|
||||||
upsertCreate.managerId = managerRecord?.id ?? null
|
// noop: já definido acima
|
||||||
}
|
}
|
||||||
await prisma.user.upsert({
|
await prisma.user.upsert({
|
||||||
where: { email: nextEmail },
|
where: { email: nextEmail },
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ interface TicketQueueSummaryProps {
|
||||||
|
|
||||||
function resolveSlaTone(percent: number) {
|
function resolveSlaTone(percent: number) {
|
||||||
if (percent < 25) {
|
if (percent < 25) {
|
||||||
return { indicatorClass: "bg-[#00e8ff]", textClass: "text-[#00e8ff]" }
|
return { indicatorClass: "bg-[#00e8ff]", textClass: "text-neutral-500" }
|
||||||
}
|
}
|
||||||
if (percent < 50) {
|
if (percent < 50) {
|
||||||
return { indicatorClass: "bg-emerald-400", textClass: "text-emerald-400" }
|
return { indicatorClass: "bg-emerald-400", textClass: "text-emerald-400" }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { format, formatDistanceToNow, parseISO } from "date-fns"
|
import { format, formatDistanceToNow, parseISO } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
|
|
@ -108,6 +109,22 @@ const PAUSE_REASONS = [
|
||||||
{ value: "IN_PROCEDURE", label: "Em procedimento" },
|
{ value: "IN_PROCEDURE", label: "Em procedimento" },
|
||||||
{ value: "LUNCH_BREAK", label: "Intervalo de almoço" },
|
{ 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 = {
|
type CustomerOption = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -140,6 +157,7 @@ function formatDuration(durationMs: number) {
|
||||||
|
|
||||||
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const { convexUserId, role, isStaff, session, machineContext } = useAuth()
|
const { convexUserId, role, isStaff, session, machineContext } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
const normalizedRole = (role ?? "").toLowerCase()
|
const normalizedRole = (role ?? "").toLowerCase()
|
||||||
const isManager = normalizedRole === "manager"
|
const isManager = normalizedRole === "manager"
|
||||||
const isAdmin = normalizedRole === "admin"
|
const isAdmin = normalizedRole === "admin"
|
||||||
|
|
@ -157,6 +175,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const viewerEmailRaw = session?.user?.email ?? machineContext?.assignedUserEmail ?? null
|
const viewerEmailRaw = session?.user?.email ?? machineContext?.assignedUserEmail ?? null
|
||||||
const viewerEmail = (viewerEmailRaw ?? "").trim().toLowerCase()
|
const viewerEmail = (viewerEmailRaw ?? "").trim().toLowerCase()
|
||||||
const [status, setStatus] = useState<TicketStatus>(ticket.status)
|
const [status, setStatus] = useState<TicketStatus>(ticket.status)
|
||||||
|
const [visitStatusLoading, setVisitStatusLoading] = useState(false)
|
||||||
const rawReopenDeadline = ticket.reopenDeadline ?? null
|
const rawReopenDeadline = ticket.reopenDeadline ?? null
|
||||||
const fallbackClosedMs = ticket.closedAt?.getTime() ?? ticket.resolvedAt?.getTime() ?? null
|
const fallbackClosedMs = ticket.closedAt?.getTime() ?? ticket.resolvedAt?.getTime() ?? null
|
||||||
const DEFAULT_REOPEN_DAYS = 7
|
const DEFAULT_REOPEN_DAYS = 7
|
||||||
|
|
@ -215,6 +234,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const updateCategories = useMutation(api.tickets.updateCategories)
|
const updateCategories = useMutation(api.tickets.updateCategories)
|
||||||
const reopenTicket = useMutation(api.tickets.reopenTicket)
|
const reopenTicket = useMutation(api.tickets.reopenTicket)
|
||||||
const updateVisitSchedule = useMutation(api.tickets.updateVisitSchedule)
|
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 agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
|
||||||
const normalizedTicketQueue = useMemo(() => (ticket.queue ?? "").toLowerCase(), [ticket.queue])
|
const normalizedTicketQueue = useMemo(() => (ticket.queue ?? "").toLowerCase(), [ticket.queue])
|
||||||
const isVisitQueueTicket = useMemo(
|
const isVisitQueueTicket = useMemo(
|
||||||
|
|
@ -379,6 +399,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
[normalizedQueueSelection],
|
[normalizedQueueSelection],
|
||||||
)
|
)
|
||||||
const visitSectionEnabled = editing ? isVisitQueueSelected : isVisitQueueTicket
|
const visitSectionEnabled = editing ? isVisitQueueSelected : isVisitQueueTicket
|
||||||
|
const visitStatusValue = (ticket.visitStatus as string | null) ?? (ticket.dueAt ? "scheduled" : null)
|
||||||
|
const visitPerformedAt = ticket.visitPerformedAt ?? null
|
||||||
const visitDirty = useMemo(() => {
|
const visitDirty = useMemo(() => {
|
||||||
if (!visitSectionEnabled) {
|
if (!visitSectionEnabled) {
|
||||||
return false
|
return false
|
||||||
|
|
@ -469,6 +491,35 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const normalizedAssigneeReason = assigneeChangeReason.trim()
|
const normalizedAssigneeReason = assigneeChangeReason.trim()
|
||||||
const assigneeReasonValid = normalizedAssigneeReason.length === 0 || normalizedAssigneeReason.length >= 5
|
const assigneeReasonValid = normalizedAssigneeReason.length === 0 || normalizedAssigneeReason.length >= 5
|
||||||
const saveDisabled = !formDirty || saving || !assigneeReasonValid || visitHasInvalid
|
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(() => {
|
const companyLabel = useMemo(() => {
|
||||||
if (ticket.company?.name) return ticket.company.name
|
if (ticket.company?.name) return ticket.company.name
|
||||||
if (isAvulso) return "Cliente avulso"
|
if (isAvulso) return "Cliente avulso"
|
||||||
|
|
@ -1810,9 +1861,49 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className={sectionValueClass}>
|
<div className="flex flex-col gap-1">
|
||||||
{ticket.dueAt ? format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR }) : "Sem data definida"}
|
<span className={sectionValueClass}>
|
||||||
</span>
|
{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>
|
</div>
|
||||||
) : slaDueDate ? (
|
) : slaDueDate ? (
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,17 @@ import {
|
||||||
|
|
||||||
import type { Ticket, TicketPriority } from "@/lib/schemas/ticket"
|
import type { Ticket, TicketPriority } from "@/lib/schemas/ticket"
|
||||||
import type { AgendaFilterState, AgendaPeriod } from "@/components/agenda/agenda-filters"
|
import type { AgendaFilterState, AgendaPeriod } from "@/components/agenda/agenda-filters"
|
||||||
import { getSlaDisplayStatus, getSlaDueDate } from "@/lib/sla-utils"
|
import { getSlaDueDate } from "@/lib/sla-utils"
|
||||||
import { isVisitTicket } from "@/lib/ticket-matchers"
|
import { isVisitTicket } from "@/lib/ticket-matchers"
|
||||||
|
|
||||||
export type AgendaSlaStatus = "on_track" | "at_risk" | "breached" | "met"
|
export type AgendaSlaStatus = "on_track" | "at_risk" | "breached" | "met"
|
||||||
|
type VisitStatus = NonNullable<Ticket["visitStatus"]>
|
||||||
|
type VisitState = {
|
||||||
|
status: VisitStatus | null
|
||||||
|
scheduledAt: Date | null
|
||||||
|
performedAt: Date | null
|
||||||
|
completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type AgendaTicketSummary = {
|
export type AgendaTicketSummary = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -30,6 +37,7 @@ export type AgendaTicketSummary = {
|
||||||
startAt: Date | null
|
startAt: Date | null
|
||||||
endAt: Date | null
|
endAt: Date | null
|
||||||
slaStatus: AgendaSlaStatus
|
slaStatus: AgendaSlaStatus
|
||||||
|
visitStatus?: VisitStatus | null
|
||||||
completedAt?: Date | null
|
completedAt?: Date | null
|
||||||
href: string
|
href: string
|
||||||
}
|
}
|
||||||
|
|
@ -85,8 +93,9 @@ export function buildAgendaDataset(tickets: Ticket[], filters: AgendaFilterState
|
||||||
|
|
||||||
const enriched = filteredTickets.map((ticket) => {
|
const enriched = filteredTickets.map((ticket) => {
|
||||||
const schedule = deriveScheduleWindow(ticket)
|
const schedule = deriveScheduleWindow(ticket)
|
||||||
const slaStatus = computeSlaStatus(ticket, now)
|
const visitState = deriveVisitState(ticket)
|
||||||
return { ticket, schedule, slaStatus }
|
const slaStatus = computeVisitSlaStatus(visitState, now)
|
||||||
|
return { ticket, schedule, visitState, slaStatus }
|
||||||
})
|
})
|
||||||
|
|
||||||
const summarySections = {
|
const summarySections = {
|
||||||
|
|
@ -99,12 +108,21 @@ export function buildAgendaDataset(tickets: Ticket[], filters: AgendaFilterState
|
||||||
const upcomingWindowEnd = addMonths(now, 12)
|
const upcomingWindowEnd = addMonths(now, 12)
|
||||||
|
|
||||||
for (const entry of enriched) {
|
for (const entry of enriched) {
|
||||||
const summary = buildSummary(entry.ticket, entry.schedule, entry.slaStatus)
|
const summary = buildSummary(entry.ticket, entry.schedule, entry.visitState, entry.slaStatus)
|
||||||
const dueDate = entry.schedule.startAt
|
const dueDate = entry.schedule.startAt
|
||||||
const createdAt = entry.ticket.createdAt
|
const createdAt = entry.ticket.createdAt
|
||||||
const resolvedAt = entry.ticket.resolvedAt
|
|
||||||
|
|
||||||
if (dueDate && !entry.ticket.resolvedAt) {
|
const isCompleted = entry.visitState.completed
|
||||||
|
const visitCompletedAt = entry.visitState.completed ? entry.visitState.performedAt ?? entry.visitState.scheduledAt : null
|
||||||
|
|
||||||
|
if (isCompleted) {
|
||||||
|
if (visitCompletedAt && isWithinRange(visitCompletedAt, range)) {
|
||||||
|
summarySections.completed.push(summary)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dueDate) {
|
||||||
const isFuture = isAfter(dueDate, now)
|
const isFuture = isAfter(dueDate, now)
|
||||||
if (isFuture && isBefore(dueDate, upcomingWindowEnd)) {
|
if (isFuture && isBefore(dueDate, upcomingWindowEnd)) {
|
||||||
summarySections.upcoming.push(summary)
|
summarySections.upcoming.push(summary)
|
||||||
|
|
@ -117,10 +135,6 @@ export function buildAgendaDataset(tickets: Ticket[], filters: AgendaFilterState
|
||||||
if (!dueDate && entry.ticket.status !== "RESOLVED" && isWithinRange(createdAt, range)) {
|
if (!dueDate && entry.ticket.status !== "RESOLVED" && isWithinRange(createdAt, range)) {
|
||||||
summarySections.unscheduled.push(summary)
|
summarySections.unscheduled.push(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedAt && isWithinRange(resolvedAt, range)) {
|
|
||||||
summarySections.completed.push(summary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
summarySections.upcoming.sort((a, b) => compareNullableDate(a.startAt, b.startAt, 1))
|
summarySections.upcoming.sort((a, b) => compareNullableDate(a.startAt, b.startAt, 1))
|
||||||
|
|
@ -129,7 +143,10 @@ export function buildAgendaDataset(tickets: Ticket[], filters: AgendaFilterState
|
||||||
summarySections.completed.sort((a, b) => compareNullableDate(a.completedAt ?? null, b.completedAt ?? null, -1))
|
summarySections.completed.sort((a, b) => compareNullableDate(a.completedAt ?? null, b.completedAt ?? null, -1))
|
||||||
|
|
||||||
const calendarEvents = enriched
|
const calendarEvents = enriched
|
||||||
.filter((entry): entry is typeof entry & { schedule: { startAt: Date; endAt: Date } } => Boolean(entry.schedule.startAt && entry.schedule.endAt))
|
.filter(
|
||||||
|
(entry): entry is typeof entry & { schedule: { startAt: Date; endAt: Date } } =>
|
||||||
|
Boolean(entry.schedule.startAt && entry.schedule.endAt),
|
||||||
|
)
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
id: `${entry.ticket.id}-event`,
|
id: `${entry.ticket.id}-event`,
|
||||||
ticketId: entry.ticket.id,
|
ticketId: entry.ticket.id,
|
||||||
|
|
@ -217,15 +234,51 @@ function deriveScheduleWindow(ticket: Ticket) {
|
||||||
return { startAt, endAt }
|
return { startAt, endAt }
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeSlaStatus(ticket: Ticket, now: Date): AgendaSlaStatus {
|
function deriveVisitState(ticket: Ticket): VisitState {
|
||||||
const status = getSlaDisplayStatus(ticket, "solution", now)
|
const scheduledAt = ticket.dueAt ?? null
|
||||||
if (status === "n/a") {
|
const rawStatus = (ticket.visitStatus ?? null) as VisitStatus | null
|
||||||
return "on_track"
|
const performedAt = ticket.visitPerformedAt ?? null
|
||||||
|
// Se já existe registro de execução, considere concluída mesmo que o status não tenha sido persistido.
|
||||||
|
if (!rawStatus && performedAt) {
|
||||||
|
return { status: "done", scheduledAt, performedAt, completed: true }
|
||||||
|
}
|
||||||
|
if (!rawStatus) {
|
||||||
|
return { status: null, scheduledAt, performedAt: null, completed: false }
|
||||||
|
}
|
||||||
|
const normalized = rawStatus.toLowerCase() as VisitStatus
|
||||||
|
const completed = normalized === "done" || normalized === "no_show" || normalized === "canceled"
|
||||||
|
// Se reabrir (status != concluído), zera performedAt.
|
||||||
|
const effectivePerformed = completed ? performedAt : null
|
||||||
|
return {
|
||||||
|
status: normalized,
|
||||||
|
scheduledAt,
|
||||||
|
performedAt: effectivePerformed,
|
||||||
|
completed,
|
||||||
}
|
}
|
||||||
return status
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSummary(ticket: Ticket, schedule: { startAt: Date | null; endAt: Date | null }, slaStatus: AgendaSlaStatus): AgendaTicketSummary {
|
function computeVisitSlaStatus(visitState: VisitState, now: Date): AgendaSlaStatus {
|
||||||
|
if (visitState.completed) {
|
||||||
|
if (visitState.status === "done") return "met"
|
||||||
|
if (visitState.status === "canceled") return "on_track"
|
||||||
|
return "breached"
|
||||||
|
}
|
||||||
|
if (visitState.scheduledAt) {
|
||||||
|
if (isBefore(visitState.scheduledAt, now)) {
|
||||||
|
return "breached"
|
||||||
|
}
|
||||||
|
return "on_track"
|
||||||
|
}
|
||||||
|
return "on_track"
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSummary(
|
||||||
|
ticket: Ticket,
|
||||||
|
schedule: { startAt: Date | null; endAt: Date | null },
|
||||||
|
visitState: VisitState,
|
||||||
|
slaStatus: AgendaSlaStatus,
|
||||||
|
): AgendaTicketSummary {
|
||||||
|
const completedAt = visitState.completed ? visitState.performedAt ?? visitState.scheduledAt : ticket.resolvedAt ?? null
|
||||||
return {
|
return {
|
||||||
id: ticket.id,
|
id: ticket.id,
|
||||||
reference: ticket.reference,
|
reference: ticket.reference,
|
||||||
|
|
@ -237,7 +290,8 @@ function buildSummary(ticket: Ticket, schedule: { startAt: Date | null; endAt: D
|
||||||
startAt: schedule.startAt,
|
startAt: schedule.startAt,
|
||||||
endAt: ticket.resolvedAt ?? schedule.endAt,
|
endAt: ticket.resolvedAt ?? schedule.endAt,
|
||||||
slaStatus,
|
slaStatus,
|
||||||
completedAt: ticket.resolvedAt ?? null,
|
visitStatus: visitState.status ?? null,
|
||||||
|
completedAt,
|
||||||
href: `/tickets/${ticket.id}`,
|
href: `/tickets/${ticket.id}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,8 @@ const serverTicketSchema = z.object({
|
||||||
slaPausedBy: z.string().nullable().optional(),
|
slaPausedBy: z.string().nullable().optional(),
|
||||||
slaPausedMs: z.number().nullable().optional(),
|
slaPausedMs: z.number().nullable().optional(),
|
||||||
dueAt: z.number().nullable().optional(),
|
dueAt: z.number().nullable().optional(),
|
||||||
|
visitStatus: z.string().nullable().optional(),
|
||||||
|
visitPerformedAt: z.number().nullable().optional(),
|
||||||
firstResponseAt: z.number().nullable().optional(),
|
firstResponseAt: z.number().nullable().optional(),
|
||||||
resolvedAt: z.number().nullable().optional(),
|
resolvedAt: z.number().nullable().optional(),
|
||||||
closedAt: z.number().nullable().optional(),
|
closedAt: z.number().nullable().optional(),
|
||||||
|
|
@ -280,6 +282,8 @@ export function mapTicketFromServer(input: unknown) {
|
||||||
slaPausedAt: s.slaPausedAt ? new Date(s.slaPausedAt) : null,
|
slaPausedAt: s.slaPausedAt ? new Date(s.slaPausedAt) : null,
|
||||||
slaPausedBy: s.slaPausedBy ?? null,
|
slaPausedBy: s.slaPausedBy ?? null,
|
||||||
slaPausedMs: typeof s.slaPausedMs === "number" ? s.slaPausedMs : null,
|
slaPausedMs: typeof s.slaPausedMs === "number" ? s.slaPausedMs : null,
|
||||||
|
visitStatus: typeof s.visitStatus === "string" ? s.visitStatus : null,
|
||||||
|
visitPerformedAt: s.visitPerformedAt ? new Date(s.visitPerformedAt) : null,
|
||||||
workSummary: s.workSummary
|
workSummary: s.workSummary
|
||||||
? {
|
? {
|
||||||
totalWorkedMs: s.workSummary.totalWorkedMs,
|
totalWorkedMs: s.workSummary.totalWorkedMs,
|
||||||
|
|
@ -362,6 +366,8 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
|
||||||
updatedAt: new Date(base.updatedAt),
|
updatedAt: new Date(base.updatedAt),
|
||||||
createdAt: new Date(base.createdAt),
|
createdAt: new Date(base.createdAt),
|
||||||
dueAt: base.dueAt ? new Date(base.dueAt) : null,
|
dueAt: base.dueAt ? new Date(base.dueAt) : null,
|
||||||
|
visitStatus: typeof base.visitStatus === "string" ? base.visitStatus : null,
|
||||||
|
visitPerformedAt: base.visitPerformedAt ? new Date(base.visitPerformedAt) : null,
|
||||||
firstResponseAt: base.firstResponseAt ? new Date(base.firstResponseAt) : null,
|
firstResponseAt: base.firstResponseAt ? new Date(base.firstResponseAt) : null,
|
||||||
resolvedAt: base.resolvedAt ? new Date(base.resolvedAt) : null,
|
resolvedAt: base.resolvedAt ? new Date(base.resolvedAt) : null,
|
||||||
closedAt: base.closedAt ? new Date(base.closedAt) : null,
|
closedAt: base.closedAt ? new Date(base.closedAt) : null,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ export type TicketStatus = z.infer<typeof ticketStatusSchema>
|
||||||
|
|
||||||
const slaStatusSchema = z.enum(["pending", "met", "breached", "n/a"])
|
const slaStatusSchema = z.enum(["pending", "met", "breached", "n/a"])
|
||||||
const slaTimeModeSchema = z.enum(["business", "calendar"])
|
const slaTimeModeSchema = z.enum(["business", "calendar"])
|
||||||
|
export const visitStatusSchema = z.enum(["scheduled", "en_route", "in_service", "done", "no_show", "canceled"])
|
||||||
|
export type VisitStatus = z.infer<typeof visitStatusSchema>
|
||||||
|
|
||||||
export const ticketSlaSnapshotSchema = z.object({
|
export const ticketSlaSnapshotSchema = z.object({
|
||||||
categoryId: z.string().optional(),
|
categoryId: z.string().optional(),
|
||||||
|
|
@ -163,6 +165,8 @@ export const ticketSchema = z.object({
|
||||||
slaPausedBy: z.string().nullable().optional(),
|
slaPausedBy: z.string().nullable().optional(),
|
||||||
slaPausedMs: z.number().nullable().optional(),
|
slaPausedMs: z.number().nullable().optional(),
|
||||||
dueAt: z.coerce.date().nullable(),
|
dueAt: z.coerce.date().nullable(),
|
||||||
|
visitStatus: visitStatusSchema.nullable().optional(),
|
||||||
|
visitPerformedAt: z.coerce.date().nullable().optional(),
|
||||||
firstResponseAt: z.coerce.date().nullable(),
|
firstResponseAt: z.coerce.date().nullable(),
|
||||||
resolvedAt: z.coerce.date().nullable(),
|
resolvedAt: z.coerce.date().nullable(),
|
||||||
closedAt: z.coerce.date().nullable().optional(),
|
closedAt: z.coerce.date().nullable().optional(),
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export const TICKET_TIMELINE_LABELS: Record<string, string> = {
|
||||||
MANAGER_NOTIFIED: "Gestor notificado",
|
MANAGER_NOTIFIED: "Gestor notificado",
|
||||||
VISIT_SCHEDULED: "Visita agendada",
|
VISIT_SCHEDULED: "Visita agendada",
|
||||||
VISIT_SCHEDULE_CHANGED: "Data da visita alterada",
|
VISIT_SCHEDULE_CHANGED: "Data da visita alterada",
|
||||||
|
VISIT_STATUS_CHANGED: "Status da visita atualizado",
|
||||||
CSAT_RECEIVED: "CSAT recebido",
|
CSAT_RECEIVED: "CSAT recebido",
|
||||||
CSAT_RATED: "CSAT avaliado",
|
CSAT_RATED: "CSAT avaliado",
|
||||||
TICKET_LINKED: "Chamado vinculado",
|
TICKET_LINKED: "Chamado vinculado",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue