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
|
|
@ -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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
|
|
@ -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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 "?"
|
||||||
|
|
|
||||||
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