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

@ -44,6 +44,12 @@
"icons/icon.icns", "icons/icon.icns",
"icons/icon.png", "icons/icon.png",
"icons/Raven.png" "icons/Raven.png"
] ],
"windows": {
"iconPath": "icons/icon.ico",
"setupIconPath": "icons/icon.ico",
"shortcutIconPath": "icons/icon.ico",
"languages": ["pt-BR"]
}
} }
} }

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View file

@ -21,7 +21,12 @@ export const metadata: Metadata = {
title: "Raven - Sistema de chamados", title: "Raven - Sistema de chamados",
description: "Plataforma Raven da Rever", description: "Plataforma Raven da Rever",
icons: { icons: {
icon: "/icon.png", icon: [
{ url: "/favicon.ico", rel: "icon" },
{ url: "/icon.png", rel: "icon", type: "image/png" },
],
shortcut: "/favicon.ico",
apple: "/icon.png",
}, },
} }

View file

@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react" import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
import { formatDistanceToNow } from "date-fns" import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { useQuery } from "convex/react"
import { import {
IconAlertTriangle, IconAlertTriangle,
IconBuildingSkyscraper, IconBuildingSkyscraper,
@ -25,6 +26,7 @@ import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { PhoneInput, formatPhoneDisplay } from "@/components/ui/phone-input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { import {
@ -47,6 +49,7 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "@/hooks/use-mobile"
import { api } from "@/convex/_generated/api"
type Company = { type Company = {
id: string id: string
@ -63,6 +66,15 @@ type Company = {
address: string | null 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[] }) { export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: Company[] }) {
const [companies, setCompanies] = useState<Company[]>(() => initialCompanies ?? []) const [companies, setCompanies] = useState<Company[]>(() => initialCompanies ?? [])
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
@ -74,6 +86,23 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
const [searchTerm, setSearchTerm] = useState("") const [searchTerm, setSearchTerm] = useState("")
const isMobile = useIsMobile() 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({}) const resetForm = () => setForm({})
async function refresh() { async function refresh() {
@ -260,7 +289,6 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
) : null} ) : null}
</Empty> </Empty>
) )
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<TooltipProvider delayDuration={120}> <TooltipProvider delayDuration={120}>
@ -313,10 +341,9 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Telefone</Label> <Label>Telefone</Label>
<Input <PhoneInput
value={form.phone ?? ""} value={form.phone ?? ""}
onChange={(e) => setForm((p) => ({ ...p, phone: e.target.value }))} onChange={(value) => setForm((p) => ({ ...p, phone: value || null }))}
placeholder="(+55) 11 99999-9999"
/> />
</div> </div>
<div className="grid gap-2 md:col-span-2"> <div className="grid gap-2 md:col-span-2">
@ -358,6 +385,51 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
</CardContent> </CardContent>
</Card> </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"> <Card className="border-slate-200/80 shadow-none">
<CardHeader className="gap-4 border-b border-slate-100 py-6 dark:border-slate-800/60"> <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"> <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"> <div className="space-y-4 p-4">
{hasCompanies ? ( {hasCompanies ? (
filteredCompanies.map((company) => { filteredCompanies.map((company) => {
const companyMachines = machinesByCompanyId.get(company.id) ?? []
const formattedPhone = formatPhoneDisplay(company.phone)
const alertInfo = lastAlerts[company.slug] ?? null const alertInfo = lastAlerts[company.slug] ?? null
const usagePct = alertInfo?.usagePct ?? 0 const usagePct = alertInfo?.usagePct ?? 0
const threshold = alertInfo?.threshold ?? 0 const threshold = alertInfo?.threshold ?? 0
@ -455,9 +529,50 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
</DropdownMenu> </DropdownMenu>
</div> </div>
<div className="mt-3 grid gap-1 text-xs text-muted-foreground"> <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} {company.address ? <span>{company.address}</span> : null}
</div> </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"> <div className="mt-4">
<ProvisioningCodeCard code={company.provisioningCode} /> <ProvisioningCodeCard code={company.provisioningCode} />
</div> </div>
@ -538,6 +653,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
const lastAlertDistance = alertInfo const lastAlertDistance = alertInfo
? formatDistanceToNow(alertInfo.createdAt, { addSuffix: true, locale: ptBR }) ? formatDistanceToNow(alertInfo.createdAt, { addSuffix: true, locale: ptBR })
: null : null
const formattedPhone = formatPhoneDisplay(company.phone)
return ( return (
<TableRow <TableRow
key={company.id} key={company.id}
@ -578,12 +694,12 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
) : null} ) : null}
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{company.phone ? <span>{company.phone}</span> : null} {formattedPhone ? <span>{formattedPhone}</span> : null}
{company.phone && company.address ? ( {formattedPhone && company.address ? (
<span className="bg-slate-300/70 dark:bg-slate-700/70 block size-1 rounded-full" /> <span className="bg-slate-300/70 dark:bg-slate-700/70 block size-1 rounded-full" />
) : null} ) : null}
{company.address ? <span className="truncate">{company.address}</span> : 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> <span className="truncate text-muted-foreground/70">{company.description}</span>
) : null} ) : null}
</div> </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) { function getInitials(value: string) {
const cleaned = value.trim() const cleaned = value.trim()
if (!cleaned) return "?" if (!cleaned) return "?"

View file

@ -0,0 +1,171 @@
"use client"
import { ChangeEvent, useEffect, useMemo, useState } from "react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
export type CountryOption = {
code: string
label: string
dialCode: string
flag: string
maxDigits: number
formatLocal: (digits: string) => string
}
const COUNTRY_OPTIONS: CountryOption[] = [
{
code: "BR",
label: "Brasil",
dialCode: "+55",
flag: "🇧🇷",
maxDigits: 11,
formatLocal: formatBrazilPhone,
},
{
code: "US",
label: "Estados Unidos",
dialCode: "+1",
flag: "🇺🇸",
maxDigits: 10,
formatLocal: formatUsPhone,
},
]
const DEFAULT_COUNTRY = COUNTRY_OPTIONS[0]
function formatBrazilPhone(digits: string): string {
const cleaned = digits.slice(0, 11)
if (!cleaned) return ""
const area = cleaned.slice(0, 2)
const rest = cleaned.slice(2)
if (cleaned.length <= 2) return `(${cleaned}`
if (rest.length <= 4) return `(${area}) ${rest}`
if (rest.length <= 8) return `(${area}) ${rest.slice(0, 4)}-${rest.slice(4)}`
return `(${area}) ${rest.slice(0, 5)}-${rest.slice(5)}`
}
function formatUsPhone(digits: string): string {
const cleaned = digits.slice(0, 10)
if (!cleaned) return ""
const area = cleaned.slice(0, 3)
const rest = cleaned.slice(3)
if (cleaned.length <= 3) return `(${cleaned}`
if (rest.length <= 3) return `(${area}) ${rest}`
return `(${area}) ${rest.slice(0, 3)}-${rest.slice(3)}`
}
function extractDigits(value?: string | null): string {
return (value ?? "").replace(/\D+/g, "")
}
function findCountryByValue(value?: string | null): CountryOption {
const digits = extractDigits(value)
return (
COUNTRY_OPTIONS.find((option) => {
const dialDigits = extractDigits(option.dialCode)
return digits.startsWith(dialDigits) && digits.length > dialDigits.length
}) ?? DEFAULT_COUNTRY
)
}
function toLocalDigits(value: string | null | undefined, country: CountryOption): string {
const digits = extractDigits(value)
const dialDigits = extractDigits(country.dialCode)
return digits.startsWith(dialDigits) ? digits.slice(dialDigits.length) : digits
}
function formatStoredValue(country: CountryOption, localDigits: string): string {
const trimmed = localDigits.trim()
if (!trimmed) return ""
const formattedLocal = country.formatLocal(trimmed)
if (!formattedLocal) return country.dialCode
return `${country.dialCode} ${formattedLocal}`.trim()
}
function placeholderFor(country: CountryOption): string {
if (country.code === "BR") return "(11) 91234-5678"
if (country.code === "US") return "(555) 123-4567"
return "Número de telefone"
}
export function PhoneInput({ value, onChange, className }: PhoneInputProps) {
const [selectedCountry, setSelectedCountry] = useState<CountryOption>(DEFAULT_COUNTRY)
const [localDigits, setLocalDigits] = useState<string>("")
useEffect(() => {
const country = findCountryByValue(value)
setSelectedCountry(country)
setLocalDigits(toLocalDigits(value, country).slice(0, country.maxDigits))
}, [value])
const displayValue = useMemo(() => selectedCountry.formatLocal(localDigits), [selectedCountry, localDigits])
const handleCountryChange = (code: string) => {
const country = COUNTRY_OPTIONS.find((option) => option.code === code) ?? DEFAULT_COUNTRY
setSelectedCountry(country)
const limitedDigits = localDigits.slice(0, country.maxDigits)
setLocalDigits(limitedDigits)
const stored = formatStoredValue(country, limitedDigits)
onChange?.(stored)
}
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const raw = event.target.value
const digits = extractDigits(raw).slice(0, selectedCountry.maxDigits)
setLocalDigits(digits)
const stored = formatStoredValue(selectedCountry, digits)
onChange?.(stored)
}
return (
<div className={cn("flex flex-col gap-2 sm:flex-row sm:items-center", className)}>
<Select value={selectedCountry.code} onValueChange={handleCountryChange}>
<SelectTrigger className="w-40">
<SelectValue>
<span className="inline-flex items-center gap-2">
<span>{selectedCountry.flag}</span>
<span className="text-sm font-medium text-foreground">{selectedCountry.dialCode}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
{COUNTRY_OPTIONS.map((option) => (
<SelectItem key={option.code} value={option.code}>
<span className="inline-flex items-center gap-2">
<span>{option.flag}</span>
<span>{option.label}</span>
<span className="text-muted-foreground">{option.dialCode}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="tel"
value={displayValue}
onChange={handleInputChange}
placeholder={placeholderFor(selectedCountry)}
inputMode="tel"
/>
</div>
)
}
export type PhoneInputProps = {
value?: string | null
onChange?: (value: string) => void
className?: string
}
export function formatPhoneDisplay(rawValue?: string | null): string | null {
const trimmed = rawValue?.trim()
if (!trimmed) return null
const country = findCountryByValue(trimmed)
const localDigits = toLocalDigits(trimmed, country)
const formattedLocal = country.formatLocal(localDigits)
if (!formattedLocal) return `${country.dialCode}`.trim()
return `${country.dialCode} ${formattedLocal}`.trim()
}