feat: alerts page and alert components (AlertCard, AlertsSection, AlertsTabs)
This commit is contained in:
61
src/app/(dashboard)/alerts/page.tsx
Normal file
61
src/app/(dashboard)/alerts/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import AlertsSection from '@/components/alerts/AlertsSection'
|
||||
import type { AlertCardData } from '@/components/alerts/AlertCard'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
|
||||
export default function AlertsPage() {
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const alertsQuery = trpc.alertas.list.useQuery(
|
||||
{ page: 1, limit: 100 },
|
||||
{ refetchOnWindowFocus: false }
|
||||
)
|
||||
|
||||
const acknowledgeMutation = trpc.alertas.reconocer.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.alertas.list.invalidate()
|
||||
utils.alertas.conteoActivas.invalidate()
|
||||
utils.clientes.dashboardStats.invalidate()
|
||||
},
|
||||
})
|
||||
const resolveMutation = trpc.alertas.resolver.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.alertas.list.invalidate()
|
||||
utils.alertas.conteoActivas.invalidate()
|
||||
utils.clientes.dashboardStats.invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
const alerts: AlertCardData[] = useMemo(() => {
|
||||
const list = alertsQuery.data?.alertas ?? []
|
||||
return list.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.titulo,
|
||||
device: a.dispositivo?.nombre ?? '—',
|
||||
description: a.mensaje,
|
||||
severity: a.severidad,
|
||||
timestamp: a.createdAt instanceof Date ? a.createdAt : new Date(a.createdAt),
|
||||
status: a.estado,
|
||||
}))
|
||||
}, [alertsQuery.data])
|
||||
|
||||
const handleAcknowledge = (id: string) => {
|
||||
acknowledgeMutation.mutate({ id })
|
||||
}
|
||||
const handleResolve = (id: string) => {
|
||||
resolveMutation.mutate({ id })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<AlertsSection
|
||||
alerts={alerts}
|
||||
isLoading={alertsQuery.isLoading}
|
||||
onAcknowledge={handleAcknowledge}
|
||||
onResolve={handleResolve}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
100
src/components/alerts/AlertCard.tsx
Normal file
100
src/components/alerts/AlertCard.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
|
||||
import { cn, formatRelativeTime } from '@/lib/utils'
|
||||
|
||||
export type AlertSeverity = 'CRITICAL' | 'WARNING' | 'INFO'
|
||||
|
||||
export type AlertStatus = 'ACTIVA' | 'RECONOCIDA' | 'RESUELTA'
|
||||
|
||||
export interface AlertCardData {
|
||||
id: string
|
||||
title: string
|
||||
device: string
|
||||
description: string
|
||||
severity: AlertSeverity
|
||||
timestamp: Date | string
|
||||
status: AlertStatus
|
||||
}
|
||||
|
||||
interface AlertCardProps {
|
||||
alert: AlertCardData
|
||||
onAcknowledge?: (id: string) => void
|
||||
onResolve?: (id: string) => void
|
||||
}
|
||||
|
||||
const severityStyles = {
|
||||
CRITICAL: {
|
||||
bar: 'bg-red-500',
|
||||
badge: 'bg-red-500/20 text-red-400 border-red-500/40',
|
||||
label: 'CRÍTICO',
|
||||
},
|
||||
WARNING: {
|
||||
bar: 'bg-amber-500',
|
||||
badge: 'bg-amber-500/20 text-amber-400 border-amber-500/40',
|
||||
label: 'ADVERTENCIA',
|
||||
},
|
||||
INFO: {
|
||||
bar: 'bg-blue-500',
|
||||
badge: 'bg-blue-500/20 text-blue-400 border-blue-500/40',
|
||||
label: 'INFO',
|
||||
},
|
||||
}
|
||||
|
||||
export default function AlertCard({ alert, onAcknowledge, onResolve }: AlertCardProps) {
|
||||
const style = severityStyles[alert.severity]
|
||||
const ts = typeof alert.timestamp === 'string' ? new Date(alert.timestamp) : alert.timestamp
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-slate-700/60 bg-slate-800/50 shadow-sm',
|
||||
'p-5 transition-all duration-200 hover:border-slate-600 hover:shadow-md',
|
||||
'flex gap-4'
|
||||
)}
|
||||
>
|
||||
<div className={cn('w-1 shrink-0 rounded-full', style.bar)} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-semibold text-slate-100 text-base leading-tight">
|
||||
{alert.title}
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-slate-400">{alert.device}</p>
|
||||
<p className="mt-2 text-sm text-slate-500 line-clamp-2">{alert.description}</p>
|
||||
<p className="mt-2 text-xs text-slate-600">{formatRelativeTime(ts)}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end justify-between gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md border px-2 py-0.5 text-xs font-medium',
|
||||
style.badge
|
||||
)}
|
||||
>
|
||||
{style.label}
|
||||
</span>
|
||||
{alert.status === 'ACTIVA' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAcknowledge?.(alert.id)}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
Marcar leído
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResolve?.(alert.id)}
|
||||
className="btn btn-ghost btn-sm"
|
||||
>
|
||||
Descartar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{alert.status !== 'ACTIVA' && (
|
||||
<span className="text-xs text-slate-500">
|
||||
{alert.status === 'RECONOCIDA' ? 'Leída' : 'Resuelta'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
src/components/alerts/AlertsSection.tsx
Normal file
70
src/components/alerts/AlertsSection.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import type { AlertCardData, AlertSeverity } from './AlertCard'
|
||||
import AlertCard from './AlertCard'
|
||||
import AlertsTabs, { type AlertsTab } from './AlertsTabs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface AlertsSectionProps {
|
||||
alerts: AlertCardData[]
|
||||
isLoading?: boolean
|
||||
onAcknowledge?: (id: string) => void
|
||||
onResolve?: (id: string) => void
|
||||
}
|
||||
|
||||
function filterByTab(alerts: AlertCardData[], tab: AlertsTab): AlertCardData[] {
|
||||
if (tab === 'all') return alerts
|
||||
return alerts.filter((a) => a.severity === tab)
|
||||
}
|
||||
|
||||
export default function AlertsSection({
|
||||
alerts,
|
||||
isLoading,
|
||||
onAcknowledge,
|
||||
onResolve,
|
||||
}: AlertsSectionProps) {
|
||||
const [activeTab, setActiveTab] = useState<AlertsTab>('all')
|
||||
|
||||
const filtered = useMemo(
|
||||
() => filterByTab(alerts, activeTab),
|
||||
[alerts, activeTab]
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">Alertas del Sistema</h1>
|
||||
<p className="mt-1 text-slate-400">Notificaciones y advertencias</p>
|
||||
</div>
|
||||
|
||||
<AlertsTabs active={activeTab} onChange={setActiveTab} />
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-xl border border-slate-700/60 bg-slate-800/50 p-12 text-center text-slate-400">
|
||||
Cargando alertas...
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/50 p-12 text-center">
|
||||
<AlertTriangle className="h-12 w-12 text-slate-500" />
|
||||
<p className="mt-3 text-slate-400">
|
||||
{activeTab === 'all'
|
||||
? 'No hay alertas'
|
||||
: `No hay alertas de tipo ${activeTab === 'CRITICAL' ? 'críticas' : activeTab === 'WARNING' ? 'advertencias' : 'informativas'}`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filtered.map((alert) => (
|
||||
<AlertCard
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
onAcknowledge={onAcknowledge}
|
||||
onResolve={onResolve}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
39
src/components/alerts/AlertsTabs.tsx
Normal file
39
src/components/alerts/AlertsTabs.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type AlertsTab = 'all' | 'CRITICAL' | 'WARNING' | 'INFO'
|
||||
|
||||
const TABS: { id: AlertsTab; label: string }[] = [
|
||||
{ id: 'all', label: 'Todas' },
|
||||
{ id: 'CRITICAL', label: 'Críticas' },
|
||||
{ id: 'WARNING', label: 'Advertencias' },
|
||||
{ id: 'INFO', label: 'Informativas' },
|
||||
]
|
||||
|
||||
interface AlertsTabsProps {
|
||||
active: AlertsTab
|
||||
onChange: (tab: AlertsTab) => void
|
||||
}
|
||||
|
||||
export default function AlertsTabs({ active, onChange }: AlertsTabsProps) {
|
||||
return (
|
||||
<div className="flex gap-1 rounded-lg bg-slate-800/50 p-1">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={cn(
|
||||
'rounded-md px-4 py-2 text-sm font-medium transition-all duration-200',
|
||||
active === tab.id
|
||||
? 'bg-slate-700 text-slate-100 shadow-sm ring-1 ring-slate-600'
|
||||
: 'text-slate-400 hover:bg-slate-700/50 hover:text-slate-300'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { AlertTriangle, CheckCircle, Info, Clock } from 'lucide-react'
|
||||
import { cn, formatRelativeTime } from '@/lib/utils'
|
||||
|
||||
@@ -42,9 +43,9 @@ export default function AlertsFeed({
|
||||
<div className="card overflow-hidden">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="font-medium">Alertas Recientes</h3>
|
||||
<a href="/alertas" className="text-sm text-primary-500 hover:underline">
|
||||
<Link href="/alerts" className="text-sm text-primary-500 hover:underline">
|
||||
Ver todas
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-dark-100">
|
||||
|
||||
Reference in New Issue
Block a user