Refine machine details layout and improve download feedback
This commit is contained in:
parent
5f7efa13e6
commit
1251468b77
6 changed files with 96 additions and 10 deletions
|
|
@ -680,6 +680,50 @@ function formatAbsoluteDateTime(date?: Date | null) {
|
|||
}).format(date)
|
||||
}
|
||||
|
||||
function parseDateish(value: unknown): Date | null {
|
||||
if (!value) return null
|
||||
if (value instanceof Date && !Number.isNaN(value.getTime())) return value
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
const numeric = value > 1e12 ? value : value > 1e9 ? value * 1000 : value
|
||||
const date = new Date(numeric)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const numericValue = Number(trimmed)
|
||||
if (Number.isFinite(numericValue)) {
|
||||
const asMs = trimmed.length >= 13 ? numericValue : numericValue * 1000
|
||||
const date = new Date(asMs)
|
||||
if (!Number.isNaN(date.getTime())) return date
|
||||
}
|
||||
const date = new Date(trimmed)
|
||||
if (!Number.isNaN(date.getTime())) return date
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getMetricsTimestamp(metrics: MachineMetrics): Date | null {
|
||||
if (!metrics || typeof metrics !== "object") return null
|
||||
const data = metrics as Record<string, unknown>
|
||||
const candidates = [
|
||||
data["collectedAt"],
|
||||
data["collected_at"],
|
||||
data["collected_at_iso"],
|
||||
data["collected_at_ms"],
|
||||
data["timestamp"],
|
||||
data["updatedAt"],
|
||||
data["updated_at"],
|
||||
data["createdAt"],
|
||||
data["created_at"],
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
const parsed = parseDateish(candidate)
|
||||
if (parsed) return parsed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function formatBytes(bytes?: number | null) {
|
||||
if (!bytes || Number.isNaN(bytes)) return "—"
|
||||
const units = ["B", "KB", "MB", "GB", "TB"]
|
||||
|
|
@ -930,6 +974,8 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
const { convexUserId } = useAuth()
|
||||
const router = useRouter()
|
||||
const effectiveStatus = machine ? resolveMachineStatus(machine) : "unknown"
|
||||
const isActive = machine?.isActive ?? true
|
||||
const isDeactivated = !isActive || effectiveStatus === "deactivated"
|
||||
// Company name lookup (by slug)
|
||||
const companyQueryArgs = convexUserId && machine ? { tenantId: machine.tenantId, viewerId: convexUserId as Id<"users"> } : undefined
|
||||
const companies = useQuery(
|
||||
|
|
@ -946,6 +992,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
const machineAlertsHistory = alertsHistory ?? []
|
||||
const metadata = machine?.inventory ?? null
|
||||
const metrics = machine?.metrics ?? null
|
||||
const metricsCapturedAt = useMemo(() => getMetricsTimestamp(metrics), [metrics])
|
||||
const hardware = metadata?.hardware
|
||||
const network = metadata?.network ?? null
|
||||
const networkInterfaces = Array.isArray(network) ? network : null
|
||||
|
|
@ -972,7 +1019,6 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
? windowsBaseboardRaw
|
||||
: null
|
||||
const windowsSerialNumber = windowsBaseboard ? readString(toRecord(windowsBaseboard) ?? {}, "SerialNumber", "serialNumber") : undefined
|
||||
const isActive = machine?.isActive ?? true
|
||||
const windowsMemoryModules = useMemo(() => {
|
||||
if (Array.isArray(windowsMemoryModulesRaw)) return windowsMemoryModulesRaw
|
||||
if (windowsMemoryModulesRaw && typeof windowsMemoryModulesRaw === "object") return [windowsMemoryModulesRaw]
|
||||
|
|
@ -1307,9 +1353,9 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
|
||||
return (
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardHeader className="gap-1">
|
||||
<CardTitle>Detalhes</CardTitle>
|
||||
<CardDescription>Resumo da máquina selecionada.</CardDescription>
|
||||
<CardDescription>Resumo da máquina selecionada</CardDescription>
|
||||
{machine ? (
|
||||
<CardAction>
|
||||
<div className="flex flex-col items-end gap-2 text-xs sm:text-sm">
|
||||
|
|
@ -1318,7 +1364,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
{companyName}
|
||||
</div>
|
||||
) : null}
|
||||
<MachineStatusBadge status={effectiveStatus} />
|
||||
{!isDeactivated ? <MachineStatusBadge status={effectiveStatus} /> : null}
|
||||
{!isActive ? (
|
||||
<Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 font-semibold uppercase text-rose-700">
|
||||
Máquina desativada
|
||||
|
|
@ -1499,7 +1545,14 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Métricas recentes</h4>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h4 className="text-sm font-semibold">Métricas recentes</h4>
|
||||
{metricsCapturedAt ? (
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Última atualização {formatRelativeTime(metricsCapturedAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<MetricsGrid metrics={metrics} hardware={hardware} disks={disks} />
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -328,14 +328,14 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
) : null}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<label htmlFor="comment" className="text-sm font-medium text-neutral-800">
|
||||
<label htmlFor="comment" className="block text-sm font-medium text-neutral-800">
|
||||
Enviar uma mensagem para a equipe
|
||||
</label>
|
||||
<RichTextEditor
|
||||
value={comment}
|
||||
onChange={(html) => setComment(html)}
|
||||
placeholder="Descreva o que aconteceu, envie atualizações ou compartilhe novas informações."
|
||||
className="rounded-2xl border border-slate-200 shadow-sm focus-within:border-neutral-900 focus-within:ring-neutral-900/20"
|
||||
className="mt-3 rounded-2xl border border-slate-200 shadow-sm focus-within:border-neutral-900 focus-within:ring-neutral-900/20"
|
||||
disabled={machineInactive}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -610,6 +610,8 @@ function PortalCommentAttachmentCard({
|
|||
const handleDownload = useCallback(async () => {
|
||||
const target = await ensureUrl()
|
||||
if (!target) return
|
||||
const toastId = `portal-attachment-download-${attachment.id}`
|
||||
toast.loading("Baixando anexo...", { id: toastId })
|
||||
try {
|
||||
const response = await fetch(target, { credentials: "include" })
|
||||
if (!response.ok) {
|
||||
|
|
@ -627,9 +629,11 @@ function PortalCommentAttachmentCard({
|
|||
window.setTimeout(() => {
|
||||
window.URL.revokeObjectURL(blobUrl)
|
||||
}, 1000)
|
||||
toast.success("Download concluído!", { id: toastId })
|
||||
} catch (error) {
|
||||
console.error("Falha ao iniciar download do anexo", error)
|
||||
window.open(target, "_blank", "noopener,noreferrer")
|
||||
toast.error("Não foi possível baixar o anexo automaticamente.", { id: toastId })
|
||||
}
|
||||
}, [attachment.name, ensureUrl])
|
||||
|
||||
|
|
|
|||
|
|
@ -613,6 +613,8 @@ function CommentAttachmentCard({
|
|||
const handleDownload = useCallback(async () => {
|
||||
const target = url ?? (await ensureUrl())
|
||||
if (!target) return
|
||||
const toastId = `attachment-download-${attachment.id}`
|
||||
toast.loading("Baixando anexo...", { id: toastId })
|
||||
try {
|
||||
const response = await fetch(target, { credentials: "include" })
|
||||
if (!response.ok) {
|
||||
|
|
@ -630,9 +632,11 @@ function CommentAttachmentCard({
|
|||
window.setTimeout(() => {
|
||||
window.URL.revokeObjectURL(blobUrl)
|
||||
}, 1000)
|
||||
toast.success("Download concluído!", { id: toastId })
|
||||
} catch (error) {
|
||||
console.error("Failed to download attachment", error)
|
||||
window.open(target, "_blank", "noopener,noreferrer")
|
||||
toast.error("Não foi possível baixar o anexo automaticamente.", { id: toastId })
|
||||
}
|
||||
}, [attachment.name, ensureUrl, url])
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ const tagBadgeClass = "inline-flex items-center gap-1 rounded-full border border
|
|||
const iconAccentClass = "size-3 text-neutral-700"
|
||||
|
||||
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||
const isAvulso = Boolean(ticket.company?.isAvulso)
|
||||
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader className="px-4 pb-3">
|
||||
|
|
@ -27,6 +30,11 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
<Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
|
||||
</div>
|
||||
<Separator className="bg-slate-200" />
|
||||
<div className="space-y-1 break-words">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Empresa</p>
|
||||
<Badge className={queueBadgeClass}>{companyLabel}</Badge>
|
||||
</div>
|
||||
<Separator className="bg-slate-200" />
|
||||
<div className="space-y-2 break-words">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">SLA</p>
|
||||
{ticket.slaPolicy ? (
|
||||
|
|
|
|||
|
|
@ -165,7 +165,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const queueDirty = useMemo(() => queueSelection !== currentQueueName, [queueSelection, currentQueueName])
|
||||
const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId])
|
||||
const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty
|
||||
|
||||
const companyLabel = useMemo(() => {
|
||||
if (ticket.company?.name) return ticket.company.name
|
||||
if (isAvulso) return "Cliente avulso"
|
||||
return "Sem empresa vinculada"
|
||||
}, [ticket.company?.name, isAvulso])
|
||||
|
||||
const activeCategory = useMemo(
|
||||
() => categories.find((category) => category.id === selectedCategoryId) ?? null,
|
||||
[categories, selectedCategoryId]
|
||||
|
|
@ -726,6 +731,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
<span className={sectionLabelClass}>Solicitante</span>
|
||||
<span className={sectionValueClass}>{ticket.requester.name}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={sectionLabelClass}>Empresa</span>
|
||||
<span className={sectionValueClass}>{companyLabel}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={sectionLabelClass}>Responsável</span>
|
||||
{editing ? (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue