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
|
|
@ -100,7 +100,7 @@ type Props = {
|
||||||
|
|
||||||
type ViewMode = "table" | "board"
|
type ViewMode = "table" | "board"
|
||||||
|
|
||||||
type EditorState =
|
export type CompanyEditorState =
|
||||||
| { mode: "create" }
|
| { mode: "create" }
|
||||||
| { mode: "edit"; company: NormalizedCompany }
|
| { mode: "edit"; company: NormalizedCompany }
|
||||||
|
|
||||||
|
|
@ -299,7 +299,7 @@ export function AdminCompaniesManager({ initialCompanies, tenantId, autoOpenCrea
|
||||||
const [contractFilter, setContractFilter] = useState<string>("all")
|
const [contractFilter, setContractFilter] = useState<string>("all")
|
||||||
const [regulatedFilter, setRegulatedFilter] = useState<string>("all")
|
const [regulatedFilter, setRegulatedFilter] = useState<string>("all")
|
||||||
const [isRefreshing, startRefresh] = useTransition()
|
const [isRefreshing, startRefresh] = useTransition()
|
||||||
const [editor, setEditor] = useState<EditorState | null>(null)
|
const [editor, setEditor] = useState<CompanyEditorState | null>(null)
|
||||||
const [isDeleting, setIsDeleting] = useState<NormalizedCompany | null>(null)
|
const [isDeleting, setIsDeleting] = useState<NormalizedCompany | null>(null)
|
||||||
const [alertsBySlug, setAlertsBySlug] = useState<Record<string, LastAlertInfo>>({})
|
const [alertsBySlug, setAlertsBySlug] = useState<Record<string, LastAlertInfo>>({})
|
||||||
|
|
||||||
|
|
@ -979,13 +979,13 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
|
||||||
|
|
||||||
type CompanySheetProps = {
|
type CompanySheetProps = {
|
||||||
tenantId: string
|
tenantId: string
|
||||||
editor: EditorState | null
|
editor: CompanyEditorState | null
|
||||||
onClose(): void
|
onClose(): void
|
||||||
onCreated(company: NormalizedCompany): void
|
onCreated(company: NormalizedCompany): void
|
||||||
onUpdated(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 [isSubmitting, startSubmit] = useTransition()
|
||||||
const open = Boolean(editor)
|
const open = Boolean(editor)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ import { Separator } from "@/components/ui/separator"
|
||||||
import { ChartContainer } from "@/components/ui/chart"
|
import { ChartContainer } from "@/components/ui/chart"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { DEVICE_INVENTORY_COLUMN_METADATA, type DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns"
|
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 { RadialBarChart, RadialBar, PolarAngleAxis } from "recharts"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
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<string, string> = {
|
|
||||||
online: "Online",
|
|
||||||
offline: "Offline",
|
|
||||||
stale: "Sem sinal",
|
|
||||||
maintenance: "Em manutenção",
|
|
||||||
blocked: "Bloqueado",
|
|
||||||
deactivated: "Desativado",
|
|
||||||
unknown: "Desconhecida",
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEVICE_TYPE_LABELS: Record<string, string> = {
|
const DEVICE_TYPE_LABELS: Record<string, string> = {
|
||||||
desktop: "Desktop",
|
desktop: "Desktop",
|
||||||
mobile: "Celular",
|
mobile: "Celular",
|
||||||
|
|
@ -1236,30 +1214,14 @@ function describeScheduledDay(value?: number | null): string | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusVariant(status?: 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()
|
const normalized = status.toLowerCase()
|
||||||
return {
|
return {
|
||||||
label: statusLabels[normalized] ?? status,
|
label: DEVICE_STATUS_LABELS[normalized] ?? status,
|
||||||
className: statusClasses[normalized] ?? statusClasses.unknown,
|
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 }) {
|
function OsIcon({ osName }: { osName?: string | null }) {
|
||||||
const name = (osName ?? "").toLowerCase()
|
const name = (osName ?? "").toLowerCase()
|
||||||
if (name.includes("mac") || name.includes("darwin") || name.includes("macos")) return <Apple className="size-4 text-black" />
|
if (name.includes("mac") || name.includes("darwin") || name.includes("macos")) return <Apple className="size-4 text-black" />
|
||||||
|
|
@ -1977,7 +1939,7 @@ export function AdminDevicesOverview({
|
||||||
<ul className="divide-y divide-slate-100">
|
<ul className="divide-y divide-slate-100">
|
||||||
{filteredDevices.map((device) => {
|
{filteredDevices.map((device) => {
|
||||||
const statusKey = resolveDeviceStatus(device)
|
const statusKey = resolveDeviceStatus(device)
|
||||||
const statusLabel = statusLabels[statusKey] ?? statusKey
|
const statusLabel = DEVICE_STATUS_LABELS[statusKey] ?? statusKey
|
||||||
const isChecked = exportSelection.includes(device.id)
|
const isChecked = exportSelection.includes(device.id)
|
||||||
const osParts = [device.osName ?? "", device.osVersion ?? ""].filter(Boolean)
|
const osParts = [device.osName ?? "", device.osVersion ?? ""].filter(Boolean)
|
||||||
const osLabel = osParts.join(" ")
|
const osLabel = osParts.join(" ")
|
||||||
|
|
@ -2360,37 +2322,7 @@ export function AdminDevicesOverview({
|
||||||
|
|
||||||
function DeviceStatusBadge({ status }: { status?: string | null }) {
|
function DeviceStatusBadge({ status }: { status?: string | null }) {
|
||||||
const { label, className } = getStatusVariant(status)
|
const { label, className } = getStatusVariant(status)
|
||||||
const s = String(status ?? "").toLowerCase()
|
const { dotClass, ringClass, isPinging } = getDeviceStatusIndicator(status)
|
||||||
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"
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -2400,8 +2332,8 @@ function DeviceStatusBadge({ status }: { status?: string | null }) {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="relative inline-flex size-4 items-center justify-center">
|
<span className="relative inline-flex size-4 items-center justify-center">
|
||||||
<span className={cn("size-2.5 rounded-full", colorClass)} />
|
<span className={cn("size-2.5 rounded-full", dotClass)} />
|
||||||
{isOnline ? (
|
{isPinging ? (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute left-1/2 top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full animate-ping [animation-duration:2s]",
|
"absolute left-1/2 top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full animate-ping [animation-duration:2s]",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useId, useMemo } from "react"
|
import { useId, useMemo, useState } from "react"
|
||||||
|
import dynamic from "next/dynamic"
|
||||||
import { usePathname, useRouter } from "next/navigation"
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
import { MonitorSmartphone, Building, UserPlus, ChevronRight } from "lucide-react"
|
import { MonitorSmartphone, Building, UserPlus, ChevronRight } from "lucide-react"
|
||||||
|
|
||||||
|
|
@ -9,9 +10,29 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { NewTicketDialogDeferred } from "@/components/tickets/new-ticket-dialog.client"
|
import { NewTicketDialogDeferred } from "@/components/tickets/new-ticket-dialog.client"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
import { QuickCreateDeviceDialog } from "@/components/quick-actions/quick-create-device-dialog"
|
||||||
|
import { QuickCreateUserDialog } from "@/components/quick-actions/quick-create-user-dialog"
|
||||||
|
import type { CompanyEditorState } from "@/components/admin/companies/admin-companies-manager"
|
||||||
|
import type { NormalizedCompany } from "@/server/company-service"
|
||||||
|
|
||||||
|
const CompanySheetDialog = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/admin/companies/admin-companies-manager").then((mod) => ({
|
||||||
|
default: mod.CompanySheet,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="rounded-3xl border border-border/60 bg-background p-6 text-sm text-muted-foreground shadow-2xl">
|
||||||
|
Carregando formulário de empresa...
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
type QuickLink = {
|
type QuickLink = {
|
||||||
id: string
|
id: "device" | "company" | "user"
|
||||||
label: string
|
label: string
|
||||||
description?: string
|
description?: string
|
||||||
icon: React.ComponentType<{ className?: string }>
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
|
@ -20,10 +41,14 @@ type QuickLink = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GlobalQuickActions() {
|
export function GlobalQuickActions() {
|
||||||
const { convexUserId, isAdmin, isStaff, isLoading } = useAuth()
|
const { convexUserId, isAdmin, isStaff, isLoading, session } = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const actionId = useId()
|
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<CompanyEditorState | null>(null)
|
||||||
|
|
||||||
const links = useMemo<QuickLink[]>(() => {
|
const links = useMemo<QuickLink[]>(() => {
|
||||||
const base: QuickLink[] = [
|
const base: QuickLink[] = [
|
||||||
|
|
@ -84,7 +109,8 @@ export function GlobalQuickActions() {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const targetPath = link.href.split("?")[0]
|
const targetPath = link.href.split("?")[0]
|
||||||
if (pathname === targetPath && typeof window !== "undefined") {
|
const dispatchQuickEvent = () => {
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
const eventName =
|
const eventName =
|
||||||
link.id === "device"
|
link.id === "device"
|
||||||
? "quick-open-device"
|
? "quick-open-device"
|
||||||
|
|
@ -93,10 +119,24 @@ export function GlobalQuickActions() {
|
||||||
: link.id === "user"
|
: link.id === "user"
|
||||||
? "quick-open-user"
|
? "quick-open-user"
|
||||||
: null
|
: null
|
||||||
if (eventName) {
|
if (!eventName) return false
|
||||||
window.dispatchEvent(new CustomEvent(eventName))
|
window.dispatchEvent(new CustomEvent(eventName))
|
||||||
return
|
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)
|
router.push(link.href)
|
||||||
}}
|
}}
|
||||||
|
|
@ -129,6 +169,38 @@ export function GlobalQuickActions() {
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<QuickCreateDeviceDialog
|
||||||
|
open={isDeviceDialogOpen}
|
||||||
|
onOpenChange={setIsDeviceDialogOpen}
|
||||||
|
tenantId={tenantId}
|
||||||
|
viewerId={convexUserId}
|
||||||
|
onSuccess={() => {
|
||||||
|
router.push("/admin/devices")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<QuickCreateUserDialog
|
||||||
|
open={isUserDialogOpen}
|
||||||
|
onOpenChange={setIsUserDialogOpen}
|
||||||
|
tenantId={tenantId}
|
||||||
|
viewerId={convexUserId}
|
||||||
|
onSuccess={() => {
|
||||||
|
router.push("/admin/users")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{companyEditor ? (
|
||||||
|
<CompanySheetDialog
|
||||||
|
tenantId={tenantId}
|
||||||
|
editor={companyEditor}
|
||||||
|
onClose={() => setCompanyEditor(null)}
|
||||||
|
onCreated={(company: NormalizedCompany) => {
|
||||||
|
setCompanyEditor(null)
|
||||||
|
router.push("/admin/companies")
|
||||||
|
}}
|
||||||
|
onUpdated={() => {
|
||||||
|
setCompanyEditor(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
270
src/components/quick-actions/quick-create-device-dialog.tsx
Normal file
270
src/components/quick-actions/quick-create-device-dialog.tsx
Normal file
|
|
@ -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<DeviceTypeOption>("mobile")
|
||||||
|
const [platform, setPlatform] = useState("")
|
||||||
|
const [companySlug, setCompanySlug] = useState<string | null>(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<SearchableComboboxOption[]>(() => {
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(next) : handleClose())}>
|
||||||
|
<DialogContent className="max-w-lg space-y-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Novo dispositivo</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Registre celulares, tablets ou outros ativos sem agente instalado. Você continuará nesta página até concluir.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label htmlFor="quick-device-name" className="text-sm font-medium text-slate-700">
|
||||||
|
Nome do dispositivo
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="quick-device-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
placeholder="iPhone da Ana"
|
||||||
|
autoFocus
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label htmlFor="quick-device-identifier" className="text-sm font-medium text-slate-700">
|
||||||
|
Identificador (hostname)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="quick-device-identifier"
|
||||||
|
value={identifier}
|
||||||
|
onChange={(event) => setIdentifier(event.target.value)}
|
||||||
|
placeholder="ana-iphone"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">Caso vazio, usaremos o nome como identificador.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 sm:gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-sm font-medium text-slate-700">Tipo</label>
|
||||||
|
<Select value={deviceType} onValueChange={(value) => setDeviceType(value as DeviceTypeOption)} disabled={isSubmitting}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DEVICE_TYPE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label htmlFor="quick-device-platform" className="text-sm font-medium text-slate-700">
|
||||||
|
Plataforma
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="quick-device-platform"
|
||||||
|
value={platform}
|
||||||
|
onChange={(event) => setPlatform(event.target.value)}
|
||||||
|
placeholder="iOS 18, Android 15, Windows 11"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-sm font-medium text-slate-700">Empresa</label>
|
||||||
|
<SearchableCombobox
|
||||||
|
value={companySlug}
|
||||||
|
onValueChange={setCompanySlug}
|
||||||
|
options={companyOptions}
|
||||||
|
placeholder="Sem empresa"
|
||||||
|
searchPlaceholder="Buscar empresa..."
|
||||||
|
emptyText={companies && companies.length === 0 ? "Nenhuma empresa cadastrada" : "Nenhum resultado"}
|
||||||
|
allowClear
|
||||||
|
clearLabel="Sem empresa"
|
||||||
|
disabled={isSubmitting || !companies}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label htmlFor="quick-device-serials" className="text-sm font-medium text-slate-700">
|
||||||
|
Seriais / IMEI
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="quick-device-serials"
|
||||||
|
value={serials}
|
||||||
|
onChange={(event) => setSerials(event.target.value)}
|
||||||
|
placeholder="Separe múltiplos valores com quebra de linha"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label htmlFor="quick-device-notes" className="text-sm font-medium text-slate-700">
|
||||||
|
Observações
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="quick-device-notes"
|
||||||
|
value={notes}
|
||||||
|
onChange={(event) => setNotes(event.target.value)}
|
||||||
|
placeholder="Informações adicionais para a equipe"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<Checkbox
|
||||||
|
checked={isActive}
|
||||||
|
onCheckedChange={(value) => setIsActive(value === true || value === "indeterminate")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<span>Ativo</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={handleClose} disabled={isSubmitting}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleSubmit} disabled={isSubmitting || name.trim().length < 3} className="gap-2">
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
Salvando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Cadastrar dispositivo"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
300
src/components/quick-actions/quick-create-user-dialog.tsx
Normal file
300
src/components/quick-actions/quick-create-user-dialog.tsx
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { useQuery } from "convex/react"
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
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"
|
||||||
|
|
||||||
|
type QuickCreateUserDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange(open: boolean): void
|
||||||
|
tenantId: string
|
||||||
|
viewerId: string | null
|
||||||
|
onSuccess?(payload: { email: string }): void
|
||||||
|
}
|
||||||
|
|
||||||
|
type PortalRole = "MANAGER" | "COLLABORATOR"
|
||||||
|
|
||||||
|
const ROLE_OPTIONS: ReadonlyArray<{ value: PortalRole; label: string }> = [
|
||||||
|
{ value: "MANAGER", label: "Gestor" },
|
||||||
|
{ value: "COLLABORATOR", label: "Colaborador" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ROLE_TO_PAYLOAD: Record<PortalRole, "manager" | "collaborator"> = {
|
||||||
|
MANAGER: "manager",
|
||||||
|
COLLABORATOR: "collaborator",
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_COMPANY_VALUE = "__none__"
|
||||||
|
const NO_MANAGER_VALUE = "__no_manager__"
|
||||||
|
|
||||||
|
type QuickUserFormState = {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
jobTitle: string
|
||||||
|
role: PortalRole
|
||||||
|
companyId: string
|
||||||
|
managerId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultForm(): QuickUserFormState {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
jobTitle: "",
|
||||||
|
role: "COLLABORATOR",
|
||||||
|
companyId: NO_COMPANY_VALUE,
|
||||||
|
managerId: NO_MANAGER_VALUE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickCreateUserDialog({ open, onOpenChange, tenantId, viewerId, onSuccess }: QuickCreateUserDialogProps) {
|
||||||
|
const [form, setForm] = useState<QuickUserFormState>(defaultForm)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const canQuery = open && Boolean(viewerId)
|
||||||
|
const companies = useQuery(
|
||||||
|
api.companies.list,
|
||||||
|
canQuery ? { tenantId, viewerId: viewerId as Id<"users"> } : "skip"
|
||||||
|
) as Array<{ id: string; name: string; slug?: string }> | undefined
|
||||||
|
const customers = useQuery(
|
||||||
|
api.users.listCustomers,
|
||||||
|
canQuery ? { tenantId, viewerId: viewerId as Id<"users"> } : "skip"
|
||||||
|
) as Array<{ id: string; name: string; email: string; role: string }> | undefined
|
||||||
|
|
||||||
|
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
|
||||||
|
const base: SearchableComboboxOption[] = [{ value: NO_COMPANY_VALUE, label: "Sem empresa vinculada" }]
|
||||||
|
if (!companies) return base
|
||||||
|
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||||
|
return [
|
||||||
|
...base,
|
||||||
|
...sorted.map((company) => ({
|
||||||
|
value: company.id,
|
||||||
|
label: company.name,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
}, [companies])
|
||||||
|
|
||||||
|
const managerOptions = useMemo<SearchableComboboxOption[]>(() => {
|
||||||
|
const base: SearchableComboboxOption[] = [{ value: NO_MANAGER_VALUE, label: "Sem gestor" }]
|
||||||
|
if (!customers) return base
|
||||||
|
const managers = customers.filter((user) => user.role === "MANAGER")
|
||||||
|
return [
|
||||||
|
...base,
|
||||||
|
...managers.map((manager) => ({
|
||||||
|
value: manager.id,
|
||||||
|
label: manager.name,
|
||||||
|
description: manager.email,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
}, [customers])
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setForm(defaultForm)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (isSubmitting) return
|
||||||
|
resetForm()
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!viewerId) {
|
||||||
|
toast.error("Não foi possível identificar o usuário atual.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const name = form.name.trim()
|
||||||
|
const email = form.email.trim().toLowerCase()
|
||||||
|
if (!name) {
|
||||||
|
toast.error("Informe o nome do usuário.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!email || !email.includes("@")) {
|
||||||
|
toast.error("Informe um e-mail válido.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const jobTitle = form.jobTitle.trim()
|
||||||
|
const managerId = form.managerId !== NO_MANAGER_VALUE ? form.managerId : null
|
||||||
|
const companyId = form.companyId !== NO_COMPANY_VALUE ? form.companyId : null
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
role: ROLE_TO_PAYLOAD[form.role],
|
||||||
|
tenantId,
|
||||||
|
jobTitle: jobTitle || null,
|
||||||
|
managerId,
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/admin/users", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
throw new Error(data?.error ?? "Não foi possível criar o usuário.")
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { user: { email: string }; temporaryPassword?: string }
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
const assignResponse = await fetch("/api/admin/users/assign-company", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ email, companyId }),
|
||||||
|
})
|
||||||
|
if (!assignResponse.ok) {
|
||||||
|
const assignData = await assignResponse.json().catch(() => null)
|
||||||
|
toast.error(assignData?.error ?? "Usuário criado, mas não foi possível vinculá-lo à empresa.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm()
|
||||||
|
onOpenChange(false)
|
||||||
|
onSuccess?.({ email })
|
||||||
|
toast.success("Usuário criado com sucesso.", {
|
||||||
|
description: data.temporaryPassword ? `Senha temporária: ${data.temporaryPassword}` : undefined,
|
||||||
|
action: data.temporaryPassword
|
||||||
|
? {
|
||||||
|
label: "Copiar",
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard?.writeText?.(data.temporaryPassword ?? "")
|
||||||
|
toast.success("Senha copiada para a área de transferência.")
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Não foi possível copiar a senha."
|
||||||
|
toast.error(message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Erro ao criar usuário."
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(next) : handleClose())}>
|
||||||
|
<DialogContent className="max-w-lg space-y-6">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Novo usuário</DialogTitle>
|
||||||
|
<DialogDescription>Crie acessos para gestores ou colaboradores sem sair da página atual.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form className="space-y-5" onSubmit={handleSubmit}>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="quick-user-name">Nome</Label>
|
||||||
|
<Input
|
||||||
|
id="quick-user-name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||||
|
placeholder="Nome completo"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="quick-user-email">E-mail</Label>
|
||||||
|
<Input
|
||||||
|
id="quick-user-email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, email: event.target.value }))}
|
||||||
|
placeholder="usuario@empresa.com"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="quick-user-job-title">Cargo</Label>
|
||||||
|
<Input
|
||||||
|
id="quick-user-job-title"
|
||||||
|
value={form.jobTitle}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, jobTitle: event.target.value }))}
|
||||||
|
placeholder="Ex.: Analista de Suporte"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Papel</Label>
|
||||||
|
<Select
|
||||||
|
value={form.role}
|
||||||
|
onValueChange={(value) => setForm((prev) => ({ ...prev, role: value as PortalRole }))}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecione" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ROLE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Gestor direto</Label>
|
||||||
|
<SearchableCombobox
|
||||||
|
value={form.managerId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
managerId: value === null ? NO_MANAGER_VALUE : value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
options={managerOptions}
|
||||||
|
placeholder="Sem gestor definido"
|
||||||
|
searchPlaceholder="Buscar gestor..."
|
||||||
|
disabled={isSubmitting || !customers}
|
||||||
|
allowClear
|
||||||
|
clearLabel="Remover gestor"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Empresa vinculada</Label>
|
||||||
|
<SearchableCombobox
|
||||||
|
value={form.companyId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
companyId: value === null ? NO_COMPANY_VALUE : value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
options={companyOptions}
|
||||||
|
placeholder="Sem empresa vinculada"
|
||||||
|
searchPlaceholder="Buscar empresa..."
|
||||||
|
disabled={isSubmitting || !companies}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button type="button" variant="ghost" onClick={handleClose} disabled={isSubmitting}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Criando..." : "Criar usuário"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,27 +1,16 @@
|
||||||
import { useCallback, useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
|
|
||||||
import { useQuery } from "convex/react"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
|
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { getSlaDisplayStatus, getSlaDueDate, type SlaDisplayStatus } from "@/lib/sla-utils"
|
import { getSlaDisplayStatus, getSlaDueDate, type SlaDisplayStatus } from "@/lib/sla-utils"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { MonitorSmartphone } from "lucide-react"
|
import { MonitorSmartphone } from "lucide-react"
|
||||||
import { api } from "@/convex/_generated/api"
|
import { useTicketRemoteAccess } from "@/hooks/use-ticket-remote-access"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
|
||||||
import {
|
|
||||||
buildRustDeskUri,
|
|
||||||
isRustDeskAccess,
|
|
||||||
normalizeDeviceRemoteAccessList,
|
|
||||||
type DeviceRemoteAccessEntry,
|
|
||||||
} from "@/components/admin/devices/admin-devices-overview"
|
|
||||||
|
|
||||||
interface TicketDetailsPanelProps {
|
interface TicketDetailsPanelProps {
|
||||||
ticket: TicketWithDetails
|
ticket: TicketWithDetails
|
||||||
|
|
@ -95,19 +84,12 @@ type SummaryChipConfig = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
const { isStaff } = useAuth()
|
const {
|
||||||
const machineId = ticket.machine?.id ?? null
|
canShowRemoteAccess,
|
||||||
const canLoadDevice = isStaff && Boolean(machineId)
|
primaryRemoteAccess,
|
||||||
|
connect: handleRemoteConnect,
|
||||||
const deviceRaw = useQuery(
|
hostname: remoteHostname,
|
||||||
api.devices.getById,
|
} = useTicketRemoteAccess(ticket)
|
||||||
canLoadDevice
|
|
||||||
? ({
|
|
||||||
id: machineId as Id<"machines">,
|
|
||||||
includeMetadata: true,
|
|
||||||
} as const)
|
|
||||||
: "skip"
|
|
||||||
) as Record<string, unknown> | null | undefined
|
|
||||||
|
|
||||||
const isAvulso = Boolean(ticket.company?.isAvulso)
|
const isAvulso = Boolean(ticket.company?.isAvulso)
|
||||||
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
|
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
|
||||||
|
|
@ -161,17 +143,6 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
return chips
|
return chips
|
||||||
}, [companyLabel, isAvulso, ticket.assignee, ticket.formTemplateLabel, ticket.priority, ticket.queue, ticket.status])
|
}, [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 agentTotals = useMemo(() => {
|
||||||
const totals = ticket.workSummary?.perAgentTotals ?? []
|
const totals = ticket.workSummary?.perAgentTotals ?? []
|
||||||
return totals
|
return totals
|
||||||
|
|
@ -185,29 +156,6 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
.sort((a, b) => b.totalWorkedMs - a.totalWorkedMs)
|
.sort((a, b) => b.totalWorkedMs - a.totalWorkedMs)
|
||||||
}, [ticket.workSummary?.perAgentTotals])
|
}, [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 (
|
return (
|
||||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
<CardHeader className="px-4 pb-3">
|
<CardHeader className="px-4 pb-3">
|
||||||
|
|
@ -235,7 +183,7 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isStaff && machineId ? (
|
{canShowRemoteAccess ? (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900">Acesso remoto</h3>
|
<h3 className="text-sm font-semibold text-neutral-900">Acesso remoto</h3>
|
||||||
{primaryRemoteAccess ? (
|
{primaryRemoteAccess ? (
|
||||||
|
|
@ -244,8 +192,8 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
<p className="font-medium text-neutral-900">
|
<p className="font-medium text-neutral-900">
|
||||||
Conecte-se remotamente à máquina vinculada a este ticket.
|
Conecte-se remotamente à máquina vinculada a este ticket.
|
||||||
</p>
|
</p>
|
||||||
{ticket.machine?.hostname ? (
|
{remoteHostname ? (
|
||||||
<p className="text-xs text-neutral-500">Host: {ticket.machine.hostname}</p>
|
<p className="text-xs text-neutral-500">Host: {remoteHostname}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{primaryRemoteAccess.identifier ? (
|
{primaryRemoteAccess.identifier ? (
|
||||||
<p className="text-xs text-neutral-500">ID RustDesk: {primaryRemoteAccess.identifier}</p>
|
<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 { StatusSelect } from "@/components/tickets/status-select"
|
||||||
import { CloseTicketDialog, type AdjustWorkSummaryResult } from "@/components/tickets/close-ticket-dialog"
|
import { CloseTicketDialog, type AdjustWorkSummaryResult } from "@/components/tickets/close-ticket-dialog"
|
||||||
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
@ -28,7 +28,10 @@ import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
import { useTicketCategories } from "@/hooks/use-ticket-categories"
|
import { useTicketCategories } from "@/hooks/use-ticket-categories"
|
||||||
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
||||||
|
import { useTicketRemoteAccess } from "@/hooks/use-ticket-remote-access"
|
||||||
import { VISIT_KEYWORDS } from "@/lib/ticket-matchers"
|
import { VISIT_KEYWORDS } from "@/lib/ticket-matchers"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { getDeviceStatusIndicator } from "@/lib/device-status"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -193,6 +196,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
[convexUserId, agentName, viewerEmailRaw, viewerAvatar]
|
[convexUserId, agentName, viewerEmailRaw, viewerAvatar]
|
||||||
)
|
)
|
||||||
useDefaultQueues(ticket.tenantId)
|
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 changeAssignee = useMutation(api.tickets.changeAssignee)
|
||||||
const changeQueue = useMutation(api.tickets.changeQueue)
|
const changeQueue = useMutation(api.tickets.changeQueue)
|
||||||
const changeRequester = useMutation(api.tickets.changeRequester)
|
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" />}
|
{exportingPdf ? <Spinner className="size-4 text-neutral-700" /> : <IconDownload className="size-5" />}
|
||||||
</Button>
|
</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">} />
|
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
|
||||||
</div>
|
</div>
|
||||||
{status === "RESOLVED" && canReopenTicket && reopenDeadlineLabel ? (
|
{status === "RESOLVED" && canReopenTicket && reopenDeadlineLabel ? (
|
||||||
|
|
|
||||||
|
|
@ -79,14 +79,19 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
|
|
||||||
const [page, setPage] = useState(1)
|
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 totalPages = Math.max(1, Math.ceil(totalItems / ITEMS_PER_PAGE))
|
||||||
const currentPage = Math.min(page, totalPages)
|
const currentPage = Math.min(page, totalPages)
|
||||||
const pageOffset = (currentPage - 1) * ITEMS_PER_PAGE
|
const pageOffset = (currentPage - 1) * ITEMS_PER_PAGE
|
||||||
|
|
||||||
const currentEvents = useMemo(
|
const currentEvents = useMemo(
|
||||||
() => ticket.timeline.slice(pageOffset, pageOffset + ITEMS_PER_PAGE),
|
() => sortedTimeline.slice(pageOffset, pageOffset + ITEMS_PER_PAGE),
|
||||||
[pageOffset, ticket.timeline]
|
[pageOffset, sortedTimeline]
|
||||||
)
|
)
|
||||||
|
|
||||||
const paginationRange = useMemo(() => {
|
const paginationRange = useMemo(() => {
|
||||||
|
|
|
||||||
98
src/hooks/use-ticket-remote-access.ts
Normal file
98
src/hooks/use-ticket-remote-access.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useMemo } from "react"
|
||||||
|
import { useQuery } from "convex/react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
||||||
|
import {
|
||||||
|
buildRustDeskUri,
|
||||||
|
isRustDeskAccess,
|
||||||
|
normalizeDeviceRemoteAccessList,
|
||||||
|
type DeviceRemoteAccessEntry,
|
||||||
|
} from "@/components/admin/devices/admin-devices-overview"
|
||||||
|
import { DEVICE_STATUS_LABELS, resolveDeviceStatus } from "@/lib/device-status"
|
||||||
|
|
||||||
|
type RemoteAccessResult = {
|
||||||
|
canShowRemoteAccess: boolean
|
||||||
|
primaryRemoteAccess: DeviceRemoteAccessEntry | null
|
||||||
|
statusKey: string
|
||||||
|
statusLabel: string
|
||||||
|
hostname: string | null
|
||||||
|
connect: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTicketRemoteAccess(ticket: TicketWithDetails): RemoteAccessResult {
|
||||||
|
const { isStaff } = useAuth()
|
||||||
|
const machineId = ticket.machine?.id ?? null
|
||||||
|
const canQueryDevice = isStaff && Boolean(machineId)
|
||||||
|
|
||||||
|
const deviceRecord = useQuery(
|
||||||
|
api.devices.getById,
|
||||||
|
canQueryDevice
|
||||||
|
? ({
|
||||||
|
id: machineId as Id<"machines">,
|
||||||
|
includeMetadata: true,
|
||||||
|
} as const)
|
||||||
|
: "skip"
|
||||||
|
) as Record<string, unknown> | null | undefined
|
||||||
|
|
||||||
|
const remoteAccessEntries = useMemo(() => {
|
||||||
|
if (!deviceRecord) return []
|
||||||
|
const source = (deviceRecord as { remoteAccess?: unknown }).remoteAccess
|
||||||
|
return normalizeDeviceRemoteAccessList(source)
|
||||||
|
}, [deviceRecord])
|
||||||
|
|
||||||
|
const primaryRemoteAccess = useMemo(
|
||||||
|
() => remoteAccessEntries.find((entry) => isRustDeskAccess(entry)) ?? null,
|
||||||
|
[remoteAccessEntries]
|
||||||
|
)
|
||||||
|
|
||||||
|
const hostname =
|
||||||
|
ticket.machine?.hostname ?? ((deviceRecord as { hostname?: string | null })?.hostname ?? null)
|
||||||
|
|
||||||
|
const statusKey = useMemo(() => {
|
||||||
|
if (deviceRecord && typeof deviceRecord === "object") {
|
||||||
|
return resolveDeviceStatus(
|
||||||
|
deviceRecord as { status?: string | null; lastHeartbeatAt?: number | null; isActive?: boolean | null }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const fallback = ticket.machine?.status
|
||||||
|
return typeof fallback === "string" && fallback.length > 0 ? fallback.toLowerCase() : "unknown"
|
||||||
|
}, [deviceRecord, ticket.machine?.status])
|
||||||
|
|
||||||
|
const handleConnect = 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 {
|
||||||
|
canShowRemoteAccess: Boolean(canQueryDevice),
|
||||||
|
primaryRemoteAccess,
|
||||||
|
statusKey,
|
||||||
|
statusLabel: DEVICE_STATUS_LABELS[statusKey] ?? statusKey,
|
||||||
|
hostname,
|
||||||
|
connect: handleConnect,
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/lib/device-status.ts
Normal file
69
src/lib/device-status.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
export const DEVICE_STATUS_LABELS: Record<string, string> = {
|
||||||
|
online: "Online",
|
||||||
|
offline: "Offline",
|
||||||
|
stale: "Sem sinal",
|
||||||
|
maintenance: "Em manutenção",
|
||||||
|
blocked: "Bloqueado",
|
||||||
|
deactivated: "Desativado",
|
||||||
|
unknown: "Desconhecida",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeviceStatusSource = {
|
||||||
|
status?: string | null
|
||||||
|
lastHeartbeatAt?: number | null
|
||||||
|
isActive?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDeviceStatus(source?: DeviceStatusSource | null): string {
|
||||||
|
if (!source) return "unknown"
|
||||||
|
if (source.isActive === false) return "deactivated"
|
||||||
|
const manualStatus = (source.status ?? "").toLowerCase()
|
||||||
|
if (manualStatus === "maintenance" || manualStatus === "blocked") {
|
||||||
|
return manualStatus
|
||||||
|
}
|
||||||
|
const heartbeat = source.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 source.status ?? "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_INDICATORS: Record<
|
||||||
|
string,
|
||||||
|
{ dotClass: string; ringClass: string; isPinging: boolean }
|
||||||
|
> = {
|
||||||
|
online: { dotClass: "bg-emerald-500", ringClass: "bg-emerald-400/30", isPinging: true },
|
||||||
|
offline: { dotClass: "bg-rose-500", ringClass: "bg-rose-400/30", isPinging: false },
|
||||||
|
stale: { dotClass: "bg-amber-500", ringClass: "bg-amber-400/30", isPinging: false },
|
||||||
|
maintenance: { dotClass: "bg-amber-500", ringClass: "bg-amber-400/30", isPinging: false },
|
||||||
|
blocked: { dotClass: "bg-orange-500", ringClass: "bg-orange-400/30", isPinging: false },
|
||||||
|
deactivated: { dotClass: "bg-slate-500", ringClass: "bg-slate-400/40", isPinging: false },
|
||||||
|
unknown: { dotClass: "bg-slate-400", ringClass: "bg-slate-300/30", isPinging: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDeviceStatusIndicator(status?: string | null) {
|
||||||
|
const normalized = (status ?? "unknown").toLowerCase()
|
||||||
|
return STATUS_INDICATORS[normalized] ?? STATUS_INDICATORS.unknown
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue