feat: enrich companies with phone input and machine overview
This commit is contained in:
parent
4c228e908a
commit
f1a0b9dae5
6 changed files with 339 additions and 10 deletions
|
|
@ -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 "?"
|
||||
|
|
|
|||
171
src/components/ui/phone-input.tsx
Normal file
171
src/components/ui/phone-input.tsx
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue