feat: improve quick actions and remote access
This commit is contained in:
parent
aeb6d50377
commit
4f8dad2255
10 changed files with 906 additions and 154 deletions
|
|
@ -1,27 +1,16 @@
|
|||
import { useCallback, useMemo } from "react"
|
||||
import { useMemo } from "react"
|
||||
import { format } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
|
||||
import { useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getSlaDisplayStatus, getSlaDueDate, type SlaDisplayStatus } from "@/lib/sla-utils"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { MonitorSmartphone } from "lucide-react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import {
|
||||
buildRustDeskUri,
|
||||
isRustDeskAccess,
|
||||
normalizeDeviceRemoteAccessList,
|
||||
type DeviceRemoteAccessEntry,
|
||||
} from "@/components/admin/devices/admin-devices-overview"
|
||||
import { useTicketRemoteAccess } from "@/hooks/use-ticket-remote-access"
|
||||
|
||||
interface TicketDetailsPanelProps {
|
||||
ticket: TicketWithDetails
|
||||
|
|
@ -95,19 +84,12 @@ type SummaryChipConfig = {
|
|||
}
|
||||
|
||||
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||
const { isStaff } = useAuth()
|
||||
const machineId = ticket.machine?.id ?? null
|
||||
const canLoadDevice = isStaff && Boolean(machineId)
|
||||
|
||||
const deviceRaw = useQuery(
|
||||
api.devices.getById,
|
||||
canLoadDevice
|
||||
? ({
|
||||
id: machineId as Id<"machines">,
|
||||
includeMetadata: true,
|
||||
} as const)
|
||||
: "skip"
|
||||
) as Record<string, unknown> | null | undefined
|
||||
const {
|
||||
canShowRemoteAccess,
|
||||
primaryRemoteAccess,
|
||||
connect: handleRemoteConnect,
|
||||
hostname: remoteHostname,
|
||||
} = useTicketRemoteAccess(ticket)
|
||||
|
||||
const isAvulso = Boolean(ticket.company?.isAvulso)
|
||||
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
|
||||
|
|
@ -161,17 +143,6 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
return chips
|
||||
}, [companyLabel, isAvulso, ticket.assignee, ticket.formTemplateLabel, ticket.priority, ticket.queue, ticket.status])
|
||||
|
||||
const remoteAccessEntries = useMemo<DeviceRemoteAccessEntry[]>(() => {
|
||||
if (!deviceRaw) return []
|
||||
const source = (deviceRaw as { remoteAccess?: unknown })?.remoteAccess
|
||||
return normalizeDeviceRemoteAccessList(source)
|
||||
}, [deviceRaw])
|
||||
|
||||
const primaryRemoteAccess = useMemo<DeviceRemoteAccessEntry | null>(
|
||||
() => remoteAccessEntries.find((entry) => isRustDeskAccess(entry)) ?? null,
|
||||
[remoteAccessEntries]
|
||||
)
|
||||
|
||||
const agentTotals = useMemo(() => {
|
||||
const totals = ticket.workSummary?.perAgentTotals ?? []
|
||||
return totals
|
||||
|
|
@ -185,29 +156,6 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
.sort((a, b) => b.totalWorkedMs - a.totalWorkedMs)
|
||||
}, [ticket.workSummary?.perAgentTotals])
|
||||
|
||||
const handleRemoteConnect = useCallback(() => {
|
||||
if (!primaryRemoteAccess) {
|
||||
toast.error("Nenhum acesso remoto RustDesk cadastrado para esta máquina.")
|
||||
return
|
||||
}
|
||||
const link = buildRustDeskUri(primaryRemoteAccess)
|
||||
if (!link) {
|
||||
toast.error("Não foi possível montar o link do RustDesk (ID ou senha ausentes).")
|
||||
return
|
||||
}
|
||||
if (typeof window === "undefined") {
|
||||
toast.error("A conexão direta só funciona no navegador.")
|
||||
return
|
||||
}
|
||||
try {
|
||||
window.location.href = link
|
||||
toast.success("Abrindo o RustDesk...")
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível acionar o RustDesk neste dispositivo.")
|
||||
}
|
||||
}, [primaryRemoteAccess])
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader className="px-4 pb-3">
|
||||
|
|
@ -235,7 +183,7 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{isStaff && machineId ? (
|
||||
{canShowRemoteAccess ? (
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Acesso remoto</h3>
|
||||
{primaryRemoteAccess ? (
|
||||
|
|
@ -244,8 +192,8 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
<p className="font-medium text-neutral-900">
|
||||
Conecte-se remotamente à máquina vinculada a este ticket.
|
||||
</p>
|
||||
{ticket.machine?.hostname ? (
|
||||
<p className="text-xs text-neutral-500">Host: {ticket.machine.hostname}</p>
|
||||
{remoteHostname ? (
|
||||
<p className="text-xs text-neutral-500">Host: {remoteHostname}</p>
|
||||
) : null}
|
||||
{primaryRemoteAccess.identifier ? (
|
||||
<p className="text-xs text-neutral-500">ID RustDesk: {primaryRemoteAccess.identifier}</p>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
|
|||
import { StatusSelect } from "@/components/tickets/status-select"
|
||||
import { CloseTicketDialog, type AdjustWorkSummaryResult } from "@/components/tickets/close-ticket-dialog"
|
||||
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
|
||||
import { Calendar as CalendarIcon, CheckCircle2, RotateCcw } from "lucide-react"
|
||||
import { Calendar as CalendarIcon, CheckCircle2, MonitorSmartphone, RotateCcw } 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"
|
||||
|
|
@ -28,7 +28,10 @@ import { Textarea } from "@/components/ui/textarea"
|
|||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { useTicketCategories } from "@/hooks/use-ticket-categories"
|
||||
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
||||
import { useTicketRemoteAccess } from "@/hooks/use-ticket-remote-access"
|
||||
import { VISIT_KEYWORDS } from "@/lib/ticket-matchers"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getDeviceStatusIndicator } from "@/lib/device-status"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -193,6 +196,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
[convexUserId, agentName, viewerEmailRaw, viewerAvatar]
|
||||
)
|
||||
useDefaultQueues(ticket.tenantId)
|
||||
const {
|
||||
canShowRemoteAccess: canShowRemoteShortcut,
|
||||
primaryRemoteAccess: remoteShortcutAccess,
|
||||
connect: handleRemoteShortcutConnect,
|
||||
hostname: remoteShortcutHostname,
|
||||
statusKey: remoteShortcutStatusKey,
|
||||
statusLabel: remoteShortcutStatusLabel,
|
||||
} = useTicketRemoteAccess(ticket)
|
||||
const remoteShortcutIndicator = getDeviceStatusIndicator(remoteShortcutStatusKey)
|
||||
const showRemoteShortcut = canShowRemoteShortcut && Boolean(remoteShortcutAccess)
|
||||
const changeAssignee = useMutation(api.tickets.changeAssignee)
|
||||
const changeQueue = useMutation(api.tickets.changeQueue)
|
||||
const changeRequester = useMutation(api.tickets.changeRequester)
|
||||
|
|
@ -1292,6 +1305,51 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
>
|
||||
{exportingPdf ? <Spinner className="size-4 text-neutral-700" /> : <IconDownload className="size-5" />}
|
||||
</Button>
|
||||
{showRemoteShortcut ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
aria-label="Conectar remotamente"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white text-neutral-800 hover:bg-slate-50"
|
||||
onClick={handleRemoteShortcutConnect}
|
||||
>
|
||||
<span className="relative inline-flex items-center justify-center">
|
||||
<MonitorSmartphone className="size-5" />
|
||||
<span className="pointer-events-none absolute -right-0.5 -top-0.5 inline-flex">
|
||||
<span className="relative inline-flex">
|
||||
{remoteShortcutIndicator.isPinging ? (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute left-1/2 top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full animate-ping",
|
||||
remoteShortcutIndicator.ringClass
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex size-2 rounded-full border border-white",
|
||||
remoteShortcutIndicator.dotClass
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs space-y-1 text-left">
|
||||
<p className="text-sm font-semibold text-neutral-900">Acesso remoto</p>
|
||||
{remoteShortcutHostname ? (
|
||||
<p className="text-xs text-neutral-500">Host: {remoteShortcutHostname}</p>
|
||||
) : null}
|
||||
{remoteShortcutAccess?.identifier ? (
|
||||
<p className="text-xs text-neutral-500">ID RustDesk: {remoteShortcutAccess.identifier}</p>
|
||||
) : null}
|
||||
<p className="text-xs text-neutral-500">Status: {remoteShortcutStatusLabel}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
|
||||
</div>
|
||||
{status === "RESOLVED" && canReopenTicket && reopenDeadlineLabel ? (
|
||||
|
|
|
|||
|
|
@ -79,14 +79,19 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
|||
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const totalItems = ticket.timeline.length
|
||||
const sortedTimeline = useMemo(
|
||||
() => [...ticket.timeline].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
|
||||
[ticket.timeline]
|
||||
)
|
||||
|
||||
const totalItems = sortedTimeline.length
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / ITEMS_PER_PAGE))
|
||||
const currentPage = Math.min(page, totalPages)
|
||||
const pageOffset = (currentPage - 1) * ITEMS_PER_PAGE
|
||||
|
||||
const currentEvents = useMemo(
|
||||
() => ticket.timeline.slice(pageOffset, pageOffset + ITEMS_PER_PAGE),
|
||||
[pageOffset, ticket.timeline]
|
||||
() => sortedTimeline.slice(pageOffset, pageOffset + ITEMS_PER_PAGE),
|
||||
[pageOffset, sortedTimeline]
|
||||
)
|
||||
|
||||
const paginationRange = useMemo(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue