feat: portal reopen, reports, templates and remote access
This commit is contained in:
parent
6a75a0a9ed
commit
52c03ff1cf
16 changed files with 1387 additions and 16 deletions
|
|
@ -35,6 +35,7 @@ import { priorityStyles } from "@/lib/ticket-priority-style"
|
|||
import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers"
|
||||
import type { TicketFormDefinition, TicketFormFieldDefinition } from "@/lib/ticket-form-types"
|
||||
import { Calendar as CalendarIcon } from "lucide-react"
|
||||
import { VISIT_KEYWORDS } from "@/lib/ticket-matchers"
|
||||
|
||||
type TriggerVariant = "button" | "card"
|
||||
|
||||
|
|
@ -108,6 +109,7 @@ const schema = z.object({
|
|||
priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
|
||||
channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"),
|
||||
queueName: z.string().nullable().optional(),
|
||||
visitDate: z.string().nullable().optional(),
|
||||
assigneeId: z.string().nullable().optional(),
|
||||
companyId: z.string().optional(),
|
||||
requesterId: z.string().min(1, "Selecione um solicitante"),
|
||||
|
|
@ -221,6 +223,7 @@ export function NewTicketDialog({
|
|||
const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
|
||||
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
|
||||
const [openCalendarField, setOpenCalendarField] = useState<string | null>(null)
|
||||
const [visitDatePickerOpen, setVisitDatePickerOpen] = useState(false)
|
||||
|
||||
const selectedForm = useMemo(() => forms.find((formDef) => formDef.key === selectedFormKey) ?? forms[0], [forms, selectedFormKey])
|
||||
|
||||
|
|
@ -270,12 +273,29 @@ export function NewTicketDialog({
|
|||
)
|
||||
const priorityValue = form.watch("priority") as TicketPriority
|
||||
const queueValue = form.watch("queueName") ?? "NONE"
|
||||
const visitDateValue = form.watch("visitDate") ?? null
|
||||
const assigneeValue = form.watch("assigneeId") ?? null
|
||||
const assigneeSelectValue = assigneeValue ?? "NONE"
|
||||
const requesterValue = form.watch("requesterId") ?? ""
|
||||
const categoryIdValue = form.watch("categoryId")
|
||||
const subcategoryIdValue = form.watch("subcategoryId")
|
||||
const isSubmitted = form.formState.isSubmitted
|
||||
|
||||
const normalizedQueueName =
|
||||
typeof queueValue === "string" && queueValue !== "NONE" ? queueValue.toLowerCase() : ""
|
||||
const isVisitQueue = useMemo(
|
||||
() => VISIT_KEYWORDS.some((keyword) => normalizedQueueName.includes(keyword)),
|
||||
[normalizedQueueName]
|
||||
)
|
||||
const visitDate = useMemo(() => {
|
||||
if (!visitDateValue) return null
|
||||
try {
|
||||
return parseISO(visitDateValue)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [visitDateValue])
|
||||
|
||||
const companyOptions = useMemo(() => {
|
||||
const map = new Map<string, { id: string; name: string; isAvulso?: boolean; keywords: string[] }>()
|
||||
companies.forEach((company) => {
|
||||
|
|
@ -429,6 +449,7 @@ export function NewTicketDialog({
|
|||
if (!open) {
|
||||
setAssigneeInitialized(false)
|
||||
setOpenCalendarField(null)
|
||||
setVisitDatePickerOpen(false)
|
||||
return
|
||||
}
|
||||
if (assigneeInitialized) return
|
||||
|
|
@ -490,6 +511,17 @@ export function NewTicketDialog({
|
|||
return
|
||||
}
|
||||
|
||||
const currentQueueName = values.queueName ?? ""
|
||||
const isVisitQueueOnSubmit =
|
||||
typeof currentQueueName === "string" &&
|
||||
VISIT_KEYWORDS.some((keyword) => currentQueueName.toLowerCase().includes(keyword))
|
||||
if (isVisitQueueOnSubmit) {
|
||||
if (!values.visitDate) {
|
||||
form.setError("visitDate", { type: "custom", message: "Informe a data da visita para chamados desta fila." })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
|
||||
if (selectedFormKey !== "default" && selectedForm?.fields?.length) {
|
||||
const normalized = normalizeCustomFieldInputs(selectedForm.fields, customFieldValues)
|
||||
|
|
@ -506,6 +538,15 @@ export function NewTicketDialog({
|
|||
const sel = queues.find((q) => q.name === values.queueName)
|
||||
const selectedAssignee = form.getValues("assigneeId") ?? null
|
||||
const requesterToSend = values.requesterId as Id<"users">
|
||||
let visitDateTimestamp: number | undefined
|
||||
if (isVisitQueueOnSubmit && values.visitDate) {
|
||||
try {
|
||||
const parsed = parseISO(values.visitDate)
|
||||
visitDateTimestamp = parsed.getTime()
|
||||
} catch {
|
||||
visitDateTimestamp = undefined
|
||||
}
|
||||
}
|
||||
const id = await create({
|
||||
actorId: convexUserId as Id<"users">,
|
||||
tenantId: DEFAULT_TENANT_ID,
|
||||
|
|
@ -520,6 +561,7 @@ export function NewTicketDialog({
|
|||
subcategoryId: values.subcategoryId as Id<"ticketSubcategories">,
|
||||
formTemplate: selectedFormKey !== "default" ? selectedFormKey : undefined,
|
||||
customFields: customFieldsPayload.length > 0 ? customFieldsPayload : undefined,
|
||||
visitDate: visitDateTimestamp,
|
||||
})
|
||||
const summaryFallback = values.summary?.trim() ?? ""
|
||||
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback
|
||||
|
|
@ -551,6 +593,7 @@ export function NewTicketDialog({
|
|||
assigneeId: convexUserId ?? null,
|
||||
categoryId: "",
|
||||
subcategoryId: "",
|
||||
visitDate: null,
|
||||
})
|
||||
form.clearErrors()
|
||||
setSelectedFormKey("default")
|
||||
|
|
@ -919,6 +962,60 @@ export function NewTicketDialog({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{isVisitQueue ? (
|
||||
<Field>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
Data da visita <span className="text-destructive">*</span>
|
||||
</FieldLabel>
|
||||
<Popover open={visitDatePickerOpen} onOpenChange={setVisitDatePickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center justify-between rounded-full border border-slate-300 bg-white px-3 text-left text-sm text-neutral-800 shadow-sm hover:bg-slate-50",
|
||||
!visitDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{visitDate
|
||||
? format(visitDate, "dd/MM/yyyy", { locale: ptBR })
|
||||
: "Selecione a data"}
|
||||
<CalendarIcon className="ml-2 size-4 text-neutral-500" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={visitDate ?? undefined}
|
||||
onSelect={(date) => {
|
||||
if (!date) {
|
||||
form.setValue("visitDate", null, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: form.formState.isSubmitted,
|
||||
})
|
||||
return
|
||||
}
|
||||
const iso = date.toISOString().slice(0, 10)
|
||||
form.setValue("visitDate", iso, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: form.formState.isSubmitted,
|
||||
})
|
||||
setVisitDatePickerOpen(false)
|
||||
}}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FieldError
|
||||
errors={
|
||||
form.formState.errors.visitDate
|
||||
? [{ message: form.formState.errors.visitDate.message as string }]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel>Responsável</FieldLabel>
|
||||
<Select
|
||||
|
|
|
|||
|
|
@ -1,13 +1,27 @@
|
|||
import { useMemo } from "react"
|
||||
import { useCallback, 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"
|
||||
|
||||
interface TicketDetailsPanelProps {
|
||||
ticket: TicketWithDetails
|
||||
|
|
@ -81,6 +95,20 @@ 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 isAvulso = Boolean(ticket.company?.isAvulso)
|
||||
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
|
||||
const responseStatus = getSlaDisplayStatus(ticket, "response")
|
||||
|
|
@ -133,6 +161,17 @@ 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
|
||||
|
|
@ -146,6 +185,29 @@ 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">
|
||||
|
|
@ -173,6 +235,41 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{isStaff && machineId ? (
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Acesso remoto</h3>
|
||||
{primaryRemoteAccess ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||
<div className="space-y-1 text-sm text-neutral-700">
|
||||
<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>
|
||||
) : null}
|
||||
{primaryRemoteAccess.identifier ? (
|
||||
<p className="text-xs text-neutral-500">ID RustDesk: {primaryRemoteAccess.identifier}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="inline-flex items-center gap-2 border-slate-300 bg-white text-sm font-semibold text-slate-800 shadow-sm hover:border-slate-400 hover:bg-slate-50"
|
||||
onClick={handleRemoteConnect}
|
||||
>
|
||||
<MonitorSmartphone className="size-4 text-slate-700" />
|
||||
Acessar remoto
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-neutral-500">
|
||||
Nenhum acesso remoto RustDesk está cadastrado para a máquina deste ticket.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">SLA & métricas</h3>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { formatDistanceStrict } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
|
|
@ -14,6 +14,7 @@ import { cn } from "@/lib/utils"
|
|||
import { PrioritySelect } from "@/components/tickets/priority-select"
|
||||
import { getTicketStatusChipClass, getTicketStatusLabel } from "@/lib/ticket-status-style"
|
||||
import { EmptyIndicator } from "@/components/ui/empty-indicator"
|
||||
import { deriveServerOffset, toServerTimestamp } from "@/components/tickets/ticket-timer.utils"
|
||||
|
||||
type TicketsBoardProps = {
|
||||
tickets: Ticket[]
|
||||
|
|
@ -70,8 +71,24 @@ function formatQueueLabel(queue?: string | null) {
|
|||
return { label: queue, title: queue }
|
||||
}
|
||||
|
||||
function formatDuration(ms?: number | null) {
|
||||
if (!ms || ms <= 0) return "—"
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes.toString().padStart(2, "0")}m`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const serverOffsetRef = useRef<number>(0)
|
||||
|
||||
const ticketTimestamps = useMemo(() => {
|
||||
return tickets
|
||||
|
|
@ -97,6 +114,31 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
|
|||
return () => window.clearTimeout(timeoutId)
|
||||
}, [ticketTimestamps, now])
|
||||
|
||||
useEffect(() => {
|
||||
const candidates = tickets
|
||||
.map((ticket) =>
|
||||
typeof ticket.workSummary?.serverNow === "number" ? ticket.workSummary.serverNow : null
|
||||
)
|
||||
.filter((value): value is number => value !== null)
|
||||
if (candidates.length === 0) return
|
||||
const latestServerNow = candidates[candidates.length - 1]
|
||||
serverOffsetRef.current = deriveServerOffset({
|
||||
currentOffset: serverOffsetRef.current,
|
||||
localNow: Date.now(),
|
||||
serverNow: latestServerNow,
|
||||
})
|
||||
}, [tickets])
|
||||
|
||||
const getWorkedMs = (ticket: Ticket) => {
|
||||
const base = ticket.workSummary?.totalWorkedMs ?? 0
|
||||
const activeStart = ticket.workSummary?.activeSession?.startedAt
|
||||
if (activeStart instanceof Date) {
|
||||
const alignedNow = toServerTimestamp(now, serverOffsetRef.current)
|
||||
return base + Math.max(0, alignedNow - activeStart.getTime())
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
if (!tickets.length) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-12 text-center shadow-sm">
|
||||
|
|
@ -233,7 +275,7 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
|
|||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-auto flex items-center justify-center border-t border-slate-200 pt-4 text-sm text-neutral-600 text-center">
|
||||
<div className="mt-auto flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-4 text-sm text-neutral-600">
|
||||
<span className="text-neutral-700">
|
||||
Categoria:{" "}
|
||||
{ticket.category?.name ? (
|
||||
|
|
@ -246,6 +288,15 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
|
|||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500">
|
||||
Tempo:{" "}
|
||||
<span className="font-semibold text-neutral-900">
|
||||
{formatDuration(getWorkedMs(ticket))}
|
||||
</span>
|
||||
{ticket.workSummary?.activeSession ? (
|
||||
<span className="ml-1 text-[11px] font-medium text-emerald-600">Em andamento</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue