Files
GRH/src/pages/Home.tsx
Exteban08 c741b697d9 Improve dark mode with Zinc color palette
- Change from gray to zinc colors for a neutral cool aesthetic
- Use zinc-950 for main background, zinc-900 for cards
- Add subtle borders to cards in dark mode for better separation
- Update all components: Home, TopMenu, connector pages
- More elegant and minimalist dark mode appearance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 11:51:03 +00:00

800 lines
31 KiB
TypeScript

import { Cpu, Settings, BarChart3, Bell } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
} from "recharts";
import { fetchMeters, type Meter } from "../api/meters";
import { getAuditLogs, type AuditLog } from "../api/audit";
import { fetchNotifications, type Notification } from "../api/notifications";
import { getAllUsers, type User } from "../api/users";
import { fetchProjects, type Project } from "../api/projects";
import { getCurrentUserRole, getCurrentUserProjectId } from "../api/auth";
import type { Page } from "../App";
import grhWatermark from "../assets/images/grhWatermark.png";
/* ================= TYPES ================= */
type OrganismStatus = "ACTIVO" | "INACTIVO";
type Organism = {
id: string;
name: string;
region: string;
projects: number;
meters: number;
activeAlerts: number;
lastSync: string;
contact: string;
status: OrganismStatus;
projectId: string | null;
};
type AlertItem = { company: string; type: string; time: string };
type HistoryItem = {
user: string;
action: string;
target: string;
time: string;
};
/* ================= COMPONENT ================= */
export default function Home({
setPage,
navigateToMetersWithProject,
}: {
setPage: (page: Page) => void;
navigateToMetersWithProject: (projectName: string) => void;
}) {
const userRole = useMemo(() => getCurrentUserRole(), []);
const userProjectId = useMemo(() => getCurrentUserProjectId(), []);
const isOperator = userRole?.toUpperCase() === 'OPERATOR';
const isAdmin = userRole?.toUpperCase() === 'ADMIN';
/* ================= METERS ================= */
const [meters, setMeters] = useState<Meter[]>([]);
const loadMeters = async () => {
try {
const data = await fetchMeters();
setMeters(data);
} catch (err) {
console.error("Error loading meters:", err);
setMeters([]);
}
};
useEffect(() => {
loadMeters();
}, []);
const [projects, setProjects] = useState<Project[]>([]);
const loadProjects = async () => {
try {
const data = await fetchProjects();
setProjects(data);
} catch (err) {
console.error("Error loading projects:", err);
setProjects([]);
}
};
useEffect(() => {
loadProjects();
}, []);
const [users, setUsers] = useState<User[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [selectedOrganism, setSelectedOrganism] = useState<string>("Todos");
const [showOrganisms, setShowOrganisms] = useState(false);
const [organismQuery, setOrganismQuery] = useState("");
const loadUsers = async () => {
setLoadingUsers(true);
try {
const response = await getAllUsers({ is_active: true });
setUsers(response.data);
} catch (err) {
console.error("Error loading users:", err);
setUsers([]);
} finally {
setLoadingUsers(false);
}
};
useEffect(() => {
if (!isOperator) {
loadUsers();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const organismsData: Organism[] = useMemo(() => {
return users.map(user => {
const userMeters = user.project_id
? meters.filter(m => m.projectId === user.project_id).length
: 0;
const userProjects = user.project_id ? 1 : 0;
return {
id: user.id,
name: user.name,
region: user.email,
projects: userProjects,
meters: userMeters,
activeAlerts: 0,
lastSync: user.last_login ? `Último acceso: ${new Date(user.last_login).toLocaleDateString()}` : "Nunca",
contact: user.role?.name || "N/A",
status: user.is_active ? "ACTIVO" : "INACTIVO",
projectId: user.project_id,
};
});
}, [users, meters]);
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
const loadAuditLogs = async () => {
setLoadingAuditLogs(true);
try {
const response = await getAuditLogs({ limit: 10, page: 1 });
setAuditLogs(response.data);
} catch (err) {
console.error("Error loading audit logs:", err);
setAuditLogs([]);
} finally {
setLoadingAuditLogs(false);
}
};
useEffect(() => {
if (!isOperator) {
loadAuditLogs();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filteredMeters = useMemo(() => {
// If user is OPERATOR, always filter by their assigned project
if (isOperator && userProjectId) {
return meters.filter((m) => m.projectId === userProjectId);
}
// For ADMIN users with organism selector
if (selectedOrganism === "Todos") {
return meters;
}
const selectedUser = users.find(u => u.id === selectedOrganism);
if (!selectedUser || !selectedUser.project_id) {
return [];
}
return meters.filter((m) => m.projectId === selectedUser.project_id);
}, [meters, selectedOrganism, users, isOperator, userProjectId]);
const filteredProjects = useMemo(
() => [...new Set(filteredMeters.map((m) => m.projectName))].filter(Boolean) as string[],
[filteredMeters]
);
const selectedUserProjectName = useMemo(() => {
// If user is OPERATOR, get their project name
if (isOperator && userProjectId) {
const project = projects.find(p => p.id === userProjectId);
return project?.name || null;
}
// For ADMIN users with organism selector
if (selectedOrganism === "Todos") return null;
const selectedUser = users.find(u => u.id === selectedOrganism);
if (!selectedUser || !selectedUser.project_id) return null;
const project = projects.find(p => p.id === selectedUser.project_id);
return project?.name || null;
}, [selectedOrganism, users, projects, isOperator, userProjectId]);
const chartData = useMemo(() => {
// If user is OPERATOR, show only their project
if (isOperator && selectedUserProjectName) {
return [{
name: selectedUserProjectName,
meterCount: filteredMeters.length,
}];
}
// For ADMIN users
if (selectedOrganism === "Todos") {
return filteredProjects.map((projectName) => ({
name: projectName,
meterCount: filteredMeters.filter((m) => m.projectName === projectName).length,
}));
}
if (selectedUserProjectName) {
const meterCount = filteredMeters.length;
return [{
name: selectedUserProjectName,
meterCount: meterCount,
}];
}
return [];
}, [selectedOrganism, filteredProjects, filteredMeters, selectedUserProjectName, isOperator]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleBarClick = (data: any) => {
if (data?.activeLabel) {
navigateToMetersWithProject(data.activeLabel);
}
};
/* ================= ORGANISM FILTER (DRAWER) ================= */
const filteredOrganisms = useMemo(() => {
const q = organismQuery.trim().toLowerCase();
if (!q) return organismsData;
return organismsData.filter((o) => o.name.toLowerCase().includes(q));
}, [organismQuery, organismsData]);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loadingNotifications, setLoadingNotifications] = useState(false);
const loadNotifications = async () => {
setLoadingNotifications(true);
try {
const response = await fetchNotifications({ limit: 10, page: 1 });
setNotifications(response.data);
} catch (err) {
console.error("Error loading notifications:", err);
setNotifications([]);
} finally {
setLoadingNotifications(false);
}
};
useEffect(() => {
if (!isOperator) {
loadNotifications();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const formatNotificationType = (type: string): string => {
const typeMap: Record<string, string> = {
NEGATIVE_FLOW: "Flujo Negativo",
SYSTEM_ALERT: "Alerta del Sistema",
MAINTENANCE: "Mantenimiento",
};
return typeMap[type] || type;
};
const formatNotificationTime = (dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Hace un momento";
if (diffMins < 60) return `Hace ${diffMins} minuto${diffMins > 1 ? "s" : ""}`;
if (diffHours < 24) return `Hace ${diffHours} hora${diffHours > 1 ? "s" : ""}`;
if (diffDays < 7) return `Hace ${diffDays} día${diffDays > 1 ? "s" : ""}`;
return date.toLocaleDateString();
};
const alerts: AlertItem[] = useMemo(
() =>
notifications.map((n) => ({
company: n.meter_serial_number || "Sistema",
type: formatNotificationType(n.notification_type),
time: formatNotificationTime(n.created_at),
})),
[notifications]
);
const formatAuditAction = (action: string): string => {
const actionMap: Record<string, string> = {
CREATE: "creó",
UPDATE: "actualizó",
DELETE: "eliminó",
READ: "consultó",
LOGIN: "inició sesión",
LOGOUT: "cerró sesión",
EXPORT: "exportó",
BULK_UPLOAD: "cargó masivamente",
STATUS_CHANGE: "cambió estado de",
PERMISSION_CHANGE: "cambió permisos de",
};
return actionMap[action] || action.toLowerCase();
};
const formatTableName = (tableName: string): string => {
const tableMap: Record<string, string> = {
meters: "medidor",
concentrators: "concentrador",
projects: "proyecto",
users: "usuario",
roles: "rol",
gateways: "gateway",
devices: "dispositivo",
readings: "lectura",
webhooks: "webhook",
};
return tableMap[tableName] || tableName;
};
const formatRelativeTime = (dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Hace un momento";
if (diffMins < 60) return `Hace ${diffMins} minuto${diffMins > 1 ? "s" : ""}`;
if (diffHours < 24) return `Hace ${diffHours} hora${diffHours > 1 ? "s" : ""}`;
if (diffDays < 7) return `Hace ${diffDays} día${diffDays > 1 ? "s" : ""}`;
return date.toLocaleDateString();
};
const formatAuditLog = (log: AuditLog): HistoryItem => {
const action = formatAuditAction(log.action);
const target = formatTableName(log.table_name);
const recordInfo = log.description || log.record_id || target;
return {
user: log.user_name || log.user_email || "Sistema",
action: action,
target: recordInfo,
time: formatRelativeTime(log.created_at),
};
};
const history: HistoryItem[] = useMemo(
() => auditLogs.map(formatAuditLog),
// eslint-disable-next-line react-hooks/exhaustive-deps
[auditLogs]
);
/* ================= KPIs (Optional) ================= */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const totalMeters = filteredMeters.length;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const totalProjects = filteredProjects.length;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const totalActiveAlerts = 0;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const avgMetersPerProject =
totalProjects > 0 ? totalMeters / totalProjects : 0;
return (
<div className="flex flex-col p-6 gap-8 w-full">
{/* Título + Selector */}
<div className="flex flex-col gap-3">
{/* ✅ Título + logo a la derecha */}
<div className="relative flex items-start justify-between gap-6">
<div className="relative z-10">
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">
Sistema de Tomas de Agua
</h1>
<p className="text-gray-600 dark:text-zinc-300 mt-2">
Monitorea, administra y controla tus operaciones en un solo lugar.
</p>
</div>
{/* ✅ Logo con z-index bajo para NO tapar menús */}
<img
src={grhWatermark}
alt="Gestión de Recursos Hídricos"
className="relative z-0 h-20 w-auto opacity-80 select-none pointer-events-none shrink-0"
draggable={false}
/>
</div>
{/* Cards de Secciones */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<div
className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-blue-50 dark:hover:bg-zinc-800 transition cursor-pointer"
onClick={() => setPage("meters")}
>
<Cpu size={40} className="text-blue-600" />
<span className="font-semibold text-gray-700 dark:text-zinc-200">Tomas</span>
</div>
<div
className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-red-50 dark:hover:bg-zinc-800 transition cursor-pointer"
onClick={() => setPage("auditoria")}
>
<Bell size={40} className="text-red-600" />
<span className="font-semibold text-gray-700 dark:text-zinc-200">Alertas</span>
</div>
<div className="cursor-pointer bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-yellow-50 dark:hover:bg-zinc-800 transition"
onClick={() => setPage("projects")}
>
<Settings size={40} className="text-yellow-600" />
<span className="font-semibold text-gray-700 dark:text-zinc-200">Proyectos</span>
</div>
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-green-50 dark:hover:bg-zinc-800 transition">
<BarChart3 size={40} className="text-green-600" />
<span className="font-semibold text-gray-700 dark:text-zinc-200">Reportes</span>
</div>
</div>
{isAdmin && (
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<p className="text-sm text-gray-500 dark:text-zinc-400">Organismos Operadores</p>
<p className="text-xs text-gray-400 dark:text-zinc-500">
Seleccionado:{" "}
<span className="font-semibold dark:text-zinc-300">
{selectedOrganism === "Todos"
? "Todos"
: organismsData.find(o => o.id === selectedOrganism)?.name || "Ninguno"}
</span>
</p>
</div>
<button
type="button"
className="inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-700 transition"
onClick={() => setShowOrganisms(true)}
>
Organismos Operadores
</button>
</div>
{showOrganisms && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Overlay */}
<div
className="absolute inset-0 bg-black/40"
onClick={() => {
setShowOrganisms(false);
setOrganismQuery("");
}}
/>
{/* Panel */}
<div className="relative w-full max-w-2xl max-h-[90vh] bg-white dark:bg-zinc-900 rounded-xl shadow-2xl flex flex-col">
{/* Header */}
<div className="p-5 border-b dark:border-zinc-800 flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
Organismos Operadores
</h3>
<p className="text-sm text-gray-500 dark:text-zinc-400">
Selecciona un organismo para filtrar la información del dashboard
</p>
</div>
<button
type="button"
className="rounded-lg px-3 py-2 text-sm border border-gray-300 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 dark:text-zinc-300"
onClick={() => {
setShowOrganisms(false);
setOrganismQuery("");
}}
>
Cerrar
</button>
</div>
{/* Search */}
<div className="p-5 border-b dark:border-zinc-800">
<input
value={organismQuery}
onChange={(e) => setOrganismQuery(e.target.value)}
placeholder="Buscar organismo…"
className="w-full rounded-lg border border-gray-300 dark:border-zinc-700 dark:bg-zinc-800 dark:text-white px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-500 dark:placeholder-gray-400"
/>
</div>
{/* List */}
<div className="p-5 overflow-y-auto flex-1 space-y-3">
{loadingUsers ? (
<div className="flex items-center justify-center py-10">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : (
<>
<div
className={[
"rounded-xl border p-4 transition",
selectedOrganism === "Todos"
? "border-blue-600 bg-blue-50/40 dark:bg-blue-900/20"
: "border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-700",
].join(" ")}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-gray-800 dark:text-white">
Todos los Organismos
</p>
<p className="text-xs text-gray-500 dark:text-zinc-400">Ver todos los datos del sistema</p>
</div>
<span className="text-xs font-semibold px-2 py-1 rounded-full bg-blue-100 text-blue-700">
TODOS
</span>
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
className={[
"rounded-lg px-3 py-2 text-sm font-semibold shadow transition",
selectedOrganism === "Todos"
? "bg-blue-600 text-white hover:bg-blue-700"
: "bg-gray-900 text-white hover:bg-gray-800",
].join(" ")}
onClick={() => {
setSelectedOrganism("Todos");
setShowOrganisms(false);
setOrganismQuery("");
}}
>
{selectedOrganism === "Todos" ? "Seleccionado" : "Seleccionar"}
</button>
</div>
</div>
{filteredOrganisms.map((o) => {
const active = o.id === selectedOrganism;
return (
<div
key={o.id}
className={[
"rounded-xl border p-4 transition",
active
? "border-blue-600 bg-blue-50/40 dark:bg-blue-900/20"
: "border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-700",
].join(" ")}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-gray-800 dark:text-white">
{o.name}
</p>
<p className="text-xs text-gray-500 dark:text-zinc-400">{o.region}</p>
</div>
<span
className={[
"text-xs font-semibold px-2 py-1 rounded-full",
o.status === "ACTIVO"
? "bg-green-100 text-green-700"
: "bg-gray-200 text-gray-700",
].join(" ")}
>
{o.status}
</span>
</div>
<div className="mt-3 space-y-2 text-xs">
<div className="flex justify-between gap-2">
<span className="text-gray-500 dark:text-zinc-400">Rol</span>
<span className="font-medium text-gray-800 dark:text-zinc-200">
{o.contact}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500 dark:text-zinc-400">Email</span>
<span className="font-medium text-gray-800 dark:text-zinc-200 truncate max-w-[200px]">
{o.region}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500 dark:text-zinc-400">Proyectos</span>
<span className="font-medium text-gray-800 dark:text-zinc-200">
{o.projects}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500 dark:text-zinc-400">Medidores</span>
<span className="font-medium text-gray-800 dark:text-zinc-200">
{o.meters}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-gray-500 dark:text-zinc-400">Último acceso</span>
<span className="font-medium text-gray-800 dark:text-zinc-200">
{o.lastSync}
</span>
</div>
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
className={[
"rounded-lg px-3 py-2 text-sm font-semibold shadow transition",
active
? "bg-blue-600 text-white hover:bg-blue-700"
: "bg-gray-900 text-white hover:bg-gray-800",
].join(" ")}
onClick={() => {
setSelectedOrganism(o.id);
setShowOrganisms(false);
setOrganismQuery("");
}}
>
{active ? "Seleccionado" : "Seleccionar"}
</button>
</div>
</div>
);
})}
</>
)}
{!loadingUsers && filteredOrganisms.length === 0 && (
<div className="text-sm text-gray-500 dark:text-zinc-400 text-center py-10">
No se encontraron organismos.
</div>
)}
</div>
{/* Footer */}
<div className="p-5 border-t dark:border-zinc-800 text-xs text-gray-500 dark:text-zinc-400">
Mostrando {filteredOrganisms.length} organismo{filteredOrganisms.length !== 1 ? 's' : ''} de {users.length} total{users.length !== 1 ? 'es' : ''}
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* Gráfica */}
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6">
<div className="flex items-center justify-between gap-4 mb-4">
<h2 className="text-lg font-semibold dark:text-white">
Numero de Medidores por Proyecto
</h2>
<span className="text-xs text-gray-400">
Click en barra para ver tomas
</span>
</div>
{chartData.length === 0 && selectedOrganism !== "Todos" ? (
<div className="h-60 flex flex-col items-center justify-center">
<p className="text-sm text-gray-500 dark:text-zinc-400 mb-2">
{selectedUserProjectName
? "Este organismo no tiene medidores registrados"
: "Este organismo no tiene un proyecto asignado"}
</p>
{selectedUserProjectName && (
<p className="text-xs text-gray-400 dark:text-zinc-500">
Proyecto asignado: <span className="font-semibold dark:text-zinc-300">{selectedUserProjectName}</span>
</p>
)}
</div>
) : chartData.length === 0 ? (
<div className="h-60 flex items-center justify-center">
<p className="text-sm text-gray-500 dark:text-zinc-400">No hay datos disponibles</p>
</div>
) : (
<>
<div className="h-60">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
onClick={handleBarClick}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="meterCount" fill="#4c5f9e" cursor="pointer" />
</BarChart>
</ResponsiveContainer>
</div>
{selectedOrganism !== "Todos" && selectedUserProjectName && (
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-zinc-800">
<div className="flex items-center justify-between text-sm">
<div>
<span className="text-gray-500 dark:text-zinc-400">Proyecto del organismo:</span>
<span className="ml-2 font-semibold text-gray-800 dark:text-white">{selectedUserProjectName}</span>
</div>
<div>
<span className="text-gray-500 dark:text-zinc-400">Total de medidores:</span>
<span className="ml-2 font-semibold text-blue-600">{filteredMeters.length}</span>
</div>
</div>
</div>
)}
</>
)}
</div>
{!isOperator && (
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6">
<h2 className="text-lg font-semibold mb-4 dark:text-white">Historial Reciente de Auditoria</h2>
{loadingAuditLogs ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : history.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-8">
No hay registros de auditoría disponibles
</p>
) : (
<ul className="divide-y divide-gray-200 dark:divide-zinc-800 max-h-60 overflow-y-auto">
{history.map((h, i) => (
<li key={i} className="py-2 flex items-start gap-3">
<span className="text-gray-400 mt-1"></span>
<div className="flex-1">
<p className="text-sm text-gray-700 dark:text-zinc-300">
<span className="font-semibold">{h.user}</span> {h.action}{" "}
<span className="font-medium">{h.target}</span>
</p>
<p className="text-xs text-gray-400">{h.time}</p>
</div>
</li>
))}
</ul>
)}
</div>
)}
{!isOperator && (
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6">
<h2 className="text-lg font-semibold mb-4 dark:text-white">Ultimas Alertas</h2>
{loadingNotifications ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : alerts.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-8">
No hay alertas disponibles
</p>
) : (
<ul className="divide-y divide-gray-200 dark:divide-zinc-800">
{alerts.map((a, i) => (
<li key={i} className="py-2 flex justify-between items-start">
<div className="flex-1">
<span className="text-sm text-gray-700 dark:text-zinc-300">
<span className="font-semibold">{a.company}</span> - {a.type}
</span>
</div>
<span className="text-xs text-red-500 font-medium whitespace-nowrap ml-4">
{a.time}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}