diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index e22d75b..d108c40 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -44,6 +44,12 @@ "icons/icon.icns", "icons/icon.png", "icons/Raven.png" - ] + ], + "windows": { + "iconPath": "icons/icon.ico", + "setupIconPath": "icons/icon.ico", + "shortcutIconPath": "icons/icon.ico", + "languages": ["pt-BR"] + } } } diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..b1471cf Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000..b1c7cf7 Binary files /dev/null and b/public/icon.png differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e13e00f..da91f5c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -21,7 +21,12 @@ export const metadata: Metadata = { title: "Raven - Sistema de chamados", description: "Plataforma Raven da Rever", icons: { - icon: "/icon.png", + icon: [ + { url: "/favicon.ico", rel: "icon" }, + { url: "/icon.png", rel: "icon", type: "image/png" }, + ], + shortcut: "/favicon.ico", + apple: "/icon.png", }, } diff --git a/src/components/admin/companies/admin-companies-manager.tsx b/src/components/admin/companies/admin-companies-manager.tsx index 3bdacb0..051a6da 100644 --- a/src/components/admin/companies/admin-companies-manager.tsx +++ b/src/components/admin/companies/admin-companies-manager.tsx @@ -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(() => 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() + ;(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} ) - return (
@@ -313,10 +341,9 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
- setForm((p) => ({ ...p, phone: e.target.value }))} - placeholder="(+55) 11 99999-9999" + onChange={(value) => setForm((p) => ({ ...p, phone: value || null }))} />
@@ -358,6 +385,51 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: + {editingId ? ( + + +
+
+ Máquinas desta empresa + + Status e último sinal das máquinas vinculadas. + +
+ + {editingCompanyMachines.length} máquina{editingCompanyMachines.length === 1 ? "" : "s"} + +
+
+ + {editingCompanyMachines.length > 0 ? ( +
+ {editingCompanyMachines.map((machine) => { + const variant = getMachineStatusVariant(machine.status) + return ( +
+
+

{machine.hostname}

+ + {variant.label} + +
+

+ Último sinal: {formatRelativeTimestamp(machine.lastHeartbeatAt) ?? "nunca"} +

+
+ ) + })} +
+ ) : ( +

Nenhuma máquina vinculada a esta empresa.

+ )} +
+
+ ) : null} +
@@ -397,6 +469,8 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
{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:
- {company.phone ? {company.phone} : null} + {formattedPhone ? {formattedPhone} : null} {company.address ? {company.address} : null}
+ {companyMachines.length > 0 ? ( +
+
+ + {companyMachines.length} máquina{companyMachines.length > 1 ? "s" : ""} + + {Object.entries(summarizeStatus(companyMachines)).map(([status, count]) => ( + + {getMachineStatusVariant(status).label}: {count} + + ))} +
+
+ {companyMachines.slice(0, 3).map((machine) => { + const variant = getMachineStatusVariant(machine.status) + return ( + + + + {machine.hostname} + + + +

{machine.hostname}

+

+ Último sinal: {formatRelativeTimestamp(machine.lastHeartbeatAt) ?? "nunca"} +

+
+
+ ) + })} + {companyMachines.length > 3 ? ( + + +{companyMachines.length - 3} {companyMachines.length - 3 === 1 ? "outra" : "outras"} + + ) : null} +
+
+ ) : ( +

Nenhuma máquina vinculada.

+ )}
@@ -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 (
- {company.phone ? {company.phone} : null} - {company.phone && company.address ? ( + {formattedPhone ? {formattedPhone} : null} + {formattedPhone && company.address ? ( ) : null} {company.address ? {company.address} : null} - {!company.phone && !company.address && company.description ? ( + {!formattedPhone && !company.address && company.description ? ( {company.description} ) : null}
@@ -741,6 +857,37 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: ) } +const MACHINE_STATUS_VARIANTS: Record = { + 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 { + return machines.reduce>((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 "?" diff --git a/src/components/ui/phone-input.tsx b/src/components/ui/phone-input.tsx new file mode 100644 index 0000000..85cd8f8 --- /dev/null +++ b/src/components/ui/phone-input.tsx @@ -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(DEFAULT_COUNTRY) + const [localDigits, setLocalDigits] = useState("") + + 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) => { + const raw = event.target.value + const digits = extractDigits(raw).slice(0, selectedCountry.maxDigits) + setLocalDigits(digits) + const stored = formatStoredValue(selectedCountry, digits) + onChange?.(stored) + } + + return ( +
+ + +
+ ) +} + +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() +}