- 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>
800 lines
31 KiB
TypeScript
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>
|
|
);
|
|
}
|