From 4f8dad22554d078fe4ff0aecbf6c4c6459f5e802 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Tue, 18 Nov 2025 21:16:00 -0300 Subject: [PATCH] feat: improve quick actions and remote access --- .../companies/admin-companies-manager.tsx | 8 +- .../admin/devices/admin-devices-overview.tsx | 82 +---- src/components/global-quick-actions.tsx | 88 ++++- .../quick-create-device-dialog.tsx | 270 ++++++++++++++++ .../quick-create-user-dialog.tsx | 300 ++++++++++++++++++ .../tickets/ticket-details-panel.tsx | 74 +---- .../tickets/ticket-summary-header.tsx | 60 +++- src/components/tickets/ticket-timeline.tsx | 11 +- src/hooks/use-ticket-remote-access.ts | 98 ++++++ src/lib/device-status.ts | 69 ++++ 10 files changed, 906 insertions(+), 154 deletions(-) create mode 100644 src/components/quick-actions/quick-create-device-dialog.tsx create mode 100644 src/components/quick-actions/quick-create-user-dialog.tsx create mode 100644 src/hooks/use-ticket-remote-access.ts create mode 100644 src/lib/device-status.ts diff --git a/src/components/admin/companies/admin-companies-manager.tsx b/src/components/admin/companies/admin-companies-manager.tsx index 3f49625..d0f02d6 100644 --- a/src/components/admin/companies/admin-companies-manager.tsx +++ b/src/components/admin/companies/admin-companies-manager.tsx @@ -100,7 +100,7 @@ type Props = { type ViewMode = "table" | "board" -type EditorState = +export type CompanyEditorState = | { mode: "create" } | { mode: "edit"; company: NormalizedCompany } @@ -299,7 +299,7 @@ export function AdminCompaniesManager({ initialCompanies, tenantId, autoOpenCrea const [contractFilter, setContractFilter] = useState("all") const [regulatedFilter, setRegulatedFilter] = useState("all") const [isRefreshing, startRefresh] = useTransition() - const [editor, setEditor] = useState(null) + const [editor, setEditor] = useState(null) const [isDeleting, setIsDeleting] = useState(null) const [alertsBySlug, setAlertsBySlug] = useState>({}) @@ -979,13 +979,13 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi type CompanySheetProps = { tenantId: string - editor: EditorState | null + editor: CompanyEditorState | null onClose(): void onCreated(company: NormalizedCompany): void onUpdated(company: NormalizedCompany): void } -function CompanySheet({ tenantId, editor, onClose, onCreated, onUpdated }: CompanySheetProps) { +export function CompanySheet({ tenantId, editor, onClose, onCreated, onUpdated }: CompanySheetProps) { const [isSubmitting, startSubmit] = useTransition() const open = Boolean(editor) diff --git a/src/components/admin/devices/admin-devices-overview.tsx b/src/components/admin/devices/admin-devices-overview.tsx index d42212a..a527d68 100644 --- a/src/components/admin/devices/admin-devices-overview.tsx +++ b/src/components/admin/devices/admin-devices-overview.tsx @@ -64,6 +64,7 @@ import { Separator } from "@/components/ui/separator" import { ChartContainer } from "@/components/ui/chart" import { cn } from "@/lib/utils" import { DEVICE_INVENTORY_COLUMN_METADATA, type DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns" +import { DEVICE_STATUS_LABELS, getDeviceStatusIndicator, resolveDeviceStatus } from "@/lib/device-status" import { RadialBarChart, RadialBar, PolarAngleAxis } from "recharts" import Link from "next/link" import { useRouter } from "next/navigation" @@ -914,29 +915,6 @@ function useDevicesQuery(tenantId: string): { devices: DevicesQueryItem[]; isLoa } } -const DEFAULT_OFFLINE_THRESHOLD_MS = 10 * 60 * 1000 -const DEFAULT_STALE_THRESHOLD_MS = DEFAULT_OFFLINE_THRESHOLD_MS * 12 - -function parseThreshold(raw: string | undefined, fallback: number) { - if (!raw) return fallback - const parsed = Number(raw) - if (!Number.isFinite(parsed) || parsed <= 0) return fallback - return parsed -} - -const MACHINE_OFFLINE_THRESHOLD_MS = parseThreshold(process.env.NEXT_PUBLIC_MACHINE_OFFLINE_THRESHOLD_MS, DEFAULT_OFFLINE_THRESHOLD_MS) -const MACHINE_STALE_THRESHOLD_MS = parseThreshold(process.env.NEXT_PUBLIC_MACHINE_STALE_THRESHOLD_MS, DEFAULT_STALE_THRESHOLD_MS) - -const statusLabels: Record = { - online: "Online", - offline: "Offline", - stale: "Sem sinal", - maintenance: "Em manutenção", - blocked: "Bloqueado", - deactivated: "Desativado", - unknown: "Desconhecida", -} - const DEVICE_TYPE_LABELS: Record = { desktop: "Desktop", mobile: "Celular", @@ -1236,30 +1214,14 @@ function describeScheduledDay(value?: number | null): string | null { } function getStatusVariant(status?: string | null) { - if (!status) return { label: statusLabels.unknown, className: statusClasses.unknown } + if (!status) return { label: DEVICE_STATUS_LABELS.unknown, className: statusClasses.unknown } const normalized = status.toLowerCase() return { - label: statusLabels[normalized] ?? status, + label: DEVICE_STATUS_LABELS[normalized] ?? status, className: statusClasses[normalized] ?? statusClasses.unknown, } } -function resolveDeviceStatus(device: { status?: string | null; lastHeartbeatAt?: number | null; isActive?: boolean | null }): string { - if (device.isActive === false) return "deactivated" - const manualStatus = (device.status ?? "").toLowerCase() - if (["maintenance", "blocked"].includes(manualStatus)) { - return manualStatus - } - const heartbeat = device.lastHeartbeatAt - if (typeof heartbeat === "number" && Number.isFinite(heartbeat) && heartbeat > 0) { - const age = Date.now() - heartbeat - if (age <= MACHINE_OFFLINE_THRESHOLD_MS) return "online" - if (age <= MACHINE_STALE_THRESHOLD_MS) return "offline" - return "stale" - } - return device.status ?? "unknown" -} - function OsIcon({ osName }: { osName?: string | null }) { const name = (osName ?? "").toLowerCase() if (name.includes("mac") || name.includes("darwin") || name.includes("macos")) return @@ -1977,7 +1939,7 @@ export function AdminDevicesOverview({
    {filteredDevices.map((device) => { const statusKey = resolveDeviceStatus(device) - const statusLabel = statusLabels[statusKey] ?? statusKey + const statusLabel = DEVICE_STATUS_LABELS[statusKey] ?? statusKey const isChecked = exportSelection.includes(device.id) const osParts = [device.osName ?? "", device.osVersion ?? ""].filter(Boolean) const osLabel = osParts.join(" ") @@ -2360,37 +2322,7 @@ export function AdminDevicesOverview({ function DeviceStatusBadge({ status }: { status?: string | null }) { const { label, className } = getStatusVariant(status) - const s = String(status ?? "").toLowerCase() - const colorClass = - s === "online" - ? "bg-emerald-500" - : s === "offline" - ? "bg-rose-500" - : s === "stale" - ? "bg-amber-500" - : s === "maintenance" - ? "bg-amber-500" - : s === "blocked" - ? "bg-orange-500" - : s === "deactivated" - ? "bg-slate-500" - : "bg-slate-400" - const ringClass = - s === "online" - ? "bg-emerald-400/30" - : s === "offline" - ? "bg-rose-400/30" - : s === "stale" - ? "bg-amber-400/30" - : s === "maintenance" - ? "bg-amber-400/30" - : s === "blocked" - ? "bg-orange-400/30" - : s === "deactivated" - ? "bg-slate-400/40" - : "bg-slate-300/30" - - const isOnline = s === "online" + const { dotClass, ringClass, isPinging } = getDeviceStatusIndicator(status) return ( - - {isOnline ? ( + + {isPinging ? ( + import("@/components/admin/companies/admin-companies-manager").then((mod) => ({ + default: mod.CompanySheet, + })), + { + ssr: false, + loading: () => ( +
    + Carregando formulário de empresa... +
    + ), + } +) type QuickLink = { - id: string + id: "device" | "company" | "user" label: string description?: string icon: React.ComponentType<{ className?: string }> @@ -20,10 +41,14 @@ type QuickLink = { } export function GlobalQuickActions() { - const { convexUserId, isAdmin, isStaff, isLoading } = useAuth() + const { convexUserId, isAdmin, isStaff, isLoading, session } = useAuth() const router = useRouter() const pathname = usePathname() const actionId = useId() + const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID + const [isDeviceDialogOpen, setIsDeviceDialogOpen] = useState(false) + const [isUserDialogOpen, setIsUserDialogOpen] = useState(false) + const [companyEditor, setCompanyEditor] = useState(null) const links = useMemo(() => { const base: QuickLink[] = [ @@ -84,7 +109,8 @@ export function GlobalQuickActions() { type="button" onClick={() => { const targetPath = link.href.split("?")[0] - if (pathname === targetPath && typeof window !== "undefined") { + const dispatchQuickEvent = () => { + if (typeof window === "undefined") return false const eventName = link.id === "device" ? "quick-open-device" @@ -93,10 +119,24 @@ export function GlobalQuickActions() { : link.id === "user" ? "quick-open-user" : null - if (eventName) { - window.dispatchEvent(new CustomEvent(eventName)) - return - } + if (!eventName) return false + window.dispatchEvent(new CustomEvent(eventName)) + return true + } + if (pathname === targetPath && dispatchQuickEvent()) { + return + } + if (link.id === "device") { + setIsDeviceDialogOpen(true) + return + } + if (link.id === "company") { + setCompanyEditor({ mode: "create" }) + return + } + if (link.id === "user") { + setIsUserDialogOpen(true) + return } router.push(link.href) }} @@ -129,6 +169,38 @@ export function GlobalQuickActions() { ) : null} + { + router.push("/admin/devices") + }} + /> + { + router.push("/admin/users") + }} + /> + {companyEditor ? ( + setCompanyEditor(null)} + onCreated={(company: NormalizedCompany) => { + setCompanyEditor(null) + router.push("/admin/companies") + }} + onUpdated={() => { + setCompanyEditor(null) + }} + /> + ) : null} ) } diff --git a/src/components/quick-actions/quick-create-device-dialog.tsx b/src/components/quick-actions/quick-create-device-dialog.tsx new file mode 100644 index 0000000..12a32ba --- /dev/null +++ b/src/components/quick-actions/quick-create-device-dialog.tsx @@ -0,0 +1,270 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useMutation, useQuery } from "convex/react" +import { toast } from "sonner" +import type { Id } from "@/convex/_generated/dataModel" +import { api } from "@/convex/_generated/api" + +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Checkbox } from "@/components/ui/checkbox" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" +import { Spinner } from "@/components/ui/spinner" + +type QuickCreateDeviceDialogProps = { + open: boolean + tenantId: string + viewerId: string | null + onOpenChange(open: boolean): void + initialCompanySlug?: string | null + onSuccess?(payload: { companySlug?: string | null }): void +} + +const DEVICE_TYPE_OPTIONS = [ + { value: "mobile", label: "Celular" }, + { value: "desktop", label: "Desktop" }, + { value: "tablet", label: "Tablet" }, +] as const + +type DeviceTypeOption = (typeof DEVICE_TYPE_OPTIONS)[number]["value"] + +export function QuickCreateDeviceDialog({ + open, + tenantId, + viewerId, + onOpenChange, + initialCompanySlug = null, + onSuccess, +}: QuickCreateDeviceDialogProps) { + const [name, setName] = useState("") + const [identifier, setIdentifier] = useState("") + const [deviceType, setDeviceType] = useState("mobile") + const [platform, setPlatform] = useState("") + const [companySlug, setCompanySlug] = useState(null) + const [serials, setSerials] = useState("") + const [notes, setNotes] = useState("") + const [isActive, setIsActive] = useState(true) + const [isSubmitting, setIsSubmitting] = useState(false) + + const saveDeviceProfile = useMutation(api.devices.saveDeviceProfile) + const canQueryCompanies = open && Boolean(viewerId) + const companies = useQuery( + api.companies.list, + canQueryCompanies ? { tenantId, viewerId: viewerId as Id<"users"> } : "skip" + ) as Array<{ id: string; name: string; slug?: string }> | undefined + + const companyOptions = useMemo(() => { + if (!companies) return [] + return companies + .map((company) => ({ + value: company.slug ?? company.id, + label: company.name, + })) + .sort((a, b) => a.label.localeCompare(b.label, "pt-BR")) + }, [companies]) + + useEffect(() => { + if (open) { + setCompanySlug(initialCompanySlug ?? null) + } + }, [initialCompanySlug, open]) + + const resetForm = () => { + setName("") + setIdentifier("") + setDeviceType("mobile") + setPlatform("") + setCompanySlug(initialCompanySlug ?? null) + setSerials("") + setNotes("") + setIsActive(true) + } + + const handleClose = () => { + if (isSubmitting) return + resetForm() + onOpenChange(false) + } + + const handleSubmit = async () => { + if (!viewerId) { + toast.error("Não foi possível identificar o usuário atual.") + return + } + const trimmedName = name.trim() + if (trimmedName.length < 3) { + toast.error("Informe um nome com ao menos 3 caracteres.") + return + } + const identifierValue = (identifier.trim() || trimmedName).trim() + const platformValue = platform.trim() + const serialList = serials + .split(/\r?\n|,|;/) + .map((entry) => entry.trim()) + .filter(Boolean) + const selectedCompany = (companies ?? []).find((company) => (company.slug ?? company.id) === companySlug) ?? null + + try { + setIsSubmitting(true) + await saveDeviceProfile({ + tenantId, + actorId: viewerId as Id<"users">, + displayName: trimmedName, + hostname: identifierValue, + deviceType, + devicePlatform: platformValue || undefined, + osName: platformValue || undefined, + serialNumbers: serialList.length > 0 ? serialList : undefined, + companyId: selectedCompany ? (selectedCompany.id as Id<"companies">) : undefined, + companySlug: selectedCompany?.slug ?? undefined, + status: "unknown", + isActive, + profile: notes.trim() ? { notes: notes.trim() } : undefined, + }) + toast.success("Dispositivo criado com sucesso.") + onSuccess?.({ companySlug: selectedCompany?.slug ?? null }) + resetForm() + onOpenChange(false) + } catch (error) { + console.error("Failed to create quick device", error) + toast.error("Não foi possível criar o dispositivo.") + } finally { + setIsSubmitting(false) + } + } + + return ( + (next ? onOpenChange(next) : handleClose())}> + + + Novo dispositivo + + Registre celulares, tablets ou outros ativos sem agente instalado. Você continuará nesta página até concluir. + + +
    +
    + + setName(event.target.value)} + placeholder="iPhone da Ana" + autoFocus + disabled={isSubmitting} + /> +
    +
    + + setIdentifier(event.target.value)} + placeholder="ana-iphone" + disabled={isSubmitting} + /> +

    Caso vazio, usaremos o nome como identificador.

    +
    +
    +
    + + +
    +
    + + setPlatform(event.target.value)} + placeholder="iOS 18, Android 15, Windows 11" + disabled={isSubmitting} + /> +
    +
    +
    + + +
    +
    + +