feat: enrich companies with phone input and machine overview

This commit is contained in:
Esdras Renan 2025-10-16 23:19:12 -03:00
parent 4c228e908a
commit f1a0b9dae5
6 changed files with 339 additions and 10 deletions

View file

@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { useQuery } from "convex/react"
import {
IconAlertTriangle,
IconBuildingSkyscraper,
@ -25,6 +26,7 @@ import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PhoneInput, formatPhoneDisplay } from "@/components/ui/phone-input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import {
@ -47,6 +49,7 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Progress } from "@/components/ui/progress"
import { useIsMobile } from "@/hooks/use-mobile"
import { api } from "@/convex/_generated/api"
type Company = {
id: string
@ -63,6 +66,15 @@ type Company = {
address: string | null
}
type MachineSummary = {
id: string
tenantId: string
companyId: string | null
hostname: string
status: string | null
lastHeartbeatAt: number | null
}
export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: Company[] }) {
const [companies, setCompanies] = useState<Company[]>(() => initialCompanies ?? [])
const [isPending, startTransition] = useTransition()
@ -74,6 +86,23 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
const [searchTerm, setSearchTerm] = useState("")
const isMobile = useIsMobile()
const machinesQuery = useQuery(api.machines.listByTenant, { includeMetadata: false }) as MachineSummary[] | undefined
const machinesByCompanyId = useMemo(() => {
const map = new Map<string, MachineSummary[]>()
;(machinesQuery ?? []).forEach((machine) => {
if (!machine.companyId) return
const list = map.get(machine.companyId) ?? []
list.push(machine)
map.set(machine.companyId, list)
})
return map
}, [machinesQuery])
const editingCompanyMachines = useMemo(() => {
if (!editingId) return []
return machinesByCompanyId.get(editingId) ?? []
}, [machinesByCompanyId, editingId])
const resetForm = () => setForm({})
async function refresh() {
@ -260,7 +289,6 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
) : null}
</Empty>
)
return (
<div className="space-y-6">
<TooltipProvider delayDuration={120}>
@ -313,10 +341,9 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
</div>
<div className="grid gap-2">
<Label>Telefone</Label>
<Input
<PhoneInput
value={form.phone ?? ""}
onChange={(e) => setForm((p) => ({ ...p, phone: e.target.value }))}
placeholder="(+55) 11 99999-9999"
onChange={(value) => setForm((p) => ({ ...p, phone: value || null }))}
/>
</div>
<div className="grid gap-2 md:col-span-2">
@ -358,6 +385,51 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
</CardContent>
</Card>
{editingId ? (
<Card className="border-slate-200/80 shadow-none">
<CardHeader>
<div className="flex items-center justify-between gap-2">
<div>
<CardTitle className="text-base font-semibold">Máquinas desta empresa</CardTitle>
<CardDescription className="text-sm">
Status e último sinal das máquinas vinculadas.
</CardDescription>
</div>
<Badge variant="outline" className="border-slate-200 bg-white text-xs font-medium dark:border-slate-800 dark:bg-slate-900">
{editingCompanyMachines.length} máquina{editingCompanyMachines.length === 1 ? "" : "s"}
</Badge>
</div>
</CardHeader>
<CardContent>
{editingCompanyMachines.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{editingCompanyMachines.map((machine) => {
const variant = getMachineStatusVariant(machine.status)
return (
<div
key={machine.id}
className="flex flex-col gap-1 rounded-lg border border-slate-200/80 bg-slate-50/60 p-3 dark:border-slate-800/60 dark:bg-slate-900/40"
>
<div className="flex items-center justify-between gap-2">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">{machine.hostname}</p>
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium", variant.className)}>
{variant.label}
</span>
</div>
<p className="text-xs text-muted-foreground">
Último sinal: {formatRelativeTimestamp(machine.lastHeartbeatAt) ?? "nunca"}
</p>
</div>
)
})}
</div>
) : (
<p className="text-sm text-muted-foreground">Nenhuma máquina vinculada a esta empresa.</p>
)}
</CardContent>
</Card>
) : null}
<Card className="border-slate-200/80 shadow-none">
<CardHeader className="gap-4 border-b border-slate-100 py-6 dark:border-slate-800/60">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
@ -397,6 +469,8 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
<div className="space-y-4 p-4">
{hasCompanies ? (
filteredCompanies.map((company) => {
const companyMachines = machinesByCompanyId.get(company.id) ?? []
const formattedPhone = formatPhoneDisplay(company.phone)
const alertInfo = lastAlerts[company.slug] ?? null
const usagePct = alertInfo?.usagePct ?? 0
const threshold = alertInfo?.threshold ?? 0
@ -455,9 +529,50 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
</DropdownMenu>
</div>
<div className="mt-3 grid gap-1 text-xs text-muted-foreground">
{company.phone ? <span>{company.phone}</span> : null}
{formattedPhone ? <span>{formattedPhone}</span> : null}
{company.address ? <span>{company.address}</span> : null}
</div>
{companyMachines.length > 0 ? (
<div className="mt-3 space-y-2">
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<Badge variant="outline" className="border-slate-200 bg-white px-2 text-[11px] font-medium dark:border-slate-800 dark:bg-slate-900">
{companyMachines.length} máquina{companyMachines.length > 1 ? "s" : ""}
</Badge>
{Object.entries(summarizeStatus(companyMachines)).map(([status, count]) => (
<Badge key={`${company.id}-${status}`} variant="outline" className={cn("px-2 text-[11px] font-medium", getMachineStatusVariant(status).className)}>
{getMachineStatusVariant(status).label}: {count}
</Badge>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
{companyMachines.slice(0, 3).map((machine) => {
const variant = getMachineStatusVariant(machine.status)
return (
<Tooltip key={machine.id}>
<TooltipTrigger asChild>
<span className={cn("inline-flex items-center gap-1 rounded-full border px-2 py-1 text-[11px] font-medium", variant.className)}>
{machine.hostname}
</span>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs font-medium text-foreground">{machine.hostname}</p>
<p className="text-[11px] text-muted-foreground">
Último sinal: {formatRelativeTimestamp(machine.lastHeartbeatAt) ?? "nunca"}
</p>
</TooltipContent>
</Tooltip>
)
})}
{companyMachines.length > 3 ? (
<span className="text-[11px] text-muted-foreground">
+{companyMachines.length - 3} {companyMachines.length - 3 === 1 ? "outra" : "outras"}
</span>
) : null}
</div>
</div>
) : (
<p className="mt-3 text-[11px] text-muted-foreground">Nenhuma máquina vinculada.</p>
)}
<div className="mt-4">
<ProvisioningCodeCard code={company.provisioningCode} />
</div>
@ -538,6 +653,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
const lastAlertDistance = alertInfo
? formatDistanceToNow(alertInfo.createdAt, { addSuffix: true, locale: ptBR })
: null
const formattedPhone = formatPhoneDisplay(company.phone)
return (
<TableRow
key={company.id}
@ -578,12 +694,12 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{company.phone ? <span>{company.phone}</span> : null}
{company.phone && company.address ? (
{formattedPhone ? <span>{formattedPhone}</span> : null}
{formattedPhone && company.address ? (
<span className="bg-slate-300/70 dark:bg-slate-700/70 block size-1 rounded-full" />
) : null}
{company.address ? <span className="truncate">{company.address}</span> : null}
{!company.phone && !company.address && company.description ? (
{!formattedPhone && !company.address && company.description ? (
<span className="truncate text-muted-foreground/70">{company.description}</span>
) : null}
</div>
@ -741,6 +857,37 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
)
}
const MACHINE_STATUS_VARIANTS: Record<string, { label: string; className: string }> = {
online: { label: "Online", className: "border-emerald-200 bg-emerald-500/10 text-emerald-600" },
offline: { label: "Offline", className: "border-rose-200 bg-rose-500/10 text-rose-600" },
stale: { label: "Sem sinal", className: "border-slate-300 bg-slate-200/60 text-slate-700" },
maintenance: { label: "Manutenção", className: "border-amber-200 bg-amber-500/10 text-amber-600" },
blocked: { label: "Bloqueada", className: "border-orange-200 bg-orange-500/10 text-orange-600" },
unknown: { label: "Desconhecida", className: "border-slate-200 bg-slate-100 text-slate-600" },
}
function getMachineStatusVariant(status?: string | null) {
const normalized = (status ?? "unknown").toLowerCase()
return MACHINE_STATUS_VARIANTS[normalized] ?? MACHINE_STATUS_VARIANTS.unknown
}
function summarizeStatus(machines: MachineSummary[]): Record<string, number> {
return machines.reduce<Record<string, number>>((acc, machine) => {
const normalized = (machine.status ?? "unknown").toLowerCase()
acc[normalized] = (acc[normalized] ?? 0) + 1
return acc
}, {})
}
function formatRelativeTimestamp(timestamp?: number | null): string | null {
if (!timestamp || !Number.isFinite(timestamp)) return null
try {
return formatDistanceToNow(new Date(timestamp), { addSuffix: true, locale: ptBR })
} catch {
return null
}
}
function getInitials(value: string) {
const cleaned = value.trim()
if (!cleaned) return "?"