Compare commits
8 Commits
d552f6c516
...
1761dcdfe8
| Author | SHA1 | Date | |
|---|---|---|---|
| 1761dcdfe8 | |||
| 43d2ed9011 | |||
| 7f6ada6d39 | |||
| 9a8815d4f5 | |||
| 20982aa077 | |||
| 5d698490e2 | |||
| 32314228c4 | |||
| d88baefdf9 |
@@ -12,6 +12,9 @@ RUN npm ci
|
|||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Ensure public exists (Next.js may not have one; COPY in runner stage requires it)
|
||||||
|
RUN mkdir -p public
|
||||||
|
|
||||||
# Generate Prisma client
|
# Generate Prisma client
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
|||||||
3764
package-lock.json
generated
Normal file
3764
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.10.0",
|
"@prisma/client": "^5.10.0",
|
||||||
"@tanstack/react-query": "^5.24.0",
|
"@tanstack/react-query": "^4.36.0",
|
||||||
"@trpc/client": "^10.45.0",
|
"@trpc/client": "^10.45.0",
|
||||||
"@trpc/next": "^10.45.0",
|
"@trpc/next": "^10.45.0",
|
||||||
"@trpc/react-query": "^10.45.0",
|
"@trpc/react-query": "^10.45.0",
|
||||||
|
|||||||
0
scripts/setup.sh
Normal file → Executable file
0
scripts/setup.sh
Normal file → Executable file
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
175
src/app/(dashboard)/devices/page.tsx
Normal file
175
src/app/(dashboard)/devices/page.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||||
|
import { trpc } from '@/lib/trpc-client'
|
||||||
|
import { formatRelativeTime } from '@/lib/utils'
|
||||||
|
import DeviceCard, { type DeviceCardStatus } from '@/components/devices/DeviceCard'
|
||||||
|
|
||||||
|
type StateFilter = '' | 'ONLINE' | 'OFFLINE' | 'ALERTA' | 'MANTENIMIENTO' | 'DESCONOCIDO'
|
||||||
|
|
||||||
|
const STATE_OPTIONS: { value: StateFilter; label: string }[] = [
|
||||||
|
{ value: '', label: 'Todos los estados' },
|
||||||
|
{ value: 'ONLINE', label: 'En línea' },
|
||||||
|
{ value: 'OFFLINE', label: 'Fuera de línea' },
|
||||||
|
{ value: 'ALERTA', label: 'Advertencia' },
|
||||||
|
{ value: 'MANTENIMIENTO', label: 'Mantenimiento' },
|
||||||
|
{ value: 'DESCONOCIDO', label: 'Desconocido' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const OS_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: '', label: 'Todos los SO' },
|
||||||
|
{ value: 'Windows', label: 'Windows' },
|
||||||
|
{ value: 'Linux', label: 'Linux' },
|
||||||
|
{ value: 'Ubuntu', label: 'Ubuntu' },
|
||||||
|
{ value: 'macOS', label: 'macOS' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function mapStateToCardStatus(state: string): DeviceCardStatus {
|
||||||
|
switch (state) {
|
||||||
|
case 'ONLINE':
|
||||||
|
return 'online'
|
||||||
|
case 'OFFLINE':
|
||||||
|
case 'DESCONOCIDO':
|
||||||
|
return 'offline'
|
||||||
|
case 'ALERTA':
|
||||||
|
case 'MANTENIMIENTO':
|
||||||
|
return 'warning'
|
||||||
|
default:
|
||||||
|
return 'offline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastSeenLabel(state: string, lastSeen: Date | string | null): string {
|
||||||
|
if (state === 'ONLINE') return 'En línea'
|
||||||
|
if (!lastSeen) return '—'
|
||||||
|
const d = new Date(lastSeen)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMin = (now.getTime() - d.getTime()) / 60000
|
||||||
|
if (diffMin < 1) return 'Hace un momento'
|
||||||
|
if (diffMin < 60) return `Hace ${Math.floor(diffMin)} min`
|
||||||
|
const hours = Math.floor(diffMin / 60)
|
||||||
|
if (hours < 24) return `Hace ${hours} h`
|
||||||
|
return formatRelativeTime(lastSeen)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevicesPage() {
|
||||||
|
const { selectedClientId } = useSelectedClient()
|
||||||
|
const clienteId = selectedClientId ?? undefined
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [stateFilter, setStateFilter] = useState<StateFilter>('')
|
||||||
|
const [osFilter, setOsFilter] = useState('')
|
||||||
|
|
||||||
|
const listQuery = trpc.equipos.list.useQuery(
|
||||||
|
{
|
||||||
|
clienteId,
|
||||||
|
search: search.trim() || undefined,
|
||||||
|
estado: stateFilter || undefined,
|
||||||
|
sistemaOperativo: osFilter || undefined,
|
||||||
|
page: 1,
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
{ refetchOnWindowFocus: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const devices = useMemo(() => {
|
||||||
|
const list = listQuery.data?.dispositivos ?? []
|
||||||
|
return list.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
name: d.nombre,
|
||||||
|
ip: d.ip ?? '',
|
||||||
|
status: mapStateToCardStatus(d.estado),
|
||||||
|
os: d.sistemaOperativo ?? '—',
|
||||||
|
lastSeen: formatLastSeenLabel(d.estado, d.lastSeen),
|
||||||
|
}))
|
||||||
|
}, [listQuery.data])
|
||||||
|
|
||||||
|
const handleConnect = (id: string) => {
|
||||||
|
console.log('Conectar', id)
|
||||||
|
}
|
||||||
|
const handleFiles = (id: string) => {
|
||||||
|
console.log('Archivos', id)
|
||||||
|
}
|
||||||
|
const handleTerminal = (id: string) => {
|
||||||
|
console.log('Terminal', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-2xl font-bold text-white sm:text-3xl">Dispositivos</h1>
|
||||||
|
<p className="mt-1 text-gray-400">Administración de equipos conectados</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Buscar dispositivos..."
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-dark-300 py-2.5 pl-10 pr-4 text-gray-200 placeholder-gray-500 focus:border-primary-500/50 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 sm:gap-3">
|
||||||
|
<select
|
||||||
|
value={stateFilter}
|
||||||
|
onChange={(e) => setStateFilter((e.target.value || '') as StateFilter)}
|
||||||
|
className="rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-300 focus:border-primary-500/50 focus:outline-none focus:ring-2 focus:ring-primary-500/20 hover:border-white/20"
|
||||||
|
>
|
||||||
|
{STATE_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value || 'all'} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={osFilter}
|
||||||
|
onChange={(e) => setOsFilter(e.target.value)}
|
||||||
|
className="rounded-lg border border-white/10 bg-dark-300 px-4 py-2.5 text-sm text-gray-300 focus:border-primary-500/50 focus:outline-none focus:ring-2 focus:ring-primary-500/20 hover:border-white/20"
|
||||||
|
>
|
||||||
|
{OS_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value || 'all'} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{listQuery.isLoading ? (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-dark-300/50 p-12 text-center text-gray-400">
|
||||||
|
Cargando dispositivos...
|
||||||
|
</div>
|
||||||
|
) : listQuery.isError ? (
|
||||||
|
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-12 text-center text-red-400">
|
||||||
|
Error al cargar dispositivos. Intente de nuevo.
|
||||||
|
</div>
|
||||||
|
) : devices.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-dark-300/50 p-12 text-center text-gray-400">
|
||||||
|
No hay dispositivos que coincidan con los filtros.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{devices.map((device) => (
|
||||||
|
<DeviceCard
|
||||||
|
key={device.id}
|
||||||
|
id={device.id}
|
||||||
|
name={device.name}
|
||||||
|
ip={device.ip}
|
||||||
|
status={device.status}
|
||||||
|
os={device.os}
|
||||||
|
lastSeen={device.lastSeen}
|
||||||
|
onConectar={handleConnect}
|
||||||
|
onArchivos={handleFiles}
|
||||||
|
onTerminal={handleTerminal}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,37 +1,132 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import Sidebar from '@/components/layout/Sidebar'
|
import Sidebar from '@/components/layout/Sidebar'
|
||||||
import Header from '@/components/layout/Header'
|
import Header from '@/components/layout/Header'
|
||||||
|
import { SelectedClientProvider, useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||||
|
import { trpc } from '@/lib/trpc-client'
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const [alertasActivas, setAlertasActivas] = useState(0)
|
const router = useRouter()
|
||||||
const [user, setUser] = useState({
|
|
||||||
nombre: 'Admin',
|
const meQuery = trpc.auth.me.useQuery(undefined, {
|
||||||
email: 'admin@example.com',
|
retry: false,
|
||||||
rol: 'SUPER_ADMIN',
|
staleTime: 60 * 1000,
|
||||||
|
})
|
||||||
|
const logoutMutation = trpc.auth.logout.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
window.location.href = '/login'
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// TODO: Cargar alertas activas desde API
|
if (meQuery.isError) {
|
||||||
// TODO: Cargar usuario desde sesion
|
router.push('/login')
|
||||||
}, [])
|
}
|
||||||
|
}, [meQuery.isError, router])
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = () => {
|
||||||
// TODO: Implementar logout
|
logoutMutation.mutate()
|
||||||
window.location.href = '/login'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (meQuery.isLoading || meQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-dark-500 items-center justify-center">
|
||||||
|
<div className="text-gray-400">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = meQuery.data
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContent user={user} onLogout={handleLogout}>
|
||||||
|
{children}
|
||||||
|
</DashboardContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardContent({
|
||||||
|
user,
|
||||||
|
onLogout,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
user: { nombre: string; email: string; rol: string }
|
||||||
|
onLogout: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectedClientProvider>
|
||||||
|
<DashboardContentInner user={user} onLogout={onLogout}>
|
||||||
|
{children}
|
||||||
|
</DashboardContentInner>
|
||||||
|
</SelectedClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardContentInner({
|
||||||
|
user,
|
||||||
|
onLogout,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
user: { nombre: string; email: string; rol: string }
|
||||||
|
onLogout: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { selectedClientId } = useSelectedClient()
|
||||||
|
const clienteId = selectedClientId ?? undefined
|
||||||
|
|
||||||
|
const activeAlertsCountQuery = trpc.alertas.conteoActivas.useQuery(
|
||||||
|
{ clienteId },
|
||||||
|
{ refetchOnWindowFocus: true, staleTime: 30 * 1000 }
|
||||||
|
)
|
||||||
|
const activeAlertsCount = activeAlertsCountQuery.data?.total ?? 0
|
||||||
|
|
||||||
|
const devicesCountQuery = trpc.equipos.list.useQuery(
|
||||||
|
{ clienteId, page: 1, limit: 1 },
|
||||||
|
{ refetchOnWindowFocus: true, staleTime: 30 * 1000 }
|
||||||
|
)
|
||||||
|
const devicesCount = devicesCountQuery.data?.pagination?.total ?? 0
|
||||||
|
|
||||||
|
const clientsQuery = trpc.clientes.list.useQuery(
|
||||||
|
{ limit: 100 },
|
||||||
|
{ staleTime: 60 * 1000 }
|
||||||
|
)
|
||||||
|
const clients = (clientsQuery.data?.clientes ?? []).map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
nombre: c.nombre,
|
||||||
|
codigo: c.codigo,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-dark-500">
|
<div className="flex h-screen bg-dark-500">
|
||||||
<Sidebar alertasActivas={alertasActivas} />
|
<Sidebar
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
activeAlertsCount={activeAlertsCount}
|
||||||
<Header user={user} onLogout={handleLogout} />
|
devicesCount={devicesCount}
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
open={sidebarOpen}
|
||||||
|
onClose={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="ml-0 md:ml-[260px] flex min-w-0 flex-1 flex-col overflow-hidden transition-[margin] duration-200">
|
||||||
|
<Header
|
||||||
|
user={{
|
||||||
|
nombre: user.nombre,
|
||||||
|
email: user.email,
|
||||||
|
rol: user.rol,
|
||||||
|
}}
|
||||||
|
onLogout={onLogout}
|
||||||
|
clients={clients}
|
||||||
|
showAllClientsOption={user.rol === 'SUPER_ADMIN'}
|
||||||
|
onOpenSidebar={() => setSidebarOpen(true)}
|
||||||
|
/>
|
||||||
|
<main className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { RefreshCw, Grid, List, Filter } from 'lucide-react'
|
import { RefreshCw, Grid, List, Filter } from 'lucide-react'
|
||||||
import KPICards from '@/components/dashboard/KPICards'
|
import KPICards from '@/components/dashboard/KPICards'
|
||||||
import DeviceGrid from '@/components/dashboard/DeviceGrid'
|
import DeviceGrid from '@/components/dashboard/DeviceGrid'
|
||||||
import AlertsFeed from '@/components/dashboard/AlertsFeed'
|
import AlertsFeed from '@/components/dashboard/AlertsFeed'
|
||||||
|
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { trpc } from '@/lib/trpc-client'
|
||||||
|
|
||||||
|
type DeviceForGrid = {
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
tipo: string
|
||||||
|
estado: string
|
||||||
|
ip?: string | null
|
||||||
|
sistemaOperativo?: string | null
|
||||||
|
lastSeen?: Date | null
|
||||||
|
cpuUsage?: number | null
|
||||||
|
ramUsage?: number | null
|
||||||
|
cliente?: { nombre: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardAlert = {
|
||||||
|
id: string
|
||||||
|
severidad: 'INFO' | 'WARNING' | 'CRITICAL'
|
||||||
|
estado: 'ACTIVA' | 'RECONOCIDA' | 'RESUELTA'
|
||||||
|
titulo: string
|
||||||
|
mensaje: string
|
||||||
|
createdAt: Date
|
||||||
|
dispositivo: { nombre: string }
|
||||||
|
cliente: { nombre: string }
|
||||||
|
}
|
||||||
|
|
||||||
// Mock data - en produccion vendria de la API
|
// Mock data - en produccion vendria de la API
|
||||||
const mockStats = {
|
const mockStats = {
|
||||||
@@ -142,18 +168,100 @@ const mockAlerts = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const DEVICES_LIMIT = 12
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
const utils = trpc.useUtils()
|
||||||
const [stats, setStats] = useState(mockStats)
|
const { selectedClientId } = useSelectedClient()
|
||||||
const [devices, setDevices] = useState(mockDevices)
|
const clienteId = selectedClientId ?? undefined
|
||||||
const [alerts, setAlerts] = useState(mockAlerts)
|
|
||||||
|
const statsQuery = trpc.clientes.dashboardStats.useQuery(
|
||||||
|
{ clienteId },
|
||||||
|
{ refetchOnWindowFocus: false }
|
||||||
|
)
|
||||||
|
const stats = statsQuery.data ?? mockStats
|
||||||
|
|
||||||
|
const alertsQuery = trpc.alertas.list.useQuery(
|
||||||
|
{ page: 1, limit: 25, clienteId },
|
||||||
|
{ refetchOnWindowFocus: false }
|
||||||
|
)
|
||||||
|
const alerts: DashboardAlert[] = useMemo(() => {
|
||||||
|
const list = alertsQuery.data?.alertas ?? []
|
||||||
|
return list.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
severidad: a.severidad,
|
||||||
|
estado: a.estado,
|
||||||
|
titulo: a.titulo,
|
||||||
|
mensaje: a.mensaje,
|
||||||
|
createdAt: a.createdAt instanceof Date ? a.createdAt : new Date(a.createdAt),
|
||||||
|
dispositivo: a.dispositivo ? { nombre: a.dispositivo.nombre } : { nombre: '—' },
|
||||||
|
cliente: { nombre: a.cliente.nombre },
|
||||||
|
}))
|
||||||
|
}, [alertsQuery.data])
|
||||||
|
|
||||||
|
const acknowledgeMutation = trpc.alertas.reconocer.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.alertas.list.invalidate()
|
||||||
|
utils.clientes.dashboardStats.invalidate()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const resolveMutation = trpc.alertas.resolver.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.alertas.list.invalidate()
|
||||||
|
utils.clientes.dashboardStats.invalidate()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const equiposQuery = trpc.equipos.list.useQuery(
|
||||||
|
{ page: 1, limit: DEVICES_LIMIT, clienteId },
|
||||||
|
{ refetchOnWindowFocus: false }
|
||||||
|
)
|
||||||
|
const redQuery = trpc.red.list.useQuery(
|
||||||
|
{ page: 1, limit: DEVICES_LIMIT, clienteId },
|
||||||
|
{ refetchOnWindowFocus: false }
|
||||||
|
)
|
||||||
|
const celularesQuery = trpc.celulares.list.useQuery(
|
||||||
|
{ page: 1, limit: DEVICES_LIMIT, clienteId },
|
||||||
|
{ refetchOnWindowFocus: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const devices: DeviceForGrid[] = useMemo(() => {
|
||||||
|
const eq = equiposQuery.data?.dispositivos ?? []
|
||||||
|
const rd = redQuery.data?.dispositivos ?? []
|
||||||
|
const cel = celularesQuery.data?.dispositivos ?? []
|
||||||
|
const all = [...eq, ...rd, ...cel]
|
||||||
|
return all.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
nombre: d.nombre,
|
||||||
|
tipo: d.tipo,
|
||||||
|
estado: d.estado,
|
||||||
|
ip: d.ip ?? null,
|
||||||
|
sistemaOperativo: d.sistemaOperativo ?? null,
|
||||||
|
lastSeen: d.lastSeen ?? null,
|
||||||
|
cpuUsage: d.cpuUsage ?? null,
|
||||||
|
ramUsage: d.ramUsage ?? null,
|
||||||
|
cliente: d.cliente ? { nombre: d.cliente.nombre } : undefined,
|
||||||
|
}))
|
||||||
|
}, [equiposQuery.data, redQuery.data, celularesQuery.data])
|
||||||
|
|
||||||
|
const devicesLoading =
|
||||||
|
equiposQuery.isLoading || redQuery.isLoading || celularesQuery.isLoading
|
||||||
|
const isRefreshing =
|
||||||
|
statsQuery.isFetching ||
|
||||||
|
alertsQuery.isFetching ||
|
||||||
|
equiposQuery.isFetching ||
|
||||||
|
redQuery.isFetching ||
|
||||||
|
celularesQuery.isFetching
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setIsRefreshing(true)
|
await Promise.all([
|
||||||
// TODO: Recargar datos de la API
|
statsQuery.refetch(),
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
alertsQuery.refetch(),
|
||||||
setIsRefreshing(false)
|
equiposQuery.refetch(),
|
||||||
|
redQuery.refetch(),
|
||||||
|
celularesQuery.refetch(),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeviceAction = (deviceId: string, action: string) => {
|
const handleDeviceAction = (deviceId: string, action: string) => {
|
||||||
@@ -162,17 +270,11 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAcknowledgeAlert = (alertId: string) => {
|
const handleAcknowledgeAlert = (alertId: string) => {
|
||||||
setAlerts((prev) =>
|
acknowledgeMutation.mutate({ id: alertId })
|
||||||
prev.map((a) => (a.id === alertId ? { ...a, estado: 'RECONOCIDA' as const } : a))
|
|
||||||
)
|
|
||||||
// TODO: Llamar API
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResolveAlert = (alertId: string) => {
|
const handleResolveAlert = (alertId: string) => {
|
||||||
setAlerts((prev) =>
|
resolveMutation.mutate({ id: alertId })
|
||||||
prev.map((a) => (a.id === alertId ? { ...a, estado: 'RESUELTA' as const } : a))
|
|
||||||
)
|
|
||||||
// TODO: Llamar API
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -232,20 +334,37 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DeviceGrid
|
{devicesLoading ? (
|
||||||
devices={devices}
|
<div className="rounded-lg border border-dark-100 bg-dark-400 p-8 text-center text-gray-400">
|
||||||
viewMode={viewMode}
|
Cargando dispositivos...
|
||||||
onAction={handleDeviceAction}
|
</div>
|
||||||
/>
|
) : devices.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dark-100 bg-dark-400 p-8 text-center text-gray-400">
|
||||||
|
No hay dispositivos. Agregue clientes y sincronice con MeshCentral, LibreNMS o Headwind.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DeviceGrid
|
||||||
|
devices={devices}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onAction={handleDeviceAction}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alerts */}
|
{/* Alerts */}
|
||||||
<div>
|
<div>
|
||||||
<AlertsFeed
|
{alertsQuery.isLoading ? (
|
||||||
alerts={alerts}
|
<div className="card p-8 text-center text-gray-400">
|
||||||
onAcknowledge={handleAcknowledgeAlert}
|
Cargando alertas...
|
||||||
onResolve={handleResolveAlert}
|
</div>
|
||||||
/>
|
) : (
|
||||||
|
<AlertsFeed
|
||||||
|
alerts={alerts}
|
||||||
|
onAcknowledge={handleAcknowledgeAlert}
|
||||||
|
onResolve={handleResolveAlert}
|
||||||
|
maxItems={10}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
13
src/app/api/trpc/[trpc]/route.ts
Normal file
13
src/app/api/trpc/[trpc]/route.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
|
||||||
|
import { appRouter } from '@/server/trpc/routers'
|
||||||
|
import { createContext } from '@/server/trpc/trpc'
|
||||||
|
|
||||||
|
const handler = (request: Request) =>
|
||||||
|
fetchRequestHandler({
|
||||||
|
endpoint: '/api/trpc',
|
||||||
|
req: request,
|
||||||
|
router: appRouter,
|
||||||
|
createContext: () => createContext(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST }
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { Inter } from 'next/font/google'
|
import { Inter } from 'next/font/google'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
import TrpcProvider from '@/components/providers/TrpcProvider'
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] })
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="es" className="dark">
|
<html lang="es" className="dark">
|
||||||
<body className={`${inter.className} dark`}>
|
<body className={`${inter.className} dark`}>
|
||||||
{children}
|
<TrpcProvider>{children}</TrpcProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
105
src/app/login/page.tsx
Normal file
105
src/app/login/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { trpc } from '@/lib/trpc-client'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const meQuery = trpc.auth.me.useQuery(undefined, { retry: false })
|
||||||
|
useEffect(() => {
|
||||||
|
if (meQuery.data) router.push('/')
|
||||||
|
}, [meQuery.data, router])
|
||||||
|
|
||||||
|
const loginMutation = trpc.auth.login.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
router.push('/')
|
||||||
|
router.refresh()
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err.message ?? 'Credenciales inválidas')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
loginMutation.mutate({ email, password })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-dark-500">
|
||||||
|
<div className="text-gray-400">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (meQuery.data) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-dark-500 p-4">
|
||||||
|
<div className="w-full max-w-sm space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-white">MSP Monitor</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Iniciar sesión</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 bg-dark-400 p-6 rounded-lg border border-dark-100">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded bg-danger/20 text-danger text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loginMutation.isPending}
|
||||||
|
className="btn btn-primary w-full"
|
||||||
|
>
|
||||||
|
{loginMutation.isPending ? 'Entrando...' : 'Entrar'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/*<p className="text-center text-gray-500 text-sm">
|
||||||
|
Por defecto: admin@example.com / Admin123!
|
||||||
|
</p>
|
||||||
|
*/}
|
||||||
|
</div>
|
||||||
|
</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'
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
import { AlertTriangle, CheckCircle, Info, Clock } from 'lucide-react'
|
import { AlertTriangle, CheckCircle, Info, Clock } from 'lucide-react'
|
||||||
import { cn, formatRelativeTime } from '@/lib/utils'
|
import { cn, formatRelativeTime } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -42,9 +43,9 @@ export default function AlertsFeed({
|
|||||||
<div className="card overflow-hidden">
|
<div className="card overflow-hidden">
|
||||||
<div className="card-header flex items-center justify-between">
|
<div className="card-header flex items-center justify-between">
|
||||||
<h3 className="font-medium">Alertas Recientes</h3>
|
<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
|
Ver todas
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-dark-100">
|
<div className="divide-y divide-dark-100">
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn, formatRelativeTime, getStatusColor, getStatusBgColor } from '@/lib/utils'
|
import { cn, formatRelativeTime, getStatusColor, getStatusBgColor, getStatusBorderColor } from '@/lib/utils'
|
||||||
|
|
||||||
interface Device {
|
interface Device {
|
||||||
id: string
|
id: string
|
||||||
@@ -80,7 +80,7 @@ function DeviceCard({
|
|||||||
|
|
||||||
const getDeviceUrl = () => {
|
const getDeviceUrl = () => {
|
||||||
const type = device.tipo
|
const type = device.tipo
|
||||||
if (['PC', 'LAPTOP', 'SERVIDOR'].includes(type)) return `/equipos/${device.id}`
|
if (['PC', 'LAPTOP', 'SERVIDOR'].includes(type)) return `/devices/${device.id}`
|
||||||
if (['CELULAR', 'TABLET'].includes(type)) return `/celulares/${device.id}`
|
if (['CELULAR', 'TABLET'].includes(type)) return `/celulares/${device.id}`
|
||||||
return `/red/${device.id}`
|
return `/red/${device.id}`
|
||||||
}
|
}
|
||||||
@@ -88,88 +88,75 @@ function DeviceCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'card p-4 transition-all hover:border-primary-500/50 relative group',
|
'card p-4 transition-all hover:border-primary-500/50 relative group border',
|
||||||
device.estado === 'ALERTA' && 'border-danger/50'
|
getStatusBorderColor(device.estado)
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Status indicator */}
|
<div className="absolute top-3 right-3 z-10">
|
||||||
<div className="absolute top-3 right-3 flex items-center gap-2">
|
<button
|
||||||
<span
|
onClick={() => setShowMenu(!showMenu)}
|
||||||
className={cn(
|
className="p-1.5 rounded hover:bg-dark-100 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity touch-manipulation"
|
||||||
'status-dot',
|
>
|
||||||
device.estado === 'ONLINE' && 'status-dot-online',
|
<MoreVertical className="w-4 h-4 text-gray-500" />
|
||||||
device.estado === 'OFFLINE' && 'status-dot-offline',
|
</button>
|
||||||
device.estado === 'ALERTA' && 'status-dot-alert',
|
|
||||||
device.estado === 'MANTENIMIENTO' && 'status-dot-maintenance'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowMenu(!showMenu)}
|
|
||||||
className="p-1 rounded hover:bg-dark-100 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-4 h-4 text-gray-500" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showMenu && (
|
{showMenu && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
|
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
|
||||||
<div className="dropdown right-0 z-50">
|
<div className="dropdown right-0 z-50">
|
||||||
{['PC', 'LAPTOP', 'SERVIDOR'].includes(device.tipo) && device.estado === 'ONLINE' && (
|
{['PC', 'LAPTOP', 'SERVIDOR'].includes(device.tipo) && device.estado === 'ONLINE' && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAction?.(device.id, 'desktop')
|
onAction?.(device.id, 'desktop')
|
||||||
setShowMenu(false)
|
setShowMenu(false)
|
||||||
}}
|
}}
|
||||||
className="dropdown-item flex items-center gap-2"
|
className="dropdown-item flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
Escritorio remoto
|
Escritorio remoto
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAction?.(device.id, 'terminal')
|
onAction?.(device.id, 'terminal')
|
||||||
setShowMenu(false)
|
setShowMenu(false)
|
||||||
}}
|
}}
|
||||||
className="dropdown-item flex items-center gap-2"
|
className="dropdown-item flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Terminal className="w-4 h-4" />
|
<Terminal className="w-4 h-4" />
|
||||||
Terminal
|
Terminal
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAction?.(device.id, 'files')
|
onAction?.(device.id, 'files')
|
||||||
setShowMenu(false)
|
setShowMenu(false)
|
||||||
}}
|
}}
|
||||||
className="dropdown-item flex items-center gap-2"
|
className="dropdown-item flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-4 h-4" />
|
<FolderOpen className="w-4 h-4" />
|
||||||
Archivos
|
Archivos
|
||||||
</button>
|
</button>
|
||||||
<div className="h-px bg-dark-100 my-1" />
|
<div className="h-px bg-dark-100 my-1" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAction?.(device.id, 'restart')
|
onAction?.(device.id, 'restart')
|
||||||
setShowMenu(false)
|
setShowMenu(false)
|
||||||
}}
|
}}
|
||||||
className="dropdown-item flex items-center gap-2 text-warning"
|
className="dropdown-item flex items-center gap-2 text-warning"
|
||||||
>
|
>
|
||||||
<Power className="w-4 h-4" />
|
<Power className="w-4 h-4" />
|
||||||
Reiniciar
|
Reiniciar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Icon and name */}
|
|
||||||
<Link href={getDeviceUrl()} className="block">
|
<Link href={getDeviceUrl()} className="block">
|
||||||
<div className="flex items-center gap-4 mb-3">
|
<div className="flex items-center gap-4 mb-3">
|
||||||
<div className={cn('p-3 rounded-lg', getStatusBgColor(device.estado))}>
|
<div className={cn('p-3 rounded-lg shrink-0', getStatusBgColor(device.estado))}>
|
||||||
<span className={getStatusColor(device.estado)}>
|
<span className={getStatusColor(device.estado)}>
|
||||||
{deviceIcons[device.tipo] || deviceIcons.OTRO}
|
{deviceIcons[device.tipo] || deviceIcons.OTRO}
|
||||||
</span>
|
</span>
|
||||||
@@ -204,9 +191,9 @@ function DeviceCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metrics bar */}
|
{/* Metrics bar */}
|
||||||
{device.estado === 'ONLINE' && (device.cpuUsage !== null || device.ramUsage !== null) && (
|
{device.estado === 'ONLINE' && (device.cpuUsage != null || device.ramUsage != null) && (
|
||||||
<div className="mt-3 pt-3 border-t border-dark-100 grid grid-cols-2 gap-2">
|
<div className="mt-3 pt-3 border-t border-dark-100 grid grid-cols-2 gap-2">
|
||||||
{device.cpuUsage !== null && (
|
{device.cpuUsage != null && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between text-xs mb-1">
|
<div className="flex justify-between text-xs mb-1">
|
||||||
<span className="text-gray-500">CPU</span>
|
<span className="text-gray-500">CPU</span>
|
||||||
@@ -225,7 +212,7 @@ function DeviceCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{device.ramUsage !== null && (
|
{device.ramUsage != null && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between text-xs mb-1">
|
<div className="flex justify-between text-xs mb-1">
|
||||||
<span className="text-gray-500">RAM</span>
|
<span className="text-gray-500">RAM</span>
|
||||||
@@ -304,7 +291,7 @@ function DeviceList({
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{device.cpuUsage !== null ? (
|
{device.cpuUsage != null ? (
|
||||||
<span className={cn(device.cpuUsage > 80 ? 'text-danger' : 'text-gray-400')}>
|
<span className={cn(device.cpuUsage > 80 ? 'text-danger' : 'text-gray-400')}>
|
||||||
{Math.round(device.cpuUsage)}%
|
{Math.round(device.cpuUsage)}%
|
||||||
</span>
|
</span>
|
||||||
@@ -313,7 +300,7 @@ function DeviceList({
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{device.ramUsage !== null ? (
|
{device.ramUsage != null ? (
|
||||||
<span className={cn(device.ramUsage > 80 ? 'text-danger' : 'text-gray-400')}>
|
<span className={cn(device.ramUsage > 80 ? 'text-danger' : 'text-gray-400')}>
|
||||||
{Math.round(device.ramUsage)}%
|
{Math.round(device.ramUsage)}%
|
||||||
</span>
|
</span>
|
||||||
@@ -326,7 +313,7 @@ function DeviceList({
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Link
|
<Link
|
||||||
href={`/equipos/${device.id}`}
|
href={`/devices/${device.id}`}
|
||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost btn-sm"
|
||||||
>
|
>
|
||||||
Ver
|
Ver
|
||||||
|
|||||||
127
src/components/devices/DeviceCard.tsx
Normal file
127
src/components/devices/DeviceCard.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Monitor, FolderOpen, Terminal, Info, ExternalLink } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type DeviceCardStatus = 'online' | 'offline' | 'warning'
|
||||||
|
export type DeviceCardOS = 'Windows' | 'Linux' | 'macOS' | string
|
||||||
|
|
||||||
|
export interface DeviceCardProps {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
ip: string
|
||||||
|
status: DeviceCardStatus
|
||||||
|
os: DeviceCardOS
|
||||||
|
lastSeen: string
|
||||||
|
onConectar?: (id: string) => void
|
||||||
|
onArchivos?: (id: string) => void
|
||||||
|
onTerminal?: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<
|
||||||
|
DeviceCardStatus,
|
||||||
|
{ label: string; className: string }
|
||||||
|
> = {
|
||||||
|
online: { label: 'En línea', className: 'bg-emerald-600/90 text-white' },
|
||||||
|
offline: { label: 'Fuera de línea', className: 'bg-red-600/90 text-white' },
|
||||||
|
warning: { label: 'Advertencia', className: 'bg-amber-500/90 text-white' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOS(os: string): string {
|
||||||
|
const u = os.toLowerCase()
|
||||||
|
if (u.includes('windows')) return 'Windows'
|
||||||
|
if (u.includes('linux') || u.includes('ubuntu') || u.includes('debian')) return 'Linux'
|
||||||
|
if (u.includes('mac') || u.includes('darwin')) return 'macOS'
|
||||||
|
return os || '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceCard({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
ip,
|
||||||
|
status,
|
||||||
|
os,
|
||||||
|
lastSeen,
|
||||||
|
onConectar,
|
||||||
|
onArchivos,
|
||||||
|
onTerminal,
|
||||||
|
}: DeviceCardProps) {
|
||||||
|
const statusStyle = statusConfig[status]
|
||||||
|
const osLabel = normalizeOS(os)
|
||||||
|
const detailUrl = id ? `/devices/${id}` : '#'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border border-white/10 bg-gradient-to-b from-dark-300/80 to-dark-400/80 p-5',
|
||||||
|
'transition-all duration-200 hover:border-primary-500/30 hover:shadow-lg hover:shadow-black/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-dark-200 border border-white/5">
|
||||||
|
<Monitor className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold text-gray-100 truncate">{name}</p>
|
||||||
|
<p className="text-sm text-gray-500 truncate">{ip || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-full px-3 py-1 text-xs font-bold',
|
||||||
|
statusStyle.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusStyle.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 border-t border-white/10 pt-4">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-400">{lastSeen}</span>
|
||||||
|
<span className="rounded-full bg-dark-200 px-2.5 py-0.5 text-xs text-gray-400 border border-white/5">
|
||||||
|
{osLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-4 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => id && onConectar?.(id)}
|
||||||
|
className="flex flex-col items-center gap-1 rounded-lg bg-dark-200/80 py-2.5 text-gray-400 transition-colors hover:bg-dark-100 hover:text-primary-400 border border-white/5"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
<span className="text-xs font-medium">Conectar</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => id && onArchivos?.(id)}
|
||||||
|
className="flex flex-col items-center gap-1 rounded-lg bg-dark-200/80 py-2.5 text-gray-400 transition-colors hover:bg-dark-100 hover:text-primary-400 border border-white/5"
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
<span className="text-xs font-medium">Archivos</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => id && onTerminal?.(id)}
|
||||||
|
className="flex flex-col items-center gap-1 rounded-lg bg-dark-200/80 py-2.5 text-gray-400 transition-colors hover:bg-dark-100 hover:text-primary-400 border border-white/5"
|
||||||
|
>
|
||||||
|
<Terminal className="h-4 w-4" />
|
||||||
|
<span className="text-xs font-medium">Terminal</span>
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href={detailUrl}
|
||||||
|
className="flex flex-col items-center gap-1 rounded-lg bg-dark-200/80 py-2.5 text-gray-400 transition-colors hover:bg-dark-100 hover:text-primary-400 border border-white/5"
|
||||||
|
>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<span className="text-xs font-medium">Info</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Bell, Search, User, LogOut, Settings, ChevronDown } from 'lucide-react'
|
import { Bell, Search, User, LogOut, Settings, ChevronDown, Menu } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import ClientSelector from './ClientSelector'
|
import ClientSelector from './ClientSelector'
|
||||||
|
import { useSelectedClient } from '@/components/providers/SelectedClientProvider'
|
||||||
|
|
||||||
|
export type HeaderClient = { id: string; nombre: string; codigo: string }
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
user?: {
|
user?: {
|
||||||
@@ -13,17 +16,36 @@ interface HeaderProps {
|
|||||||
rol: string
|
rol: string
|
||||||
}
|
}
|
||||||
onLogout?: () => void
|
onLogout?: () => void
|
||||||
|
clients?: HeaderClient[]
|
||||||
|
showAllClientsOption?: boolean
|
||||||
|
onOpenSidebar?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Header({ user, onLogout }: HeaderProps) {
|
export default function Header({
|
||||||
|
user,
|
||||||
|
onLogout,
|
||||||
|
clients = [],
|
||||||
|
showAllClientsOption = false,
|
||||||
|
onOpenSidebar,
|
||||||
|
}: HeaderProps) {
|
||||||
const [showUserMenu, setShowUserMenu] = useState(false)
|
const [showUserMenu, setShowUserMenu] = useState(false)
|
||||||
const [showNotifications, setShowNotifications] = useState(false)
|
const [showNotifications, setShowNotifications] = useState(false)
|
||||||
|
const { selectedClientId, setSelectedClientId } = useSelectedClient()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-16 bg-dark-400 border-b border-dark-100 flex items-center justify-between px-6">
|
<header className="h-16 bg-dark-400 border-b border-dark-100 flex items-center justify-between gap-2 px-4 sm:px-6">
|
||||||
{/* Search */}
|
<div className="flex items-center gap-2 sm:gap-4 flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-4 flex-1">
|
{onOpenSidebar != null && (
|
||||||
<div className="relative w-96">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenSidebar}
|
||||||
|
aria-label="Abrir menú"
|
||||||
|
className="md:hidden shrink-0 p-2 rounded-lg hover:bg-dark-100 text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Menu className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="relative w-full max-w-xs sm:max-w-none sm:w-96">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -31,9 +53,13 @@ export default function Header({ user, onLogout }: HeaderProps) {
|
|||||||
className="input pl-10 bg-dark-300"
|
className="input pl-10 bg-dark-300"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Client Selector */}
|
{/* Client Selector */}
|
||||||
<ClientSelector />
|
<ClientSelector
|
||||||
|
clients={clients}
|
||||||
|
selectedId={selectedClientId}
|
||||||
|
onChange={setSelectedClientId}
|
||||||
|
showAll={showAllClientsOption}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right section */}
|
{/* Right section */}
|
||||||
@@ -79,7 +105,7 @@ export default function Header({ user, onLogout }: HeaderProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-3 border-t border-dark-100">
|
<div className="px-4 py-3 border-t border-dark-100">
|
||||||
<a href="/alertas" className="text-primary-500 text-sm hover:underline">
|
<a href="/alerts" className="text-primary-500 text-sm hover:underline">
|
||||||
Ver todas las alertas
|
Ver todas las alertas
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,88 +1,112 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Monitor,
|
Monitor,
|
||||||
Smartphone,
|
Video,
|
||||||
|
Terminal,
|
||||||
|
FolderOpen,
|
||||||
|
Gauge,
|
||||||
|
Package,
|
||||||
Network,
|
Network,
|
||||||
|
Smartphone,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
FileText,
|
FileText,
|
||||||
Settings,
|
Settings,
|
||||||
Users,
|
|
||||||
Building2,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Activity,
|
Activity,
|
||||||
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import SidebarItem, { type BadgeType } from './SidebarItem'
|
||||||
|
import SidebarSection from './SidebarSection'
|
||||||
|
|
||||||
interface NavItem {
|
export interface SidebarMenuItem {
|
||||||
label: string
|
label: string
|
||||||
href: string
|
href: string
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
badge?: number
|
badge?: { type: BadgeType; value: string | number }
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
export interface SidebarMenuSection {
|
||||||
{
|
label: string
|
||||||
label: 'Dashboard',
|
items: SidebarMenuItem[]
|
||||||
href: '/',
|
}
|
||||||
icon: <LayoutDashboard className="w-5 h-5" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Equipos',
|
|
||||||
href: '/equipos',
|
|
||||||
icon: <Monitor className="w-5 h-5" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Celulares',
|
|
||||||
href: '/celulares',
|
|
||||||
icon: <Smartphone className="w-5 h-5" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Red',
|
|
||||||
href: '/red',
|
|
||||||
icon: <Network className="w-5 h-5" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Alertas',
|
|
||||||
href: '/alertas',
|
|
||||||
icon: <AlertTriangle className="w-5 h-5" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Reportes',
|
|
||||||
href: '/reportes',
|
|
||||||
icon: <FileText className="w-5 h-5" />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const adminItems: NavItem[] = [
|
const menuConfig: SidebarMenuSection[] = [
|
||||||
{
|
{
|
||||||
label: 'Clientes',
|
label: 'PRINCIPAL',
|
||||||
href: '/clientes',
|
items: [
|
||||||
icon: <Building2 className="w-5 h-5" />,
|
{ label: 'Dashboard', href: '/', icon: <LayoutDashboard className="w-5 h-5" /> },
|
||||||
|
{
|
||||||
|
label: 'Dispositivos',
|
||||||
|
href: '/devices',
|
||||||
|
icon: <Monitor className="w-5 h-5" />,
|
||||||
|
badge: { type: 'red', value: 10 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sesiones',
|
||||||
|
href: '/sesiones',
|
||||||
|
icon: <Video className="w-5 h-5" />,
|
||||||
|
badge: { type: 'red', value: 4 },
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Usuarios',
|
label: 'HERRAMIENTAS',
|
||||||
href: '/usuarios',
|
items: [
|
||||||
icon: <Users className="w-5 h-5" />,
|
{ label: 'Terminal', href: '/terminal', icon: <Terminal className="w-5 h-5" /> },
|
||||||
|
{ label: 'Archivos', href: '/archivos', icon: <FolderOpen className="w-5 h-5" /> },
|
||||||
|
{ label: 'Rendimiento', href: '/rendimiento', icon: <Gauge className="w-5 h-5" /> },
|
||||||
|
{ label: 'Software', href: '/software', icon: <Package className="w-5 h-5" /> },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Configuracion',
|
label: 'INTEGRACIONES',
|
||||||
href: '/configuracion',
|
items: [
|
||||||
icon: <Settings className="w-5 h-5" />,
|
{
|
||||||
|
label: 'LibreNMS',
|
||||||
|
href: '/configuracion',
|
||||||
|
icon: <Network className="w-5 h-5" />,
|
||||||
|
badge: { type: 'green', value: 'OK' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Headwind MDM',
|
||||||
|
href: '/configuracion',
|
||||||
|
icon: <Smartphone className="w-5 h-5" />,
|
||||||
|
badge: { type: 'blue', value: 12 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'MONITOREO',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Alertas',
|
||||||
|
href: '/alerts',
|
||||||
|
icon: <AlertTriangle className="w-5 h-5" />,
|
||||||
|
badge: { type: 'red', value: 5 },
|
||||||
|
},
|
||||||
|
{ label: 'Reportes', href: '/reportes', icon: <FileText className="w-5 h-5" /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'SISTEMA',
|
||||||
|
items: [
|
||||||
|
{ label: 'Configuracion', href: '/configuracion', icon: <Settings className="w-5 h-5" /> },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
alertasActivas?: number
|
activeAlertsCount?: number
|
||||||
|
devicesCount?: number
|
||||||
|
open?: boolean
|
||||||
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar({ alertasActivas = 0 }: SidebarProps) {
|
export default function Sidebar({ activeAlertsCount, devicesCount, open = false, onClose }: SidebarProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
const isActive = (href: string) => {
|
const isActive = (href: string) => {
|
||||||
@@ -90,93 +114,83 @@ export default function Sidebar({ alertasActivas = 0 }: SidebarProps) {
|
|||||||
return pathname.startsWith(href)
|
return pathname.startsWith(href)
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = navItems.map((item) => ({
|
const getBadgeValue = (item: SidebarMenuItem): SidebarMenuItem['badge'] => {
|
||||||
...item,
|
if (item.href === '/alerts' && activeAlertsCount !== undefined) {
|
||||||
badge: item.href === '/alertas' ? alertasActivas : undefined,
|
if (activeAlertsCount === 0) return undefined
|
||||||
}))
|
return { type: 'red', value: activeAlertsCount }
|
||||||
|
}
|
||||||
|
if (item.href === '/devices' && devicesCount !== undefined) {
|
||||||
|
return { type: 'red', value: devicesCount }
|
||||||
|
}
|
||||||
|
return item.badge
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<>
|
||||||
className={cn(
|
{/* Mobile overlay */}
|
||||||
'h-screen bg-dark-400 border-r border-dark-100 flex flex-col transition-all duration-300',
|
<div
|
||||||
collapsed ? 'w-16' : 'w-64'
|
role="button"
|
||||||
)}
|
tabIndex={0}
|
||||||
>
|
aria-label="Cerrar menú"
|
||||||
{/* Logo */}
|
onClick={onClose}
|
||||||
<div className="h-16 flex items-center justify-between px-4 border-b border-dark-100">
|
onKeyDown={(e) => e.key === 'Escape' && onClose?.()}
|
||||||
{!collapsed && (
|
className={cn(
|
||||||
<Link href="/" className="flex items-center gap-2">
|
'fixed inset-0 z-30 bg-black/60 transition-opacity duration-200 md:hidden',
|
||||||
<Activity className="w-8 h-8 text-primary-500" />
|
open ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||||
<span className="font-bold text-lg gradient-text">MSP Monitor</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
)}
|
||||||
<button
|
/>
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
|
||||||
className="p-1.5 rounded-lg hover:bg-dark-100 text-gray-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
{collapsed ? <ChevronRight className="w-5 h-5" /> : <ChevronLeft className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
<aside
|
||||||
<nav className="flex-1 px-2 py-4 space-y-1 overflow-y-auto">
|
className={cn(
|
||||||
{items.map((item) => (
|
'fixed left-0 top-0 z-40 h-screen w-[260px] flex flex-col overflow-hidden',
|
||||||
|
'bg-gradient-to-b from-[#0f172a] to-[#111827]',
|
||||||
|
'border-r border-slate-800/80',
|
||||||
|
'transition-transform duration-200 ease-out',
|
||||||
|
'md:translate-x-0',
|
||||||
|
open ? 'translate-x-0' : '-translate-x-full'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-16 shrink-0 items-center justify-between gap-2 border-b border-slate-800/80 px-4">
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
href="/"
|
||||||
href={item.href}
|
onClick={onClose}
|
||||||
className={cn(
|
className="flex items-center gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-slate-700/30"
|
||||||
'sidebar-link',
|
|
||||||
isActive(item.href) && 'active',
|
|
||||||
collapsed && 'justify-center px-2'
|
|
||||||
)}
|
|
||||||
title={collapsed ? item.label : undefined}
|
|
||||||
>
|
>
|
||||||
{item.icon}
|
<Activity className="h-8 w-8 text-cyan-400" />
|
||||||
{!collapsed && (
|
<span className="text-lg font-semibold text-white">MSP Monitor</span>
|
||||||
<>
|
|
||||||
<span className="flex-1">{item.label}</span>
|
|
||||||
{item.badge !== undefined && item.badge > 0 && (
|
|
||||||
<span className="badge badge-danger">{item.badge}</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{collapsed && item.badge !== undefined && item.badge > 0 && (
|
|
||||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-danger rounded-full text-xs flex items-center justify-center">
|
|
||||||
{item.badge > 9 ? '9+' : item.badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
<button
|
||||||
|
type="button"
|
||||||
{/* Separador */}
|
onClick={onClose}
|
||||||
<div className="h-px bg-dark-100 my-4" />
|
aria-label="Cerrar menú"
|
||||||
|
className="md:hidden p-2 rounded-lg text-slate-400 hover:bg-slate-700/30 hover:text-white transition-colors"
|
||||||
{/* Admin items */}
|
|
||||||
{adminItems.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
'sidebar-link',
|
|
||||||
isActive(item.href) && 'active',
|
|
||||||
collapsed && 'justify-center px-2'
|
|
||||||
)}
|
|
||||||
title={collapsed ? item.label : undefined}
|
|
||||||
>
|
>
|
||||||
{item.icon}
|
<X className="w-6 h-6" />
|
||||||
{!collapsed && <span className="flex-1">{item.label}</span>}
|
</button>
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
{!collapsed && (
|
|
||||||
<div className="p-4 border-t border-dark-100">
|
|
||||||
<div className="text-xs text-gray-500 text-center">
|
|
||||||
MSP Monitor v1.0.0
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</aside>
|
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||||
|
{menuConfig.map((section) => (
|
||||||
|
<SidebarSection key={section.label} label={section.label}>
|
||||||
|
{section.items.map((item) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={item.href + item.label}
|
||||||
|
label={item.label}
|
||||||
|
href={item.href}
|
||||||
|
icon={item.icon}
|
||||||
|
active={isActive(item.href)}
|
||||||
|
badge={getBadgeValue(item)}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SidebarSection>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-800/80 px-4 py-3 shrink-0">
|
||||||
|
<p className="text-center text-xs text-slate-500">MSP Monitor v1.0.0</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
69
src/components/layout/SidebarItem.tsx
Normal file
69
src/components/layout/SidebarItem.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type BadgeType = 'red' | 'blue' | 'green'
|
||||||
|
|
||||||
|
export interface SidebarItemProps {
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
active?: boolean
|
||||||
|
badge?: {
|
||||||
|
type: BadgeType
|
||||||
|
value: string | number
|
||||||
|
}
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeStyles: Record<BadgeType, string> = {
|
||||||
|
red: 'bg-red-500/90 text-white',
|
||||||
|
blue: 'bg-blue-500/90 text-white',
|
||||||
|
green: 'bg-emerald-500/90 text-white',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SidebarItem({
|
||||||
|
label,
|
||||||
|
href,
|
||||||
|
icon,
|
||||||
|
active,
|
||||||
|
badge,
|
||||||
|
onClick,
|
||||||
|
}: SidebarItemProps) {
|
||||||
|
const isPill = badge?.type === 'green' && typeof badge.value === 'string'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-lg px-4 py-2.5 transition-all duration-200',
|
||||||
|
active
|
||||||
|
? 'bg-gradient-to-r from-cyan-600/30 to-blue-600/20 text-white shadow-[0_0_20px_-5px_rgba(6,182,212,0.25)]'
|
||||||
|
: 'text-slate-400 hover:bg-slate-700/50 hover:text-slate-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0',
|
||||||
|
active ? 'text-cyan-300' : 'text-slate-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||||
|
{badge != null && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 items-center justify-center text-xs font-bold',
|
||||||
|
isPill ? 'rounded-full px-2 py-0.5' : 'h-5 min-w-[1.25rem] rounded-full px-1.5',
|
||||||
|
badgeStyles[badge.type]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{badge.value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/components/layout/SidebarSection.tsx
Normal file
24
src/components/layout/SidebarSection.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface SidebarSectionProps {
|
||||||
|
label: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SidebarSection({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: SidebarSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('mt-6 first:mt-4', className)}>
|
||||||
|
<p className="mb-2 px-4 text-xs font-medium uppercase tracking-wider text-slate-500">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-0.5">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
src/components/providers/SelectedClientProvider.tsx
Normal file
69
src/components/providers/SelectedClientProvider.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'msp-selected-client-id'
|
||||||
|
|
||||||
|
type SelectedClientContextValue = {
|
||||||
|
selectedClientId: string | null
|
||||||
|
setSelectedClientId: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectedClientContext = createContext<SelectedClientContextValue | null>(null)
|
||||||
|
|
||||||
|
export function SelectedClientProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [selectedClientId, setState] = useState<string | null>(null)
|
||||||
|
const [hydrated, setHydrated] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
|
||||||
|
const legacy = typeof window !== 'undefined' ? localStorage.getItem('msp-selected-cliente-id') : null
|
||||||
|
const value = stored || legacy || null
|
||||||
|
if (value) setState(value)
|
||||||
|
if (legacy && !stored && typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(STORAGE_KEY, value!)
|
||||||
|
localStorage.removeItem('msp-selected-cliente-id')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setHydrated(true)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setSelectedClientId = useCallback((id: string | null) => {
|
||||||
|
setState(id)
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
if (id) localStorage.setItem(STORAGE_KEY, id)
|
||||||
|
else localStorage.removeItem(STORAGE_KEY)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value: SelectedClientContextValue = hydrated
|
||||||
|
? { selectedClientId, setSelectedClientId }
|
||||||
|
: { selectedClientId: null, setSelectedClientId }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectedClientContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</SelectedClientContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSelectedClient() {
|
||||||
|
const ctx = useContext(SelectedClientContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useSelectedClient must be used within SelectedClientProvider')
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
46
src/components/providers/TrpcProvider.tsx
Normal file
46
src/components/providers/TrpcProvider.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { httpBatchLink } from '@trpc/client'
|
||||||
|
import superjson from 'superjson'
|
||||||
|
import { trpc } from '@/lib/trpc-client'
|
||||||
|
|
||||||
|
function getBaseUrl() {
|
||||||
|
if (typeof window !== 'undefined') return ''
|
||||||
|
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
|
||||||
|
return `http://localhost:${process.env.PORT ?? 3000}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrpcProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 5 * 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const [trpcClient] = useState(() =>
|
||||||
|
trpc.createClient({
|
||||||
|
links: [
|
||||||
|
httpBatchLink({
|
||||||
|
url: `${getBaseUrl()}/api/trpc`,
|
||||||
|
fetch(url, options) {
|
||||||
|
return fetch(url, { ...options, credentials: 'include' })
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
transformer: superjson,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
</trpc.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
4
src/lib/trpc-client.ts
Normal file
4
src/lib/trpc-client.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { createTRPCReact } from '@trpc/react-query'
|
||||||
|
import type { AppRouter } from '@/server/trpc/routers'
|
||||||
|
|
||||||
|
export const trpc = createTRPCReact<AppRouter>()
|
||||||
@@ -81,6 +81,21 @@ export function getStatusBgColor(status: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStatusBorderColor(status: string): string {
|
||||||
|
switch (status.toUpperCase()) {
|
||||||
|
case 'ONLINE':
|
||||||
|
return 'border-success/50'
|
||||||
|
case 'OFFLINE':
|
||||||
|
return 'border-gray-500/40'
|
||||||
|
case 'ALERTA':
|
||||||
|
return 'border-danger/50'
|
||||||
|
case 'MANTENIMIENTO':
|
||||||
|
return 'border-warning/50'
|
||||||
|
default:
|
||||||
|
return 'border-dark-100'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getSeverityColor(severity: string): string {
|
export function getSeverityColor(severity: string): string {
|
||||||
switch (severity.toUpperCase()) {
|
switch (severity.toUpperCase()) {
|
||||||
case 'CRITICAL':
|
case 'CRITICAL':
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { TipoDispositivo } from '@prisma/client'
|
||||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||||
import { HeadwindClient } from '@/server/services/headwind/client'
|
import { HeadwindClient } from '@/server/services/headwind/client'
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ export const celularesRouter = router({
|
|||||||
const { clienteId, estado, search, page = 1, limit = 20 } = input || {}
|
const { clienteId, estado, search, page = 1, limit = 20 } = input || {}
|
||||||
|
|
||||||
const where = {
|
const where = {
|
||||||
tipo: { in: ['CELULAR', 'TABLET'] as const },
|
tipo: { in: ['CELULAR', 'TABLET'] as TipoDispositivo[] },
|
||||||
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
|
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
|
||||||
...(clienteId ? { clienteId } : {}),
|
...(clienteId ? { clienteId } : {}),
|
||||||
...(estado ? { estado } : {}),
|
...(estado ? { estado } : {}),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { TipoDispositivo } from '@prisma/client'
|
||||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||||
import { MeshCentralClient } from '@/server/services/meshcentral/client'
|
import { MeshCentralClient } from '@/server/services/meshcentral/client'
|
||||||
|
|
||||||
@@ -12,18 +13,22 @@ export const equiposRouter = router({
|
|||||||
tipo: z.enum(['PC', 'LAPTOP', 'SERVIDOR']).optional(),
|
tipo: z.enum(['PC', 'LAPTOP', 'SERVIDOR']).optional(),
|
||||||
estado: z.enum(['ONLINE', 'OFFLINE', 'ALERTA', 'MANTENIMIENTO', 'DESCONOCIDO']).optional(),
|
estado: z.enum(['ONLINE', 'OFFLINE', 'ALERTA', 'MANTENIMIENTO', 'DESCONOCIDO']).optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
|
sistemaOperativo: z.string().optional(),
|
||||||
page: z.number().default(1),
|
page: z.number().default(1),
|
||||||
limit: z.number().default(20),
|
limit: z.number().default(20),
|
||||||
}).optional()
|
}).optional()
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const { clienteId, tipo, estado, search, page = 1, limit = 20 } = input || {}
|
const { clienteId, tipo, estado, search, sistemaOperativo, page = 1, limit = 20 } = input || {}
|
||||||
|
|
||||||
const where = {
|
const where = {
|
||||||
tipo: tipo ? { equals: tipo } : { in: ['PC', 'LAPTOP', 'SERVIDOR'] as const },
|
tipo: tipo ? { equals: tipo } : { in: ['PC', 'LAPTOP', 'SERVIDOR'] as TipoDispositivo[] },
|
||||||
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
|
...(ctx.user.clienteId ? { clienteId: ctx.user.clienteId } : {}),
|
||||||
...(clienteId ? { clienteId } : {}),
|
...(clienteId ? { clienteId } : {}),
|
||||||
...(estado ? { estado } : {}),
|
...(estado ? { estado } : {}),
|
||||||
|
...(sistemaOperativo ? {
|
||||||
|
sistemaOperativo: { contains: sistemaOperativo, mode: 'insensitive' as const },
|
||||||
|
} : {}),
|
||||||
...(search ? {
|
...(search ? {
|
||||||
OR: [
|
OR: [
|
||||||
{ nombre: { contains: search, mode: 'insensitive' as const } },
|
{ nombre: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { TipoDispositivo } from '@prisma/client'
|
||||||
import { router, protectedProcedure } from '../trpc'
|
import { router, protectedProcedure } from '../trpc'
|
||||||
import { LibreNMSClient } from '@/server/services/librenms/client'
|
import { LibreNMSClient } from '@/server/services/librenms/client'
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ export const redRouter = router({
|
|||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const { clienteId, tipo, estado, search, page = 1, limit = 20 } = input || {}
|
const { clienteId, tipo, estado, search, page = 1, limit = 20 } = input || {}
|
||||||
|
|
||||||
const tiposRed = ['ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO'] as const
|
const tiposRed: TipoDispositivo[] = ['ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO']
|
||||||
|
|
||||||
const where = {
|
const where = {
|
||||||
tipo: tipo ? { equals: tipo } : { in: tiposRed },
|
tipo: tipo ? { equals: tipo } : { in: tiposRed },
|
||||||
@@ -289,7 +290,7 @@ export const redRouter = router({
|
|||||||
const clienteId = ctx.user.clienteId || input.clienteId
|
const clienteId = ctx.user.clienteId || input.clienteId
|
||||||
|
|
||||||
const where = {
|
const where = {
|
||||||
tipo: { in: ['ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO'] as const },
|
tipo: { in: ['ROUTER', 'SWITCH', 'FIREWALL', 'AP', 'IMPRESORA', 'OTRO'] as TipoDispositivo[] },
|
||||||
...(clienteId ? { clienteId } : {}),
|
...(clienteId ? { clienteId } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user