feat: cadastro manual de acesso remoto e ajustes de horas

This commit is contained in:
Esdras Renan 2025-10-24 23:52:58 -03:00
parent 8e3cbc7a9a
commit f3a7045691
16 changed files with 1549 additions and 207 deletions

View file

@ -38,6 +38,8 @@ const selectTriggerClass = "h-8 w-[140px] rounded-lg border border-slate-300 bg-
const submitButtonClass =
"inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
type CommentsOrder = "descending" | "ascending"
export function TicketComments({ ticket }: TicketCommentsProps) {
const { convexUserId, isStaff, role } = useAuth()
const normalizedRole = role ?? null
@ -66,6 +68,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const [editingComment, setEditingComment] = useState<{ id: string; value: string } | null>(null)
const [savingCommentId, setSavingCommentId] = useState<string | null>(null)
const [localBodies, setLocalBodies] = useState<Record<string, string>>({})
const [commentsOrder, setCommentsOrder] = useState<CommentsOrder>("descending")
const templateArgs = convexUserId && isStaff
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users">, kind: "comment" as const }
@ -133,8 +136,16 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
)
const commentsAll = useMemo(() => {
return [...pending, ...ticket.comments]
}, [pending, ticket.comments])
const base = [...pending, ...ticket.comments]
return base.sort((a, b) => {
const aTime = new Date(a.createdAt).getTime()
const bTime = new Date(b.createdAt).getTime()
if (commentsOrder === "ascending") {
return aTime - bTime
}
return bTime - aTime
})
}, [pending, ticket.comments, commentsOrder])
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
@ -232,6 +243,36 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-4 pb-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium text-neutral-600">
<IconMessage className="size-4" />
<span>{commentsAll.length} {commentsAll.length === 1 ? "comentário" : "comentários"}</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="flex items-center gap-2 text-sm text-neutral-600 hover:text-neutral-900"
onClick={() => setCommentsOrder((prev) => (prev === "descending" ? "ascending" : "descending"))}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-4"
focusable="false"
>
<path
d="M7 18.5v-13M7 5.5L4 8.5M7 5.5l3 3M17 5.5v13M17 18.5l-3-3M17 18.5l3-3"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
{commentsOrder === "descending" ? "Mais recentes primeiro" : "Mais antigos primeiro"}
</Button>
</div>
{commentsAll.length === 0 ? (
<Empty>
<EmptyHeader>

View file

@ -1,9 +1,9 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react"
import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconClock, IconDownload, IconPlayerPause, IconPlayerPlay, IconPencil } from "@tabler/icons-react"
import { IconClock, IconDownload, IconPlayerPause, IconPlayerPlay, IconPencil, IconAdjustmentsHorizontal } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
@ -21,6 +21,7 @@ import { CheckCircle2 } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
@ -133,6 +134,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const normalizedRole = (role ?? "").toLowerCase()
const isManager = normalizedRole === "manager"
const isAdmin = normalizedRole === "admin"
const canAdjustWork = isAdmin || normalizedRole === "agent"
const sessionName = session?.user?.name?.trim()
const machineAssignedName = machineContext?.assignedUserName?.trim()
const agentName =
@ -163,6 +165,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const updateSummary = useMutation(api.tickets.updateSummary)
const startWork = useMutation(api.tickets.startWork)
const pauseWork = useMutation(api.tickets.pauseWork)
const adjustWorkSummaryMutation = useMutation(api.tickets.adjustWorkSummary)
const updateCategories = useMutation(api.tickets.updateCategories)
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const queuesEnabled = Boolean(isStaff && convexUserId)
@ -248,6 +251,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [pausing, setPausing] = useState(false)
const [exportingPdf, setExportingPdf] = useState(false)
const [closeOpen, setCloseOpen] = useState(false)
const [adjustDialogOpen, setAdjustDialogOpen] = useState(false)
const [adjustInternalHours, setAdjustInternalHours] = useState("")
const [adjustExternalHours, setAdjustExternalHours] = useState("")
const [adjustReason, setAdjustReason] = useState("")
const [adjusting, setAdjusting] = useState(false)
const [assigneeChangeReason, setAssigneeChangeReason] = useState("")
const [assigneeReasonError, setAssigneeReasonError] = useState<string | null>(null)
const [companySelection, setCompanySelection] = useState<string>(NO_COMPANY_VALUE)
@ -644,6 +652,25 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
])
const [workSummary, setWorkSummary] = useState<WorkSummarySnapshot | null>(initialWorkSummary)
const formatHoursInput = useCallback((ms: number) => {
if (!Number.isFinite(ms) || ms <= 0) {
return "0"
}
const hours = ms / 3600000
const rounded = Math.round(hours * 100) / 100
return rounded.toString()
}, [])
const effectiveWorkSummary = workSummary ?? initialWorkSummary
useEffect(() => {
if (!adjustDialogOpen) return
const internalMs = effectiveWorkSummary?.internalWorkedMs ?? 0
const externalMs = effectiveWorkSummary?.externalWorkedMs ?? 0
setAdjustInternalHours(formatHoursInput(internalMs))
setAdjustExternalHours(formatHoursInput(externalMs))
setAdjustReason("")
}, [adjustDialogOpen, effectiveWorkSummary, formatHoursInput])
const serverOffsetRef = useRef<number>(0)
const calibrateServerOffset = useCallback(
@ -944,6 +971,116 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}
}
const handleAdjustSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!convexUserId) {
toast.error("Sessão expirada. Faça login novamente.")
return
}
const parseHours = (value: string) => {
const normalized = value.replace(",", ".").trim()
if (normalized.length === 0) return 0
const numeric = Number.parseFloat(normalized)
if (!Number.isFinite(numeric) || numeric < 0) return null
return numeric
}
const internalHoursParsed = parseHours(adjustInternalHours)
if (internalHoursParsed === null) {
toast.error("Informe um valor válido para horas internas.")
return
}
const externalHoursParsed = parseHours(adjustExternalHours)
if (externalHoursParsed === null) {
toast.error("Informe um valor válido para horas externas.")
return
}
const trimmedReason = adjustReason.trim()
if (trimmedReason.length < 5) {
toast.error("Descreva o motivo do ajuste (mínimo de 5 caracteres).")
return
}
toast.dismiss("adjust-hours")
toast.loading("Ajustando horas...", { id: "adjust-hours" })
setAdjusting(true)
try {
const targetInternalMs = Math.round(internalHoursParsed * 3600000)
const targetExternalMs = Math.round(externalHoursParsed * 3600000)
const result = (await adjustWorkSummaryMutation({
ticketId: ticket.id as Id<"tickets">,
actorId: convexUserId as Id<"users">,
internalWorkedMs: targetInternalMs,
externalWorkedMs: targetExternalMs,
reason: trimmedReason,
})) as {
ticketId: Id<"tickets">
totalWorkedMs: number
internalWorkedMs: number
externalWorkedMs: number
serverNow?: number
perAgentTotals?: Array<{
agentId: string
agentName: string | null
agentEmail: string | null
avatarUrl: string | null
totalWorkedMs: number
internalWorkedMs: number
externalWorkedMs: number
}>
}
calibrateServerOffset(result?.serverNow ?? null)
setWorkSummary((prev) => {
const base: WorkSummarySnapshot =
prev ??
({
ticketId: ticket.id as Id<"tickets">,
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
serverNow: result?.serverNow ?? null,
activeSession: null,
perAgentTotals: [],
} satisfies WorkSummarySnapshot)
return {
...base,
totalWorkedMs: result?.totalWorkedMs ?? base.totalWorkedMs,
internalWorkedMs: result?.internalWorkedMs ?? base.internalWorkedMs,
externalWorkedMs: result?.externalWorkedMs ?? base.externalWorkedMs,
serverNow: result?.serverNow ?? base.serverNow,
perAgentTotals: result?.perAgentTotals
? result.perAgentTotals.map((item) => ({
agentId: item.agentId,
agentName: item.agentName ?? null,
agentEmail: item.agentEmail ?? null,
avatarUrl: item.avatarUrl ?? null,
totalWorkedMs: item.totalWorkedMs,
internalWorkedMs: item.internalWorkedMs,
externalWorkedMs: item.externalWorkedMs,
}))
: base.perAgentTotals,
}
})
toast.success("Horas ajustadas com sucesso.", { id: "adjust-hours" })
setAdjustDialogOpen(false)
setAdjustReason("")
} catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível ajustar as horas."
toast.error(message, { id: "adjust-hours" })
} finally {
setAdjusting(false)
}
},
[
adjustInternalHours,
adjustExternalHours,
adjustReason,
adjustWorkSummaryMutation,
calibrateServerOffset,
convexUserId,
ticket.id,
],
)
const handleExportPdf = useCallback(async () => {
try {
setExportingPdf(true)
@ -999,6 +1136,17 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</TooltipContent>
</Tooltip>
) : null}
{canAdjustWork && workSummary ? (
<Button
type="button"
variant="outline"
size="sm"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-neutral-700 hover:bg-slate-50"
onClick={() => setAdjustDialogOpen(true)}
>
<IconAdjustmentsHorizontal className="size-4" /> Ajustar horas
</Button>
) : null}
{!editing ? (
<Button
size="icon"
@ -1033,6 +1181,71 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
agentName={agentName}
onSuccess={() => setStatus("RESOLVED")}
/>
<Dialog open={adjustDialogOpen} onOpenChange={setAdjustDialogOpen}>
<DialogContent className="max-w-lg">
<form onSubmit={handleAdjustSubmit} className="space-y-6">
<DialogHeader>
<DialogTitle>Ajustar horas do chamado</DialogTitle>
<DialogDescription>
Atualize os tempos registrados e descreva o motivo do ajuste. Apenas agentes e administradores visualizam este log.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="adjust-internal">Horas internas</Label>
<Input
id="adjust-internal"
type="number"
min={0}
step={0.25}
value={adjustInternalHours}
onChange={(event) => setAdjustInternalHours(event.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="adjust-external">Horas externas</Label>
<Input
id="adjust-external"
type="number"
min={0}
step={0.25}
value={adjustExternalHours}
onChange={(event) => setAdjustExternalHours(event.target.value)}
required
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="adjust-reason">Motivo do ajuste</Label>
<Textarea
id="adjust-reason"
value={adjustReason}
onChange={(event) => setAdjustReason(event.target.value)}
placeholder="Descreva por que o tempo precisa ser ajustado..."
rows={4}
required
/>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => {
setAdjustDialogOpen(false)
setAdjusting(false)
}}
disabled={adjusting}
>
Cancelar
</Button>
<Button type="submit" disabled={adjusting}>
{adjusting ? "Salvando..." : "Salvar ajuste"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">

View file

@ -1,7 +1,8 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { formatDistanceToNowStrict } from "date-fns"
import { formatDistanceStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
import { LayoutGrid } from "lucide-react"
@ -17,11 +18,72 @@ type TicketsBoardProps = {
enteringIds?: Set<string>
}
function formatUpdated(date: Date) {
return formatDistanceToNowStrict(date, { addSuffix: true, locale: ptBR })
const SECOND = 1_000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
function getTimestamp(value: Date | number | string | null | undefined) {
if (value == null) return null
if (typeof value === "number") {
return Number.isFinite(value) ? value : null
}
const parsed = value instanceof Date ? value.getTime() : new Date(value).getTime()
return Number.isFinite(parsed) ? parsed : null
}
function getNextDelay(diff: number) {
if (diff < MINUTE) {
return SECOND
}
if (diff < HOUR) {
const pastMinute = diff % MINUTE
return pastMinute === 0 ? MINUTE : MINUTE - pastMinute
}
if (diff < DAY) {
const pastHour = diff % HOUR
return pastHour === 0 ? HOUR : HOUR - pastHour
}
const pastDay = diff % DAY
return pastDay === 0 ? DAY : DAY - pastDay
}
function formatUpdated(date: Date | number | string, now: number) {
const timestamp = getTimestamp(date)
if (timestamp === null) return "—"
return formatDistanceStrict(timestamp, now, { addSuffix: true, locale: ptBR })
}
export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
const [now, setNow] = useState(() => Date.now())
const ticketTimestamps = useMemo(() => {
return tickets
.map((ticket) => getTimestamp(ticket.updatedAt))
.filter((value): value is number => value !== null)
}, [tickets])
useEffect(() => {
if (ticketTimestamps.length === 0) {
return
}
let minDelay = DAY
for (const timestamp of ticketTimestamps) {
const diff = Math.abs(now - timestamp)
const candidate = Math.max(SECOND, getNextDelay(diff))
if (candidate < minDelay) {
minDelay = candidate
}
}
const timeoutId = window.setTimeout(() => setNow(Date.now()), minDelay)
return () => window.clearTimeout(timeoutId)
}, [ticketTimestamps, now])
if (!tickets.length) {
return (
<div className="rounded-3xl border border-slate-200 bg-white p-12 text-center shadow-sm">
@ -45,70 +107,70 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
{tickets.map((ticket) => {
const isEntering = enteringIds?.has(ticket.id)
return (
<Link
key={ticket.id}
href={`/tickets/${ticket.id}`}
className={cn(
"group block h-full rounded-3xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300",
isEntering ? "recent-ticket-enter" : ""
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<Badge
variant="outline"
className="border-slate-200 bg-slate-100 px-3.5 py-1.5 text-xs font-semibold text-neutral-700"
>
#{ticket.reference}
</Badge>
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition",
getTicketStatusChipClass(ticket.status),
)}
>
{getTicketStatusLabel(ticket.status)}
<Link
key={ticket.id}
href={`/tickets/${ticket.id}`}
className={cn(
"group block h-full rounded-3xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300",
isEntering ? "recent-ticket-enter" : ""
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<Badge
variant="outline"
className="border-slate-200 bg-slate-100 px-3.5 py-1.5 text-xs font-semibold text-neutral-700"
>
#{ticket.reference}
</Badge>
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition",
getTicketStatusChipClass(ticket.status),
)}
>
{getTicketStatusLabel(ticket.status)}
</span>
</div>
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
{formatUpdated(ticket.updatedAt, now)}
</span>
</div>
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
{formatUpdated(ticket.updatedAt)}
</span>
</div>
<h3 className="mt-5 line-clamp-2 text-lg font-semibold text-neutral-900">
{ticket.subject || "Sem assunto"}
</h3>
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-neutral-600">
<span className="font-medium text-neutral-500">Fila:</span>
<span className="rounded-full bg-slate-100 px-3.5 py-0.5 text-xs font-medium text-neutral-700">
{ticket.queue ?? "Sem fila"}
</span>
<span className="font-medium text-neutral-500">Prioridade:</span>
<TicketPriorityPill
priority={ticket.priority}
className="h-7 gap-1.5 px-3.5 text-xs shadow-sm"
/>
</div>
<dl className="mt-6 space-y-2.5 text-sm text-neutral-600">
<div className="flex items-start justify-between gap-4">
<dt className="font-medium text-neutral-500">Empresa</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.company?.name ?? "Sem empresa"}
</dd>
<h3 className="mt-5 line-clamp-2 text-lg font-semibold text-neutral-900">
{ticket.subject || "Sem assunto"}
</h3>
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-neutral-600">
<span className="font-medium text-neutral-500">Fila:</span>
<span className="rounded-full bg-slate-100 px-3.5 py-0.5 text-xs font-medium text-neutral-700">
{ticket.queue ?? "Sem fila"}
</span>
<span className="font-medium text-neutral-500">Prioridade:</span>
<TicketPriorityPill
priority={ticket.priority}
className="h-7 gap-1.5 px-3.5 text-xs shadow-sm"
/>
</div>
<div className="flex items-start justify-between gap-4">
<dt className="font-medium text-neutral-500">Responsável</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.assignee?.name ?? "Sem responsável"}
</dd>
</div>
<div className="flex items-start justify-between gap-4">
<dt className="font-medium text-neutral-500">Solicitante</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.requester?.name ?? ticket.requester?.email ?? "—"}
</dd>
</div>
</dl>
</Link>
<dl className="mt-6 space-y-2.5 text-sm text-neutral-600">
<div className="flex items-start justify-between gap-4">
<dt className="font-medium text-neutral-500">Empresa</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.company?.name ?? "Sem empresa"}
</dd>
</div>
<div className="flex items-start justify-between gap-4">
<dt className="font-medium text-neutral-500">Responsável</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.assignee?.name ?? "Sem responsável"}
</dd>
</div>
<div className="flex items-start justify-between gap-4">
<dt className="font-medium text-neutral-500">Solicitante</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.requester?.name ?? ticket.requester?.email ?? "—"}
</dd>
</div>
</dl>
</Link>
)
})}
</div>