Merge pull request #5 from luanngel/DevMarlene
Se quitaron los labels de meter
This commit is contained in:
@@ -6,6 +6,7 @@ body, html, #root {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
font-family: "Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
@@ -21,7 +22,7 @@ body, html, #root {
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
grid-template-rows: 100vh;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
}
|
||||
|
||||
161
src/App.tsx
161
src/App.tsx
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Sidebar from "./components/layout/Sidebar";
|
||||
import TopMenu from "./components/layout/TopMenu";
|
||||
|
||||
@@ -8,6 +8,13 @@ import ConcentratorsPage from "./pages/concentrators/ConcentratorsPage";
|
||||
import ProjectsPage from "./pages/projects/ProjectsPage";
|
||||
import UsersPage from "./pages/UsersPage";
|
||||
import RolesPage from "./pages/RolesPage";
|
||||
import ProfileModal from "./components/layout/common/ProfileModal";
|
||||
import { uploadMyAvatar, updateMyProfile } from "./api/me";
|
||||
|
||||
import SettingsModal, {
|
||||
type AppSettings,
|
||||
loadSettings,
|
||||
} from "./components/SettingsModals";
|
||||
|
||||
export type Page =
|
||||
| "home"
|
||||
@@ -22,11 +29,85 @@ export default function App() {
|
||||
const [subPage, setSubPage] = useState<string>("default");
|
||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||
|
||||
// ✅ perfil usuario + modal
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
|
||||
const [user, setUser] = useState({
|
||||
name: "CESPT Admin",
|
||||
email: "admin@cespt.gob.mx",
|
||||
avatarUrl: null as string | null,
|
||||
organismName: "CESPT", // ✅ NUEVO: Empresa/Organismo
|
||||
});
|
||||
|
||||
// Settings state
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settings, setSettings] = useState<AppSettings>(() => loadSettings());
|
||||
|
||||
const navigateToMetersWithProject = (projectName: string) => {
|
||||
setSelectedProject(projectName);
|
||||
setSubPage(projectName); // útil para breadcrumb si lo usas
|
||||
setPage("meters");
|
||||
};
|
||||
|
||||
// ✅ handlers
|
||||
const handleUploadAvatar = async (file: File) => {
|
||||
// 1) Guardar como base64 en localStorage (demo)
|
||||
const base64 = await fileToBase64(file);
|
||||
localStorage.setItem("mock_avatar", base64 as string);
|
||||
|
||||
// 2) Guardar en state para que se vea inmediato
|
||||
setUser((prev) => ({ ...prev, avatarUrl: base64 as string }));
|
||||
};
|
||||
|
||||
function fileToBase64(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ ahora también recibe organismName
|
||||
const handleSaveProfile = async (next: {
|
||||
name: string;
|
||||
email: string;
|
||||
organismName?: string;
|
||||
}) => {
|
||||
setSavingProfile(true);
|
||||
try {
|
||||
const updated = await updateMyProfile(next);
|
||||
|
||||
setUser((prev) => ({
|
||||
...prev,
|
||||
// si backend regresa valores, los usamos; si no, usamos "next" o lo anterior
|
||||
name: updated.name ?? next.name ?? prev.name,
|
||||
email: updated.email ?? next.email ?? prev.email,
|
||||
avatarUrl: updated.avatarUrl ?? prev.avatarUrl,
|
||||
organismName: updated.organismName ?? next.organismName ?? prev.organismName,
|
||||
}));
|
||||
|
||||
setProfileOpen(false);
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Aplica theme al cargar / cambiar (para cubrir refresh)
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("dark");
|
||||
|
||||
if (settings.theme === "dark") root.classList.add("dark");
|
||||
if (settings.theme === "system") {
|
||||
const prefersDark = window.matchMedia?.(
|
||||
"(prefers-color-scheme: dark)"
|
||||
)?.matches;
|
||||
if (prefersDark) root.classList.add("dark");
|
||||
}
|
||||
}, [settings.theme]);
|
||||
|
||||
const renderPage = () => {
|
||||
switch (page) {
|
||||
case "projects":
|
||||
@@ -41,19 +122,81 @@ export default function App() {
|
||||
return <RolesPage />;
|
||||
case "home":
|
||||
default:
|
||||
return <Home setPage={setPage} navigateToMetersWithProject={navigateToMetersWithProject} />;
|
||||
return (
|
||||
<Home
|
||||
setPage={(p) => {
|
||||
setPage(p);
|
||||
setSubPage("default");
|
||||
}}
|
||||
navigateToMetersWithProject={navigateToMetersWithProject}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar setPage={setPage} />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<TopMenu page={page} subPage={subPage} setSubPage={setSubPage} />
|
||||
<main className="flex-1 overflow-auto">
|
||||
{renderPage()}
|
||||
</main>
|
||||
// Blindaje global del layout
|
||||
<div
|
||||
className={[
|
||||
"flex h-screen w-full overflow-hidden",
|
||||
settings.compactMode ? "text-sm" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{/* Sidebar no debe encogerse */}
|
||||
<div className="shrink-0">
|
||||
<Sidebar
|
||||
setPage={(p) => {
|
||||
setPage(p);
|
||||
setSubPage("default");
|
||||
if (p !== "meters") setSelectedProject("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* min-w-0: evita que páginas anchas (tablas) empujen el layout */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="shrink-0">
|
||||
<TopMenu
|
||||
page={page}
|
||||
subPage={subPage}
|
||||
setSubPage={setSubPage}
|
||||
setPage={setPage}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
// props de perfil
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
avatarUrl={user.avatarUrl}
|
||||
onOpenProfile={() => setProfileOpen(true)}
|
||||
onUploadAvatar={handleUploadAvatar}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scroll solo aquí */}
|
||||
<main className="min-w-0 flex-1 overflow-auto">{renderPage()}</main>
|
||||
</div>
|
||||
|
||||
{/* Settings modal */}
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
settings={settings}
|
||||
setSettings={setSettings}
|
||||
/>
|
||||
|
||||
{/* ✅ Profile modal (con avatar + cambiar img + empresa) */}
|
||||
<ProfileModal
|
||||
open={profileOpen}
|
||||
loading={savingProfile}
|
||||
avatarUrl={user.avatarUrl} // ✅ NUEVO
|
||||
initial={{
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
organismName: user.organismName, // ✅ NUEVO
|
||||
}}
|
||||
onClose={() => setProfileOpen(false)}
|
||||
onSave={handleSaveProfile}
|
||||
onUploadAvatar={handleUploadAvatar} // ✅ NUEVO (botón Cambiar img en modal)
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
45
src/api/me.ts
Normal file
45
src/api/me.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export async function uploadMyAvatar(file: File): Promise<{ avatarUrl: string }> {
|
||||
const form = new FormData();
|
||||
form.append("avatar", file);
|
||||
|
||||
const res = await fetch("/api/me/avatar", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
// NO pongas Content-Type; el browser lo agrega con boundary
|
||||
headers: {
|
||||
// Si usas token:
|
||||
// Authorization: `Bearer ${localStorage.getItem("token") ?? ""}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Upload avatar failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data?.avatarUrl) throw new Error("Respuesta sin avatarUrl");
|
||||
return { avatarUrl: data.avatarUrl };
|
||||
}
|
||||
|
||||
export async function updateMyProfile(input: {
|
||||
name: string;
|
||||
email: string;
|
||||
}): Promise<{ name?: string; email?: string; avatarUrl?: string | null }> {
|
||||
const res = await fetch("/api/me", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Authorization: `Bearer ${localStorage.getItem("token") ?? ""}`,
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Update profile failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ export const PROJECTS_API_URL = `${API_BASE_URL}/api/v3/data/ppfu31vhv5gf6i0/m05
|
||||
const API_TOKEN = import.meta.env.VITE_API_TOKEN;
|
||||
|
||||
export const getAuthHeaders = () => ({
|
||||
Authorization: `Bearer ${API_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
"xc-token": API_TOKEN, // NocoDB style
|
||||
Authorization: `Bearer ${API_TOKEN}`, // fallback por si el backend usa Bearer
|
||||
});
|
||||
|
||||
export interface ProjectRecord {
|
||||
|
||||
BIN
src/assets/images/grhWatermark.jpg
Normal file
BIN
src/assets/images/grhWatermark.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
201
src/components/SettingsModals.tsx
Normal file
201
src/components/SettingsModals.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
type Theme = "system" | "light" | "dark";
|
||||
|
||||
export interface AppSettings {
|
||||
theme: Theme;
|
||||
compactMode: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "water_project_settings_v1";
|
||||
|
||||
export const defaultSettings: AppSettings = {
|
||||
theme: "system",
|
||||
compactMode: false,
|
||||
};
|
||||
|
||||
export const loadSettings = (): AppSettings => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return defaultSettings;
|
||||
const parsed = JSON.parse(raw) as Partial<AppSettings>;
|
||||
return {
|
||||
theme: parsed.theme ?? defaultSettings.theme,
|
||||
compactMode: parsed.compactMode ?? defaultSettings.compactMode,
|
||||
};
|
||||
} catch {
|
||||
return defaultSettings;
|
||||
}
|
||||
};
|
||||
|
||||
export const saveSettings = (s: AppSettings) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
};
|
||||
|
||||
const applyTheme = (theme: Theme) => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("dark");
|
||||
|
||||
if (theme === "dark") root.classList.add("dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)")?.matches;
|
||||
if (prefersDark) root.classList.add("dark");
|
||||
}
|
||||
};
|
||||
|
||||
export default function SettingsModal({
|
||||
open,
|
||||
onClose,
|
||||
settings,
|
||||
setSettings,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
settings: AppSettings;
|
||||
setSettings: (s: AppSettings) => void;
|
||||
}) {
|
||||
const [local, setLocal] = useState<AppSettings>(settings);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setLocal(settings);
|
||||
}, [open, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
if (open) window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const onSave = () => {
|
||||
setSettings(local);
|
||||
saveSettings(local);
|
||||
applyTheme(local.theme);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onReset = () => setLocal(defaultSettings);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Configuración"
|
||||
>
|
||||
{/* Overlay */}
|
||||
<button
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={onClose}
|
||||
aria-label="Cerrar"
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="relative w-[92vw] max-w-lg rounded-xl bg-white shadow-lg">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||
<h2 className="text-base font-semibold text-gray-800">Configuración</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Cerrar"
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 space-y-5">
|
||||
{/* Tema */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-gray-800">Tema</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{([
|
||||
{ key: "system", label: "Sistema" },
|
||||
{ key: "light", label: "Claro" },
|
||||
{ key: "dark", label: "Oscuro" },
|
||||
] as const).map((t) => {
|
||||
const active = local.theme === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setLocal((p) => ({ ...p, theme: t.key }))}
|
||||
className={[
|
||||
"px-3 py-1 rounded-full text-sm border transition",
|
||||
active
|
||||
? "bg-blue-600 text-white border-blue-600"
|
||||
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50",
|
||||
].join(" ")}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compact mode */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-800">Modo compacto</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Reduce paddings/espaciado en tablas y tarjetas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setLocal((p) => ({ ...p, compactMode: !p.compactMode }))
|
||||
}
|
||||
className={[
|
||||
"w-12 h-7 rounded-full transition relative",
|
||||
local.compactMode ? "bg-blue-600" : "bg-gray-300",
|
||||
].join(" ")}
|
||||
aria-label="Alternar modo compacto"
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"absolute top-0.5 w-6 h-6 rounded-full bg-white shadow transition",
|
||||
local.compactMode ? "left-5" : "left-0.5",
|
||||
].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-5 py-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="text-sm font-semibold text-gray-600 hover:text-gray-800 transition"
|
||||
>
|
||||
Restablecer
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg text-sm font-semibold border border-gray-300 hover:bg-gray-50 transition"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
className="px-4 py-2 rounded-lg text-sm font-semibold bg-blue-600 text-white hover:bg-blue-700 transition"
|
||||
>
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,65 @@
|
||||
import React from "react";
|
||||
import { Bell, User, Settings } from "lucide-react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bell, User, LogOut } from "lucide-react";
|
||||
|
||||
interface TopMenuProps {
|
||||
page: string;
|
||||
subPage: string;
|
||||
setSubPage: (subPage: string) => void;
|
||||
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
avatarUrl?: string | null;
|
||||
|
||||
onLogout?: () => void;
|
||||
onOpenProfile?: () => void;
|
||||
}
|
||||
|
||||
const TopMenu: React.FC<TopMenuProps> = ({ page, subPage, setSubPage }) => {
|
||||
const TopMenu: React.FC<TopMenuProps> = ({
|
||||
page,
|
||||
subPage,
|
||||
setSubPage,
|
||||
|
||||
userName = "Usuario",
|
||||
userEmail,
|
||||
avatarUrl = null,
|
||||
|
||||
onLogout,
|
||||
onOpenProfile,
|
||||
}) => {
|
||||
const [openUserMenu, setOpenUserMenu] = useState(false);
|
||||
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const initials = useMemo(() => {
|
||||
const parts = (userName || "").trim().split(/\s+/).filter(Boolean);
|
||||
const a = parts[0]?.[0] ?? "U";
|
||||
const b = parts[1]?.[0] ?? "";
|
||||
return (a + b).toUpperCase();
|
||||
}, [userName]);
|
||||
|
||||
// Cerrar al click afuera
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (!openUserMenu) return;
|
||||
const el = menuRef.current;
|
||||
if (el && !el.contains(e.target as Node)) setOpenUserMenu(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [openUserMenu]);
|
||||
|
||||
// Cerrar con ESC
|
||||
useEffect(() => {
|
||||
function handleEsc(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpenUserMenu(false);
|
||||
}
|
||||
document.addEventListener("keydown", handleEsc);
|
||||
return () => document.removeEventListener("keydown", handleEsc);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header
|
||||
className="h-14 shrink-0 flex items-center justify-between px-4 text-white"
|
||||
className="relative z-40 h-14 shrink-0 flex items-center justify-between px-4 text-white"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, #4c5f9e, #2a355d, #566bb8, #3d4e87)",
|
||||
@@ -36,26 +85,144 @@ const TopMenu: React.FC<TopMenuProps> = ({ page, subPage, setSubPage }) => {
|
||||
<button
|
||||
aria-label="Notificaciones"
|
||||
className="p-2 rounded-full hover:bg-white/10 transition"
|
||||
type="button"
|
||||
>
|
||||
<Bell size={20} />
|
||||
</button>
|
||||
|
||||
{/* USER MENU */}
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
aria-label="Configuración"
|
||||
className="p-2 rounded-full hover:bg-white/10 transition"
|
||||
>
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="w-9 h-9 rounded-full bg-white/15 flex items-center justify-center cursor-pointer hover:bg-white/25 transition"
|
||||
type="button"
|
||||
aria-label="Perfil"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={openUserMenu}
|
||||
onClick={() => setOpenUserMenu((v) => !v)}
|
||||
className="w-9 h-9 rounded-full bg-white/15 flex items-center justify-center cursor-pointer hover:bg-white/25 transition overflow-hidden"
|
||||
title="Perfil"
|
||||
>
|
||||
<User size={20} />
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="Avatar"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs font-bold opacity-90">{initials}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{openUserMenu && (
|
||||
<div
|
||||
role="menu"
|
||||
className="
|
||||
absolute right-0 mt-2 w-80
|
||||
rounded-2xl
|
||||
bg-white
|
||||
border border-slate-200
|
||||
shadow-xl
|
||||
overflow-hidden
|
||||
z-50
|
||||
"
|
||||
>
|
||||
{/* Header usuario */}
|
||||
<div className="px-5 py-4 border-b border-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-full bg-slate-100 overflow-hidden flex items-center justify-center">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="Avatar"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm font-semibold text-slate-700">
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-slate-900 truncate">
|
||||
{userName}
|
||||
</div>
|
||||
{userEmail ? (
|
||||
<div className="text-xs text-slate-500 truncate">
|
||||
{userEmail}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-400 truncate">—</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items (solo 2) */}
|
||||
<MenuItem
|
||||
label="Ver / editar perfil"
|
||||
onClick={() => {
|
||||
setOpenUserMenu(false);
|
||||
onOpenProfile?.();
|
||||
}}
|
||||
left={<User size={16} />}
|
||||
/>
|
||||
|
||||
<div className="h-px bg-slate-200 my-1" />
|
||||
|
||||
<MenuItem
|
||||
label="Cerrar sesión"
|
||||
tone="danger"
|
||||
onClick={() => {
|
||||
setOpenUserMenu(false);
|
||||
if (onLogout) onLogout();
|
||||
else {
|
||||
localStorage.removeItem("token");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}}
|
||||
left={<LogOut size={16} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
function MenuItem({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
tone = "default",
|
||||
left,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: "default" | "danger";
|
||||
left?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={[
|
||||
"w-full flex items-center gap-3 px-5 py-3 text-sm text-left",
|
||||
"transition-colors",
|
||||
disabled ? "opacity-40 cursor-not-allowed" : "hover:bg-slate-100",
|
||||
tone === "danger"
|
||||
? "text-red-600 hover:text-red-700"
|
||||
: "text-slate-700",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="text-slate-400">{left}</span>
|
||||
<span className="font-medium">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopMenu;
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
export default function ConfirmModal({
|
||||
open,
|
||||
title = "Confirmar",
|
||||
message = "¿Estás seguro?",
|
||||
confirmText = "Confirmar",
|
||||
cancelText = "Cancelar",
|
||||
danger = false,
|
||||
loading = false,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
loading?: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// enfoque inicial para accesibilidad
|
||||
const t = setTimeout(() => panelRef.current?.focus(), 0);
|
||||
return () => clearTimeout(t);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Cerrar"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-black/40"
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="relative mx-auto mt-28 w-[min(520px,calc(100vw-32px))]">
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className="rounded-2xl bg-white border border-slate-200 shadow-xl overflow-hidden outline-none"
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<div className="text-base font-semibold text-slate-900">{title}</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5">
|
||||
<p className="text-sm text-slate-700">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-slate-200 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="rounded-xl px-4 py-2 text-sm font-medium border border-slate-200 bg-white text-slate-700 hover:bg-slate-100 transition disabled:opacity-60"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className={[
|
||||
"rounded-xl px-4 py-2 text-sm font-semibold text-white transition",
|
||||
danger ? "bg-red-600 hover:bg-red-700" : "bg-blue-600 hover:bg-blue-700",
|
||||
"focus:outline-none focus:ring-2",
|
||||
danger ? "focus:ring-red-500" : "focus:ring-blue-500",
|
||||
"focus:ring-offset-2",
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed",
|
||||
].join(" ")}
|
||||
>
|
||||
{loading ? "Procesando..." : confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
284
src/components/layout/common/ProfileModal.tsx
Normal file
284
src/components/layout/common/ProfileModal.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type ProfileForm = {
|
||||
name: string;
|
||||
email: string;
|
||||
organismName?: string; // "Empresa" / "Organismo" (CESPT, etc.)
|
||||
};
|
||||
|
||||
export default function ProfileModal({
|
||||
open,
|
||||
loading = false,
|
||||
initial,
|
||||
avatarUrl = null,
|
||||
onClose,
|
||||
onSave,
|
||||
onUploadAvatar,
|
||||
}: {
|
||||
open: boolean;
|
||||
loading?: boolean;
|
||||
initial: ProfileForm;
|
||||
avatarUrl?: string | null;
|
||||
onClose: () => void;
|
||||
onSave: (next: ProfileForm) => void | Promise<void>;
|
||||
onUploadAvatar?: (file: File) => void | Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [email, setEmail] = useState(initial?.email ?? "");
|
||||
const [organismName, setOrganismName] = useState(initial?.organismName ?? "");
|
||||
|
||||
// Avatar preview local (si el usuario selecciona imagen)
|
||||
const [localAvatar, setLocalAvatar] = useState<string | null>(null);
|
||||
const lastPreviewUrlRef = useRef<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Mantener el form sincronizado cuando se abre o cambia initial
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(initial?.name ?? "");
|
||||
setEmail(initial?.email ?? "");
|
||||
setOrganismName(initial?.organismName ?? "");
|
||||
setLocalAvatar(null);
|
||||
}, [open, initial?.name, initial?.email, initial?.organismName]);
|
||||
|
||||
// Cerrar con ESC
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Limpieza de object URLs
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (lastPreviewUrlRef.current) URL.revokeObjectURL(lastPreviewUrlRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const initials = useMemo(() => {
|
||||
const parts = (name || "").trim().split(/\s+/).filter(Boolean);
|
||||
const a = parts[0]?.[0] ?? "U";
|
||||
const b = parts[1]?.[0] ?? "";
|
||||
return (a + b).toUpperCase();
|
||||
}, [name]);
|
||||
|
||||
const computedAvatarSrc = useMemo(() => {
|
||||
const src = localAvatar ?? avatarUrl;
|
||||
if (!src) return null;
|
||||
if (src.startsWith("blob:")) return src;
|
||||
// cache-bust por si el backend mantiene la misma URL
|
||||
const sep = src.includes("?") ? "&" : "?";
|
||||
return `${src}${sep}t=${Date.now()}`;
|
||||
}, [localAvatar, avatarUrl]);
|
||||
|
||||
const triggerFilePicker = () => {
|
||||
if (!onUploadAvatar) return;
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const isImage = file.type.startsWith("image/");
|
||||
const maxMb = 5;
|
||||
const sizeOk = file.size <= maxMb * 1024 * 1024;
|
||||
|
||||
if (!isImage) {
|
||||
alert("Selecciona un archivo de imagen.");
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
if (!sizeOk) {
|
||||
alert(`La imagen debe pesar máximo ${maxMb}MB.`);
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Preview inmediato
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
if (lastPreviewUrlRef.current) URL.revokeObjectURL(lastPreviewUrlRef.current);
|
||||
lastPreviewUrlRef.current = previewUrl;
|
||||
setLocalAvatar(previewUrl);
|
||||
|
||||
try {
|
||||
await onUploadAvatar?.(file);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("No se pudo subir la imagen. Intenta de nuevo.");
|
||||
} finally {
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
alert("El nombre es obligatorio.");
|
||||
return;
|
||||
}
|
||||
if (!email.trim()) {
|
||||
alert("El correo es obligatorio.");
|
||||
return;
|
||||
}
|
||||
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
organismName: organismName.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Cerrar"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-black/40"
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative mx-auto mt-16 w-[min(860px,calc(100vw-32px))]">
|
||||
<div className="rounded-2xl bg-white shadow-xl border border-slate-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<div className="text-base font-semibold text-slate-900">Editar perfil</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-[260px_1fr] gap-6">
|
||||
{/* LEFT: Avatar */}
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-5">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-28 h-28 rounded-2xl bg-white border border-slate-200 overflow-hidden flex items-center justify-center">
|
||||
{computedAvatarSrc ? (
|
||||
<img
|
||||
src={computedAvatarSrc}
|
||||
alt="Avatar"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-700 font-semibold text-2xl">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="text-sm font-semibold text-slate-900 truncate max-w-[220px]">
|
||||
{name || "Usuario"}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 truncate max-w-[220px]">
|
||||
{email || "correo@ejemplo.gob.mx"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={triggerFilePicker}
|
||||
disabled={!onUploadAvatar}
|
||||
className={[
|
||||
"mt-4 w-full rounded-xl px-4 py-2 text-sm font-medium",
|
||||
"border border-slate-200 bg-white text-slate-700",
|
||||
"hover:bg-slate-100 transition",
|
||||
!onUploadAvatar ? "opacity-50 cursor-not-allowed" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
Cambiar imagen
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Form */}
|
||||
<div className="rounded-2xl border border-slate-200 p-5">
|
||||
{/* “correo electronico” como en tu dibujo */}
|
||||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
correo electrónico
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<Field label="Nombre:">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none focus:ring-2 focus:ring-slate-200"
|
||||
placeholder="Nombre del usuario"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Correo:">
|
||||
<input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none focus:ring-2 focus:ring-slate-200"
|
||||
placeholder="correo@organismo.gob.mx"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Empresa:">
|
||||
<input
|
||||
value={organismName}
|
||||
onChange={(e) => setOrganismName(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none focus:ring-2 focus:ring-slate-200"
|
||||
placeholder="Organismo operador"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-slate-200 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl px-4 py-2 text-sm font-medium border border-slate-200 bg-white text-slate-700 hover:bg-slate-100 transition"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className={[
|
||||
"rounded-xl px-4 py-2 text-sm font-semibold",
|
||||
"bg-blue-600 text-white hover:bg-blue-700",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
|
||||
loading ? "opacity-60 cursor-not-allowed" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{loading ? "Guardando..." : "Guardar"}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[90px_1fr] items-center gap-3">
|
||||
<div className="text-sm font-medium text-slate-700">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Cpu, Settings, BarChart3, Bell } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -9,58 +9,152 @@ import {
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
} from "recharts";
|
||||
import { fetchMeters, Meter } from "../api/meters";
|
||||
import { Page } from "../App";
|
||||
import { fetchMeters, type Meter } from "../api/meters";
|
||||
import type { Page } from "../App";
|
||||
import grhWatermark from "../assets/images/grhWatermark.jpg";
|
||||
|
||||
/* ================= TYPES ================= */
|
||||
|
||||
type OrganismStatus = "ACTIVO" | "INACTIVO";
|
||||
|
||||
type Organism = {
|
||||
name: string;
|
||||
region: string;
|
||||
projects: number;
|
||||
meters: number;
|
||||
activeAlerts: number;
|
||||
lastSync: string;
|
||||
contact: string;
|
||||
status: OrganismStatus;
|
||||
};
|
||||
|
||||
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
|
||||
navigateToMetersWithProject,
|
||||
}: {
|
||||
setPage: (page: Page) => void;
|
||||
navigateToMetersWithProject: (projectName: string) => void;
|
||||
}) {
|
||||
const [allProjects, setAllProjects] = useState<string[]>([]);
|
||||
/* ================= ORGANISMS (MOCK) ================= */
|
||||
|
||||
const organismsData: Organism[] = [
|
||||
{
|
||||
name: "CESPT TIJUANA",
|
||||
region: "Tijuana, BC",
|
||||
projects: 6,
|
||||
meters: 128,
|
||||
activeAlerts: 0,
|
||||
lastSync: "Hace 12 min",
|
||||
contact: "Operaciones CESPT",
|
||||
status: "ACTIVO",
|
||||
},
|
||||
{
|
||||
name: "CESPT TECATE",
|
||||
region: "Tecate, BC",
|
||||
projects: 3,
|
||||
meters: 54,
|
||||
activeAlerts: 1,
|
||||
lastSync: "Hace 40 min",
|
||||
contact: "Mantenimiento",
|
||||
status: "ACTIVO",
|
||||
},
|
||||
{
|
||||
name: "CESPT MEXICALI",
|
||||
region: "Mexicali, BC",
|
||||
projects: 4,
|
||||
meters: 92,
|
||||
activeAlerts: 0,
|
||||
lastSync: "Hace 1 h",
|
||||
contact: "Supervisión",
|
||||
status: "ACTIVO",
|
||||
},
|
||||
];
|
||||
|
||||
const [selectedOrganism, setSelectedOrganism] = useState<string>(
|
||||
organismsData[0]?.name ?? "CESPT TIJUANA"
|
||||
);
|
||||
const [showOrganisms, setShowOrganisms] = useState(false);
|
||||
const [organismQuery, setOrganismQuery] = useState("");
|
||||
|
||||
/* ================= METERS ================= */
|
||||
|
||||
const [meters, setMeters] = useState<Meter[]>([]);
|
||||
|
||||
|
||||
const loadMeters = async () => {
|
||||
try {
|
||||
const data = await fetchMeters();
|
||||
setMeters(data);
|
||||
const projectsArray = [...new Set(data.map((record: Meter) => record["areaName"]))];
|
||||
setAllProjects(projectsArray);
|
||||
} catch (err) {
|
||||
console.error("Error loading meters:", err);
|
||||
setMeters([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadMeters();
|
||||
}, []);
|
||||
|
||||
const chartData = allProjects.map((projectName) => ({
|
||||
// TODO: Reemplazar cuando el backend mande el organismo real (ej: meter.organismName)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const getOrganismFromMeter = (_m: Meter): string => {
|
||||
return "CESPT TIJUANA";
|
||||
};
|
||||
|
||||
const filteredMeters = useMemo(
|
||||
() => meters.filter((m) => getOrganismFromMeter(m) === selectedOrganism),
|
||||
[meters, selectedOrganism]
|
||||
);
|
||||
|
||||
const filteredProjects = useMemo(
|
||||
() => [...new Set(filteredMeters.map((m) => m.areaName))],
|
||||
[filteredMeters]
|
||||
);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
filteredProjects.map((projectName) => ({
|
||||
name: projectName,
|
||||
meterCount: meters.filter((meter) => meter.areaName === projectName).length,
|
||||
}));
|
||||
meterCount: filteredMeters.filter((m) => m.areaName === projectName)
|
||||
.length,
|
||||
})),
|
||||
[filteredProjects, filteredMeters]
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleBarClick = (data: any) => {
|
||||
if (data.activeLabel) {
|
||||
if (data?.activeLabel) {
|
||||
navigateToMetersWithProject(data.activeLabel);
|
||||
}
|
||||
};
|
||||
|
||||
// Datos de ejemplo para empresas
|
||||
const companies = [
|
||||
{ name: "Empresa A", tomas: 12, alerts: 2, consumption: 320 },
|
||||
{ name: "Empresa B", tomas: 8, alerts: 0, consumption: 210 },
|
||||
{ name: "Empresa C", tomas: 15, alerts: 1, consumption: 450 },
|
||||
];
|
||||
/* ================= ORGANISM FILTER (DRAWER) ================= */
|
||||
|
||||
// Alertas recientes
|
||||
const alerts = [
|
||||
const filteredOrganisms = useMemo(() => {
|
||||
const q = organismQuery.trim().toLowerCase();
|
||||
if (!q) return organismsData;
|
||||
return organismsData.filter((o) => o.name.toLowerCase().includes(q));
|
||||
}, [organismQuery]);
|
||||
|
||||
/* ================= MOCK ALERTS / HISTORY ================= */
|
||||
|
||||
const alerts: AlertItem[] = [
|
||||
{ company: "Empresa A", type: "Fuga", time: "Hace 2 horas" },
|
||||
{ company: "Empresa C", type: "Consumo alto", time: "Hace 5 horas" },
|
||||
{ company: "Empresa B", type: "Inactividad", time: "Hace 8 horas" },
|
||||
];
|
||||
|
||||
// Historial tipo Google
|
||||
const history = [
|
||||
const history: HistoryItem[] = [
|
||||
{
|
||||
user: "GRH",
|
||||
action: "Creó un nuevo medidor",
|
||||
@@ -93,10 +187,25 @@ export default function Home({
|
||||
},
|
||||
];
|
||||
|
||||
/* ================= 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 */}
|
||||
<div>
|
||||
{/* 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">
|
||||
Sistema de Tomas de Agua
|
||||
</h1>
|
||||
@@ -105,56 +214,231 @@ export default function Home({
|
||||
</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-10 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 rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-blue-50 transition"
|
||||
className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-blue-50 transition cursor-pointer"
|
||||
onClick={() => setPage("meters")}
|
||||
>
|
||||
<Cpu size={40} className="text-blue-600" />
|
||||
<span className="font-semibold text-gray-700">Tomas</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-red-50 transition">
|
||||
<Bell size={40} className="text-red-600" />
|
||||
<span className="font-semibold text-gray-700">Alertas</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-yellow-50 transition">
|
||||
<Settings size={40} className="text-yellow-600" />
|
||||
<span className="font-semibold text-gray-700">Mantenimiento</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow p-6 flex flex-col items-center justify-center gap-2 hover:bg-green-50 transition">
|
||||
<BarChart3 size={40} className="text-green-600" />
|
||||
<span className="font-semibold text-gray-700">Reportes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resumen de tomas por empresa */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{companies.map((c) => (
|
||||
<div
|
||||
key={c.name}
|
||||
className="bg-white rounded-xl shadow p-4 flex flex-col gap-1"
|
||||
>
|
||||
<span className="text-gray-500 text-sm">{c.name}</span>
|
||||
<span className="text-2xl font-bold text-gray-800">
|
||||
{c.tomas} Tomas
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
c.alerts > 0 ? "text-red-500" : "text-green-500"
|
||||
}`}
|
||||
>
|
||||
{c.alerts} Alertas
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Organismos Operadores */}
|
||||
<div className="bg-white 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">Organismos Operadores</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Seleccionado:{" "}
|
||||
<span className="font-semibold">{selectedOrganism}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Gráfica de consumo */}
|
||||
<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">
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={() => {
|
||||
setShowOrganisms(false);
|
||||
setOrganismQuery("");
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="absolute right-0 top-0 h-full w-full sm:w-[520px] bg-white shadow-2xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">
|
||||
Organismos Operadores
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
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 hover:bg-gray-50"
|
||||
onClick={() => {
|
||||
setShowOrganisms(false);
|
||||
setOrganismQuery("");
|
||||
}}
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-5 border-b">
|
||||
<input
|
||||
value={organismQuery}
|
||||
onChange={(e) => setOrganismQuery(e.target.value)}
|
||||
placeholder="Buscar organismo…"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="p-5 overflow-y-auto flex-1 space-y-3">
|
||||
{filteredOrganisms.map((o) => {
|
||||
const active = o.name === selectedOrganism;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={o.name}
|
||||
className={[
|
||||
"rounded-xl border p-4 transition",
|
||||
active
|
||||
? "border-blue-600 bg-blue-50/40"
|
||||
: "border-gray-200 bg-white hover:bg-gray-50",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-800">
|
||||
{o.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{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 grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-gray-500">Proyectos</span>
|
||||
<span className="font-medium text-gray-800">
|
||||
{o.projects}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-gray-500">Medidores</span>
|
||||
<span className="font-medium text-gray-800">
|
||||
{o.meters}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-gray-500">Alertas activas</span>
|
||||
<span className="font-medium text-gray-800">
|
||||
{o.activeAlerts}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-gray-500">Última sync</span>
|
||||
<span className="font-medium text-gray-800">
|
||||
{o.lastSync}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 flex justify-between gap-2">
|
||||
<span className="text-gray-500">Responsable</span>
|
||||
<span className="font-medium text-gray-800">
|
||||
{o.contact}
|
||||
</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.name);
|
||||
setShowOrganisms(false);
|
||||
setOrganismQuery("");
|
||||
}}
|
||||
>
|
||||
{active ? "Seleccionado" : "Seleccionar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredOrganisms.length === 0 && (
|
||||
<div className="text-sm text-gray-500 text-center py-10">
|
||||
No se encontraron organismos.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-5 border-t text-xs text-gray-500">
|
||||
Nota: Las propiedades están en modo demostración hasta integrar
|
||||
backend.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gráfica */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
Número de Medidores por Proyecto
|
||||
</h2>
|
||||
<span className="text-xs text-gray-400">
|
||||
Click en barra para ver tomas
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-60">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
@@ -166,17 +450,13 @@ export default function Home({
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar
|
||||
dataKey="meterCount"
|
||||
fill="#4c5f9e"
|
||||
cursor="pointer"
|
||||
/>
|
||||
<Bar dataKey="meterCount" fill="#4c5f9e" cursor="pointer" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Historial tipo Google */}
|
||||
{/* Historial */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Historial Reciente</h2>
|
||||
<ul className="divide-y divide-gray-200 max-h-60 overflow-y-auto">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
|
||||
import MaterialTable from "@material-table/core";
|
||||
import {
|
||||
@@ -8,21 +8,36 @@ import {
|
||||
deleteMeter,
|
||||
type Meter,
|
||||
} from "../../api/meters";
|
||||
import ConfirmModal from "../../components/layout/common/ConfirmModal"; // ✅ NUEVO
|
||||
|
||||
interface DeviceData {
|
||||
"Device ID": number;
|
||||
"Device EUI": string;
|
||||
"Join EUI": string;
|
||||
"AppKey": string;
|
||||
AppKey: string;
|
||||
meterId?: string;
|
||||
}
|
||||
|
||||
type ProjectStatus = "ACTIVO" | "INACTIVO";
|
||||
|
||||
type ProjectCard = {
|
||||
name: string;
|
||||
region: string;
|
||||
projects: number; // placeholder
|
||||
meters: number;
|
||||
activeAlerts: number;
|
||||
lastSync: string;
|
||||
contact: string;
|
||||
status: ProjectStatus;
|
||||
};
|
||||
|
||||
/* ================= COMPONENT ================= */
|
||||
export default function MeterManagement({ selectedProject: initialProject }: { selectedProject?: string } = {}) {
|
||||
export default function MeterManagement({
|
||||
selectedProject: initialProject,
|
||||
}: { selectedProject?: string } = {}) {
|
||||
const [allProjects, setAllProjects] = useState<string[]>([]);
|
||||
const [loadingProjects, setLoadingProjects] = useState(true);
|
||||
|
||||
|
||||
const [selectedProject, setSelectedProject] = useState(initialProject || "");
|
||||
|
||||
const [meters, setMeters] = useState<Meter[]>([]);
|
||||
@@ -31,9 +46,15 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
const [activeMeter, setActiveMeter] = useState<Meter | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const [projectQuery, setProjectQuery] = useState("");
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
// ✅ NUEVO: confirm modal delete
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const emptyMeter: Omit<Meter, "id"> = {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -60,29 +81,32 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
"Device ID": 0,
|
||||
"Device EUI": "",
|
||||
"Join EUI": "",
|
||||
"AppKey": "",
|
||||
AppKey: "",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
const filtered = meters.filter((meter) => meter.areaName === selectedProject);
|
||||
setFilteredMeters(filtered);
|
||||
} else {
|
||||
setFilteredMeters(meters);
|
||||
}
|
||||
}, [selectedProject, meters]);
|
||||
|
||||
const [form, setForm] = useState<Omit<Meter, "id">>(emptyMeter);
|
||||
const [deviceForm, setDeviceForm] = useState<DeviceData>(emptyDeviceData);
|
||||
const [errors, setErrors] = useState<{ [key: string]: boolean }>({});
|
||||
|
||||
/* ================= LOAD ================= */
|
||||
const loadMeters = async () => {
|
||||
setLoadingMeters(true);
|
||||
setLoadingProjects(true);
|
||||
try {
|
||||
const data = await fetchMeters();
|
||||
const projectsArray = [...new Set(data.map((record) => record["areaName"]))];
|
||||
|
||||
const projectsArray = [...new Set(data.map((r) => r.areaName))]
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
setAllProjects(projectsArray);
|
||||
setMeters(data);
|
||||
|
||||
// ✅ FIX: si no hay proyecto seleccionado, autoselecciona el primero disponible
|
||||
setSelectedProject((prev) => {
|
||||
if (prev) return prev;
|
||||
if (initialProject) return initialProject;
|
||||
return projectsArray[0] ?? "";
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error loading meters:", error);
|
||||
setAllProjects([]);
|
||||
@@ -95,39 +119,74 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
|
||||
useEffect(() => {
|
||||
loadMeters();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialProject) {
|
||||
setSelectedProject(initialProject);
|
||||
}
|
||||
if (initialProject) setSelectedProject(initialProject);
|
||||
}, [initialProject]);
|
||||
|
||||
const createOrUpdateDevice = async (deviceData: DeviceData): Promise<void> => {
|
||||
//await fetch('/api/devices', { method: 'POST', body: JSON.stringify(deviceData) })
|
||||
// Filtrado por proyecto
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setFilteredMeters([]);
|
||||
return;
|
||||
}
|
||||
setFilteredMeters(meters.filter((m) => m.areaName === selectedProject));
|
||||
}, [selectedProject, meters]);
|
||||
|
||||
/* ================= SIDEBAR PROJECT CARDS (ALWAYS OPEN) ================= */
|
||||
const projectsData: ProjectCard[] = useMemo(() => {
|
||||
const counts = meters.reduce<Record<string, number>>((acc, m) => {
|
||||
const area = m.areaName ?? "SIN PROYECTO";
|
||||
acc[area] = (acc[area] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const baseRegion = "Baja California";
|
||||
const baseContact = "Operaciones";
|
||||
const baseLastSync = "Hace 1 h";
|
||||
|
||||
return allProjects.map((name) => ({
|
||||
name,
|
||||
region: baseRegion,
|
||||
projects: 1,
|
||||
meters: counts[name] ?? 0,
|
||||
activeAlerts: 0,
|
||||
lastSync: baseLastSync,
|
||||
contact: baseContact,
|
||||
status: "ACTIVO",
|
||||
}));
|
||||
}, [meters, allProjects]);
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
const q = projectQuery.trim().toLowerCase();
|
||||
if (!q) return projectsData;
|
||||
return projectsData.filter((p) => p.name.toLowerCase().includes(q));
|
||||
}, [projectQuery, projectsData]);
|
||||
|
||||
/* ================= DEVICE CONFIG MOCK ================= */
|
||||
const createOrUpdateDevice = async (deviceData: DeviceData): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.log('Device data that would be sent to API:', deviceData);
|
||||
console.log("Device data that would be sent to API:", deviceData);
|
||||
resolve();
|
||||
}, 500);
|
||||
});
|
||||
};
|
||||
|
||||
/* ================= VALIDATION ================= */
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: { [key: string]: boolean } = {};
|
||||
|
||||
// Required fields
|
||||
if (!form.meterName.trim()) newErrors["meterName"] = true;
|
||||
if (!form.meterSerialNumber.trim()) newErrors["meterSerialNumber"] = true;
|
||||
if (!form.areaName.trim()) newErrors["areaName"] = true;
|
||||
if (!form.deviceName.trim()) newErrors["deviceName"] = true;
|
||||
if (!form.protocolType.trim()) newErrors["protocolType"] = true;
|
||||
|
||||
// Device Configuration - Required
|
||||
if (!deviceForm["Device ID"] || deviceForm["Device ID"] === 0) {
|
||||
if (!deviceForm["Device ID"] || deviceForm["Device ID"] === 0)
|
||||
newErrors["Device ID"] = true;
|
||||
}
|
||||
if (!deviceForm["Device EUI"].trim()) newErrors["Device EUI"] = true;
|
||||
if (!deviceForm["Join EUI"].trim()) newErrors["Join EUI"] = true;
|
||||
if (!deviceForm["AppKey"].trim()) newErrors["AppKey"] = true;
|
||||
@@ -136,25 +195,20 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
/* ================= CRUD ================= */
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
let savedMeter: Meter;
|
||||
|
||||
if (editingId) {
|
||||
const meterToUpdate = meters.find(m => m.id === editingId);
|
||||
if (!meterToUpdate) {
|
||||
throw new Error("Meter to update not found");
|
||||
}
|
||||
const meterToUpdate = meters.find((m) => m.id === editingId);
|
||||
if (!meterToUpdate) throw new Error("Meter to update not found");
|
||||
|
||||
const updatedMeter = await updateMeter(editingId, form);
|
||||
setMeters((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === editingId ? updatedMeter : m
|
||||
)
|
||||
prev.map((m) => (m.id === editingId ? updatedMeter : m))
|
||||
);
|
||||
savedMeter = updatedMeter;
|
||||
} else {
|
||||
@@ -164,15 +218,11 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
}
|
||||
|
||||
try {
|
||||
const deviceDataWithRef = {
|
||||
...deviceForm,
|
||||
meterId: savedMeter.id,
|
||||
};
|
||||
const deviceDataWithRef = { ...deviceForm, meterId: savedMeter.id };
|
||||
await createOrUpdateDevice(deviceDataWithRef);
|
||||
console.log('Device data saved successfully');
|
||||
} catch (deviceError) {
|
||||
console.error('Error saving device data:', deviceError);
|
||||
alert('Meter saved, but there was an error saving device data.');
|
||||
console.error("Error saving device data:", deviceError);
|
||||
alert("Meter saved, but there was an error saving device data.");
|
||||
}
|
||||
|
||||
setShowModal(false);
|
||||
@@ -182,24 +232,23 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
setErrors({});
|
||||
setActiveMeter(null);
|
||||
} catch (error) {
|
||||
console.error('Error saving meter:', error);
|
||||
console.error("Error saving meter:", error);
|
||||
alert(
|
||||
`Error saving meter: ${
|
||||
error instanceof Error ? error.message : "Please try again."
|
||||
}`
|
||||
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// ✅ MISMA lógica de delete, solo sin window.confirm
|
||||
const handleDelete = async () => {
|
||||
if (!activeMeter) return;
|
||||
|
||||
const confirmDelete = window.confirm(
|
||||
`Are you sure you want to delete the meter "${activeMeter.meterName}" (${activeMeter.meterSerialNumber})?`
|
||||
);
|
||||
|
||||
if (!confirmDelete) return;
|
||||
|
||||
try {
|
||||
await deleteMeter(activeMeter.id);
|
||||
setMeters((prev) => prev.filter((m) => m.id !== activeMeter.id));
|
||||
@@ -219,63 +268,204 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
setActiveMeter(null);
|
||||
};
|
||||
|
||||
/* ================= SEARCH (CLIENT) ================= */
|
||||
const searchFiltered = filteredMeters.filter((m) => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
|
||||
return (
|
||||
(m.meterName ?? "").toLowerCase().includes(q) ||
|
||||
(m.meterSerialNumber ?? "").toLowerCase().includes(q) ||
|
||||
(m.deviceId ?? "").toLowerCase().includes(q) ||
|
||||
(m.areaName ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
/* ================= UI ================= */
|
||||
return (
|
||||
<div className="flex gap-6 p-6 w-full bg-gray-100">
|
||||
{/* LEFT INFO SIDEBAR */}
|
||||
<div className="w-72 bg-white rounded-xl shadow p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 mb-3">
|
||||
Project Information
|
||||
</h3>
|
||||
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(e) => setSelectedProject(e.target.value)}
|
||||
className="w-full border px-3 py-2 rounded"
|
||||
disabled={loadingProjects || allProjects.length === 0}
|
||||
>
|
||||
{loadingProjects ? (
|
||||
<option>Loading projects...</option>
|
||||
) : meters.length === 0 ? (
|
||||
<option>No projects available</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="">Select a project</option>
|
||||
{allProjects.map((proj) => (
|
||||
<option key={proj} value={proj}>
|
||||
{proj}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
|
||||
{allProjects.length === 0 && !loadingProjects && (
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
No projects available. Please contact your administrator.
|
||||
{/* SIDEBAR */}
|
||||
<aside className="w-[420px] shrink-0">
|
||||
<div className="bg-white rounded-xl shadow p-4 flex flex-col h-[calc(100vh-48px)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Proyectos</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Seleccionado:{" "}
|
||||
<span className="font-semibold">{selectedProject || "—"}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white shadow hover:bg-blue-700 transition disabled:opacity-60"
|
||||
onClick={handleRefresh}
|
||||
disabled={loadingProjects}
|
||||
title="Actualizar"
|
||||
>
|
||||
<RefreshCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mt-4">
|
||||
<input
|
||||
value={projectQuery}
|
||||
onChange={(e) => setProjectQuery(e.target.value)}
|
||||
placeholder="Buscar proyecto…"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
|
||||
disabled={loadingProjects}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="mt-4 overflow-y-auto flex-1 space-y-3 pr-1">
|
||||
{loadingProjects ? (
|
||||
<div className="text-sm text-gray-500">Loading projects...</div>
|
||||
) : allProjects.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">
|
||||
No projects available. Please contact your administrator.
|
||||
</div>
|
||||
) : filteredProjects.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 text-center py-10">
|
||||
No se encontraron proyectos.
|
||||
</div>
|
||||
) : (
|
||||
filteredProjects.map((p) => {
|
||||
const active = p.name === selectedProject;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={p.name}
|
||||
// ✅ FIX: también selecciona proyecto al dar clic en la tarjeta
|
||||
onClick={() => {
|
||||
setSelectedProject(p.name);
|
||||
setActiveMeter(null);
|
||||
setSearch("");
|
||||
}}
|
||||
className={[
|
||||
"rounded-xl border p-4 transition cursor-pointer",
|
||||
active
|
||||
? "border-blue-600 bg-blue-50/40"
|
||||
: "border-gray-200 bg-white hover:bg-gray-50",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-800">
|
||||
{p.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{p.region}</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={[
|
||||
"text-xs font-semibold px-2 py-1 rounded-full",
|
||||
p.status === "ACTIVO"
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-gray-200 text-gray-700",
|
||||
].join(" ")}
|
||||
>
|
||||
{p.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-gray-500">Subproyectos</span>
|
||||
<span className="font-medium text-gray-800">
|
||||
{p.projects}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-gray-500">Medidores</span>
|
||||
<span className="font-medium text-gray-800">
|
||||
{p.meters}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-gray-500">Alertas activas</span>
|
||||
<span className="font-medium text-gray-800">
|
||||
{p.activeAlerts}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-gray-500">Última sync</span>
|
||||
<span className="font-medium text-gray-800">
|
||||
{p.lastSync}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 flex justify-between gap-2">
|
||||
<span className="text-gray-500">Responsable</span>
|
||||
<span className="font-medium text-gray-800">
|
||||
{p.contact}
|
||||
</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={(e) => {
|
||||
// ✅ evita doble click (card + button)
|
||||
e.stopPropagation();
|
||||
setSelectedProject(p.name);
|
||||
setActiveMeter(null);
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
{active ? "Seleccionado" : "Seleccionar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t text-xs text-gray-500">
|
||||
Nota: region/alertas/última sync están en modo demostración hasta
|
||||
integrar backend.
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* MAIN */}
|
||||
<div className="flex-1 flex flex-col gap-6">
|
||||
<main className="flex-1 flex flex-col gap-6 min-w-0">
|
||||
{/* HEADER */}
|
||||
<div
|
||||
className="rounded-xl shadow p-6 text-white flex justify-between items-center"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, #4c5f9e, #2a355d, #566bb8)",
|
||||
background: "linear-gradient(135deg, #4c5f9e, #2a355d, #566bb8)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Meter Management</h1>
|
||||
<p className="text-sm text-blue-100">Medidores registrados</p>
|
||||
<p className="text-sm text-blue-100">
|
||||
{selectedProject
|
||||
? `Proyecto: ${selectedProject}`
|
||||
: "Selecciona un proyecto desde el panel izquierdo"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setForm(emptyMeter);
|
||||
const base = { ...emptyMeter };
|
||||
if (selectedProject) base.areaName = selectedProject;
|
||||
|
||||
setForm(base);
|
||||
setDeviceForm(emptyDeviceData);
|
||||
setErrors({});
|
||||
setEditingId(null);
|
||||
@@ -284,7 +474,7 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
disabled={!selectedProject || allProjects.length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus size={16} /> Add
|
||||
<Plus size={16} /> Agregar
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -319,22 +509,23 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
disabled={!activeMeter}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
|
||||
>
|
||||
<Pencil size={16} /> Edit
|
||||
<Pencil size={16} /> Editar
|
||||
</button>
|
||||
|
||||
{/* ✅ CAMBIO: antes llamaba handleDelete, ahora abre modal */}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={!activeMeter}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
|
||||
>
|
||||
<Trash2 size={16} /> Delete
|
||||
<Trash2 size={16} /> Eliminar
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg"
|
||||
>
|
||||
<RefreshCcw size={16} /> Refresh
|
||||
<RefreshCcw size={16} /> Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -345,24 +536,62 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
placeholder="Search by meter name, serial number, device ID, or area..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
disabled={!selectedProject}
|
||||
/>
|
||||
|
||||
{/* TABLE */}
|
||||
<div className={!selectedProject ? "opacity-60 pointer-events-none" : ""}>
|
||||
<MaterialTable
|
||||
title="Meters"
|
||||
isLoading={loadingMeters}
|
||||
columns={[
|
||||
{ title: "Area Name", field: "areaName", render: (rowData) => rowData.areaName || "-" },
|
||||
{ title: "Account Number", field: "accountNumber", render: (rowData) => rowData.accountNumber || "-" },
|
||||
{ title: "User Name", field: "userName", render: (rowData) => rowData.userName || "-" },
|
||||
{ title: "User Address", field: "userAddress", render: (rowData) => rowData.userAddress || "-" },
|
||||
{ title: "Meter S/N", field: "meterSerialNumber", render: (rowData) => rowData.meterSerialNumber || "-" },
|
||||
{ title: "Meter Name", field: "meterName", render: (rowData) => rowData.meterName || "-" },
|
||||
{ title: "Protocol Type", field: "protocolType", render: (rowData) => rowData.protocolType || "-" },
|
||||
{ title: "Device ID", field: "deviceId", render: (rowData) => rowData.deviceId || "-" },
|
||||
{ title: "Device Name", field: "deviceName", render: (rowData) => rowData.deviceName || "-" },
|
||||
{
|
||||
title: "Area Name",
|
||||
field: "areaName",
|
||||
render: (rowData) => rowData.areaName || "-",
|
||||
},
|
||||
{
|
||||
title: "Account Number",
|
||||
field: "accountNumber",
|
||||
render: (rowData) => rowData.accountNumber || "-",
|
||||
},
|
||||
{
|
||||
title: "User Name",
|
||||
field: "userName",
|
||||
render: (rowData) => rowData.userName || "-",
|
||||
},
|
||||
{
|
||||
title: "User Address",
|
||||
field: "userAddress",
|
||||
render: (rowData) => rowData.userAddress || "-",
|
||||
},
|
||||
{
|
||||
title: "Meter S/N",
|
||||
field: "meterSerialNumber",
|
||||
render: (rowData) => rowData.meterSerialNumber || "-",
|
||||
},
|
||||
{
|
||||
title: "Meter Name",
|
||||
field: "meterName",
|
||||
render: (rowData) => rowData.meterName || "-",
|
||||
},
|
||||
{
|
||||
title: "Protocol Type",
|
||||
field: "protocolType",
|
||||
render: (rowData) => rowData.protocolType || "-",
|
||||
},
|
||||
{
|
||||
title: "Device ID",
|
||||
field: "deviceId",
|
||||
render: (rowData) => rowData.deviceId || "-",
|
||||
},
|
||||
{
|
||||
title: "Device Name",
|
||||
field: "deviceName",
|
||||
render: (rowData) => rowData.deviceName || "-",
|
||||
},
|
||||
]}
|
||||
data={filteredMeters}
|
||||
data={searchFiltered}
|
||||
onRowClick={(_, rowData) => setActiveMeter(rowData as Meter)}
|
||||
options={{
|
||||
actionsColumnIndex: -1,
|
||||
@@ -378,7 +607,9 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
}}
|
||||
localization={{
|
||||
body: {
|
||||
emptyDataSourceMessage: loadingMeters
|
||||
emptyDataSourceMessage: !selectedProject
|
||||
? "Select a project to view meters."
|
||||
: loadingMeters
|
||||
? "Loading meters..."
|
||||
: "No meters found. Click 'Add' to create your first meter.",
|
||||
},
|
||||
@@ -386,6 +617,30 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ✅ NUEVO: ConfirmModal para borrar */}
|
||||
<ConfirmModal
|
||||
open={confirmOpen}
|
||||
title="Eliminar medidor"
|
||||
message={`¿Estás seguro que quieres eliminar "${
|
||||
activeMeter?.meterName ?? "este medidor"
|
||||
}" (${activeMeter?.meterSerialNumber ?? "—"})? Esta acción no se puede deshacer.`}
|
||||
confirmText="Eliminar"
|
||||
cancelText="Cancelar"
|
||||
danger
|
||||
loading={deleting}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
onConfirm={async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await handleDelete();
|
||||
setConfirmOpen(false);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* MODAL */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
@@ -394,6 +649,7 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
{editingId ? "Edit Meter" : "Add Meter"}
|
||||
</h2>
|
||||
|
||||
{/* ✅ FORMULARIO (REINTEGRADO) */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
|
||||
Meter Information
|
||||
@@ -410,13 +666,15 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, areaName: e.target.value });
|
||||
if (errors["areaName"]) {
|
||||
setErrors({ ...errors, "areaName": false });
|
||||
setErrors({ ...errors, areaName: false });
|
||||
}
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{errors["areaName"] && (
|
||||
<p className="text-red-500 text-xs mt-1">This field is required</p>
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
This field is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -426,7 +684,10 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
placeholder="Account Number (optional)"
|
||||
value={form.accountNumber ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, accountNumber: e.target.value || null })
|
||||
setForm({
|
||||
...form,
|
||||
accountNumber: e.target.value || null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -467,13 +728,15 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, meterSerialNumber: e.target.value });
|
||||
if (errors["meterSerialNumber"]) {
|
||||
setErrors({ ...errors, "meterSerialNumber": false });
|
||||
setErrors({ ...errors, meterSerialNumber: false });
|
||||
}
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{errors["meterSerialNumber"] && (
|
||||
<p className="text-red-500 text-xs mt-1">This field is required</p>
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
This field is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -487,13 +750,15 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, meterName: e.target.value });
|
||||
if (errors["meterName"]) {
|
||||
setErrors({ ...errors, "meterName": false });
|
||||
setErrors({ ...errors, meterName: false });
|
||||
}
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{errors["meterName"] && (
|
||||
<p className="text-red-500 text-xs mt-1">This field is required</p>
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
This field is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -509,13 +774,15 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, protocolType: e.target.value });
|
||||
if (errors["protocolType"]) {
|
||||
setErrors({ ...errors, "protocolType": false });
|
||||
setErrors({ ...errors, protocolType: false });
|
||||
}
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{errors["protocolType"] && (
|
||||
<p className="text-red-500 text-xs mt-1">This field is required</p>
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
This field is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -541,13 +808,15 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, deviceName: e.target.value });
|
||||
if (errors["deviceName"]) {
|
||||
setErrors({ ...errors, "deviceName": false });
|
||||
setErrors({ ...errors, deviceName: false });
|
||||
}
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{errors["deviceName"] && (
|
||||
<p className="text-red-500 text-xs mt-1">This field is required</p>
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
This field is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -576,10 +845,12 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
}
|
||||
}}
|
||||
required
|
||||
min="1"
|
||||
min={1}
|
||||
/>
|
||||
{errors["Device ID"] && (
|
||||
<p className="text-red-500 text-xs mt-1">This field is required</p>
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
This field is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -591,7 +862,10 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
placeholder="Device EUI *"
|
||||
value={deviceForm["Device EUI"]}
|
||||
onChange={(e) => {
|
||||
setDeviceForm({ ...deviceForm, "Device EUI": e.target.value });
|
||||
setDeviceForm({
|
||||
...deviceForm,
|
||||
"Device EUI": e.target.value,
|
||||
});
|
||||
if (errors["Device EUI"]) {
|
||||
setErrors({ ...errors, "Device EUI": false });
|
||||
}
|
||||
@@ -599,7 +873,9 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
required
|
||||
/>
|
||||
{errors["Device EUI"] && (
|
||||
<p className="text-red-500 text-xs mt-1">This field is required</p>
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
This field is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -612,7 +888,10 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
placeholder="Join EUI *"
|
||||
value={deviceForm["Join EUI"]}
|
||||
onChange={(e) => {
|
||||
setDeviceForm({ ...deviceForm, "Join EUI": e.target.value });
|
||||
setDeviceForm({
|
||||
...deviceForm,
|
||||
"Join EUI": e.target.value,
|
||||
});
|
||||
if (errors["Join EUI"]) {
|
||||
setErrors({ ...errors, "Join EUI": false });
|
||||
}
|
||||
@@ -620,7 +899,9 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
required
|
||||
/>
|
||||
{errors["Join EUI"] && (
|
||||
<p className="text-red-500 text-xs mt-1">This field is required</p>
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
This field is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -632,15 +913,17 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
placeholder="AppKey *"
|
||||
value={deviceForm["AppKey"]}
|
||||
onChange={(e) => {
|
||||
setDeviceForm({ ...deviceForm, "AppKey": e.target.value });
|
||||
setDeviceForm({ ...deviceForm, AppKey: e.target.value });
|
||||
if (errors["AppKey"]) {
|
||||
setErrors({ ...errors, "AppKey": false });
|
||||
setErrors({ ...errors, AppKey: false });
|
||||
}
|
||||
}}
|
||||
required
|
||||
/>
|
||||
{errors["AppKey"] && (
|
||||
<p className="text-red-500 text-xs mt-1">This field is required</p>
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
This field is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -666,7 +949,7 @@ export default function MeterManagement({ selectedProject: initialProject }: { s
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,16 +2,12 @@ import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
|
||||
server: {
|
||||
allowedHosts: [
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"reyna-compressive-shaunna.ngrok-free.dev",
|
||||
],
|
||||
host: true, // expone en 0.0.0.0
|
||||
allowedHosts: "all", // permite dominios como *.ngrok-free.dev
|
||||
port: 5173,
|
||||
strictPort: true, // no brinca de puerto
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user