Compare commits
10 Commits
fab39f099d
...
2b5735d78d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b5735d78d | ||
|
|
143cd77cee | ||
|
|
c63e6085bd | ||
|
|
9af06addad | ||
|
|
16f1f68499 | ||
|
|
3681725b8f | ||
|
|
48df8a2bfd | ||
|
|
f8c75c3c9c | ||
|
|
dd3997a3a8 | ||
|
|
59067b387c |
611
DOCUMENTATION.md
Normal file
611
DOCUMENTATION.md
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
# Documentacion Tecnica - Water Project GRH
|
||||||
|
|
||||||
|
Documentacion tecnica detallada del Sistema de Gestion de Recursos Hidricos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabla de Contenidos
|
||||||
|
|
||||||
|
1. [Arquitectura del Sistema](#arquitectura-del-sistema)
|
||||||
|
2. [Componentes Principales](#componentes-principales)
|
||||||
|
3. [Capa de API](#capa-de-api)
|
||||||
|
4. [Hooks Personalizados](#hooks-personalizados)
|
||||||
|
5. [Sistema de Autenticacion](#sistema-de-autenticacion)
|
||||||
|
6. [Gestion de Estado](#gestion-de-estado)
|
||||||
|
7. [Sistema de Temas](#sistema-de-temas)
|
||||||
|
8. [Guia de Desarrollo](#guia-de-desarrollo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura del Sistema
|
||||||
|
|
||||||
|
### Vision General
|
||||||
|
|
||||||
|
El proyecto sigue una arquitectura **frontend SPA (Single Page Application)** con las siguientes capas:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Capa de Presentacion │
|
||||||
|
│ (React Components, Pages, Layout) │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Capa de Logica │
|
||||||
|
│ (Custom Hooks, State Management) │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Capa de Datos │
|
||||||
|
│ (API Services, localStorage) │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ API Externa │
|
||||||
|
│ (REST API Backend) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Patron de Diseno
|
||||||
|
|
||||||
|
El proyecto utiliza varios patrones de diseno:
|
||||||
|
|
||||||
|
1. **Container/Presentational Pattern**: Separacion entre componentes con logica (pages) y componentes de UI puros (components)
|
||||||
|
2. **Custom Hooks Pattern**: Encapsulacion de logica reutilizable en hooks (`useMeters`, `useConcentrators`)
|
||||||
|
3. **Module Pattern**: Organizacion de codigo relacionado en modulos (meters/, concentrators/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Componentes Principales
|
||||||
|
|
||||||
|
### App.tsx - Componente Raiz
|
||||||
|
|
||||||
|
El componente raiz maneja:
|
||||||
|
- Autenticacion global
|
||||||
|
- Routing interno (sin react-router)
|
||||||
|
- Estado de pagina actual
|
||||||
|
- Modales globales (perfil, configuracion, logout)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Estados principales
|
||||||
|
const [isAuth, setIsAuth] = useState<boolean>(() => {
|
||||||
|
return Boolean(localStorage.getItem(AUTH_KEY));
|
||||||
|
});
|
||||||
|
const [currentPage, setCurrentPage] = useState("home");
|
||||||
|
const [currentSubpage, setCurrentSubpage] = useState<string | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sidebar.tsx - Menu Lateral
|
||||||
|
|
||||||
|
Caracteristicas:
|
||||||
|
- Estados: colapsado/expandido
|
||||||
|
- Hover expansion con delay
|
||||||
|
- Pin/unpin para mantener expandido
|
||||||
|
- Menu jerarquico con submenus
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SidebarProps {
|
||||||
|
currentPage: string;
|
||||||
|
setCurrentPage: (page: string) => void;
|
||||||
|
currentSubpage: string | null;
|
||||||
|
setCurrentSubpage: (subpage: string | null) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TopMenu.tsx - Barra Superior
|
||||||
|
|
||||||
|
Funcionalidades:
|
||||||
|
- Breadcrumb de navegacion
|
||||||
|
- Notificaciones (placeholder)
|
||||||
|
- Menu de usuario con dropdown
|
||||||
|
- Acciones: perfil, configuracion, logout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Capa de API
|
||||||
|
|
||||||
|
### Estructura de Archivos
|
||||||
|
|
||||||
|
```
|
||||||
|
src/api/
|
||||||
|
├── me.ts # Perfil de usuario
|
||||||
|
├── meters.ts # Operaciones CRUD de medidores
|
||||||
|
├── concentrators.ts # Operaciones CRUD de concentradores
|
||||||
|
└── projects.ts # Operaciones CRUD de proyectos
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuracion
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
const API_TOKEN = import.meta.env.VITE_API_TOKEN;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Funciones de API - Medidores
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// meters.ts
|
||||||
|
|
||||||
|
// Obtener todos los medidores
|
||||||
|
export async function fetchMeters(): Promise<Meter[]>
|
||||||
|
|
||||||
|
// Crear medidor
|
||||||
|
export async function createMeter(meterData: Partial<Meter>): Promise<Meter>
|
||||||
|
|
||||||
|
// Actualizar medidor
|
||||||
|
export async function updateMeter(
|
||||||
|
id: string,
|
||||||
|
meterData: Partial<Meter>
|
||||||
|
): Promise<Meter>
|
||||||
|
|
||||||
|
// Eliminar medidor
|
||||||
|
export async function deleteMeter(id: string): Promise<void>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Funciones de API - Concentradores
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// concentrators.ts
|
||||||
|
|
||||||
|
export async function fetchConcentrators(): Promise<Concentrator[]>
|
||||||
|
export async function createConcentrator(data: Partial<Concentrator>): Promise<Concentrator>
|
||||||
|
export async function updateConcentrator(id: string, data: Partial<Concentrator>): Promise<Concentrator>
|
||||||
|
export async function deleteConcentrator(id: string): Promise<void>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Funciones de API - Proyectos
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// projects.ts
|
||||||
|
|
||||||
|
export async function fetchProjects(): Promise<Project[]>
|
||||||
|
export async function fetchProjectNames(): Promise<string[]>
|
||||||
|
export async function createProject(data: Partial<Project>): Promise<Project>
|
||||||
|
export async function updateProject(id: string, data: Partial<Project>): Promise<Project>
|
||||||
|
export async function deleteProject(id: string): Promise<void>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manejo de Errores
|
||||||
|
|
||||||
|
Todas las funciones de API siguen el patron:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Error: ${response.status}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hooks Personalizados
|
||||||
|
|
||||||
|
### useMeters.ts
|
||||||
|
|
||||||
|
Hook para gestion completa del modulo de medidores.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UseMetersReturn {
|
||||||
|
// Data
|
||||||
|
meters: Meter[];
|
||||||
|
filteredMeters: Meter[];
|
||||||
|
projectNames: string[];
|
||||||
|
|
||||||
|
// State
|
||||||
|
loading: boolean;
|
||||||
|
selectedMeter: Meter | null;
|
||||||
|
selectedProject: string | null;
|
||||||
|
searchTerm: string;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setSelectedMeter: (meter: Meter | null) => void;
|
||||||
|
setSelectedProject: (project: string | null) => void;
|
||||||
|
setSearchTerm: (term: string) => void;
|
||||||
|
refreshData: () => Promise<void>;
|
||||||
|
|
||||||
|
// CRUD
|
||||||
|
handleCreate: (data: Partial<Meter>) => Promise<void>;
|
||||||
|
handleUpdate: (id: string, data: Partial<Meter>) => Promise<void>;
|
||||||
|
handleDelete: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMeters(): UseMetersReturn
|
||||||
|
```
|
||||||
|
|
||||||
|
### useConcentrators.ts
|
||||||
|
|
||||||
|
Hook similar para concentradores con estructura equivalente.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UseConcentratorsReturn {
|
||||||
|
concentrators: Concentrator[];
|
||||||
|
filteredConcentrators: Concentrator[];
|
||||||
|
projectNames: string[];
|
||||||
|
loading: boolean;
|
||||||
|
selectedConcentrator: Concentrator | null;
|
||||||
|
// ... similar a useMeters
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConcentrators(): UseConcentratorsReturn
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sistema de Autenticacion
|
||||||
|
|
||||||
|
### Flujo de Autenticacion
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ ┌─────────────┐ ┌──────────────┐
|
||||||
|
│ LoginPage │────>│ Validacion │────>│ localStorage│
|
||||||
|
└──────────────┘ └─────────────┘ └──────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ App.tsx │
|
||||||
|
│ (isAuth) │
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Almacenamiento de Token
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const AUTH_KEY = "grh_auth";
|
||||||
|
|
||||||
|
interface AuthData {
|
||||||
|
token: string;
|
||||||
|
ts: number; // timestamp de login
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guardar
|
||||||
|
localStorage.setItem(AUTH_KEY, JSON.stringify({ token: "demo", ts: Date.now() }));
|
||||||
|
|
||||||
|
// Verificar
|
||||||
|
const isAuth = Boolean(localStorage.getItem(AUTH_KEY));
|
||||||
|
|
||||||
|
// Eliminar (logout)
|
||||||
|
localStorage.removeItem(AUTH_KEY);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Proteccion de Rutas
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// App.tsx
|
||||||
|
if (!isAuth) {
|
||||||
|
return <LoginPage onSuccess={handleLogin} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si esta autenticado, renderiza la aplicacion
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen">
|
||||||
|
<Sidebar {...props} />
|
||||||
|
<main>{renderPage()}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gestion de Estado
|
||||||
|
|
||||||
|
### Estado Local (useState)
|
||||||
|
|
||||||
|
La aplicacion utiliza principalmente `useState` de React para gestion de estado local:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Ejemplo en MeterPage.tsx
|
||||||
|
const [meters, setMeters] = useState<Meter[]>([]);
|
||||||
|
const [selectedMeter, setSelectedMeter] = useState<Meter | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [modalMode, setModalMode] = useState<"add" | "edit">("add");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estado Persistente (localStorage)
|
||||||
|
|
||||||
|
Para datos que deben persistir entre sesiones:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Configuraciones de usuario
|
||||||
|
const SETTINGS_KEY = "water_project_settings_v1";
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
theme: "system" | "light" | "dark";
|
||||||
|
compactMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guardar
|
||||||
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||||
|
|
||||||
|
// Cargar
|
||||||
|
const settings = JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flujo de Datos en Modulos
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ API Layer │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ Custom Hook │ (useMeters, useConcentrators)
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ Page │ (MeterPage, ConcentratorsPage)
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
├────────────────┬────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌───────────┐ ┌───────────┐ ┌───────────┐
|
||||||
|
│ Sidebar │ │ Table │ │ Modal │
|
||||||
|
└───────────┘ └───────────┘ └───────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sistema de Temas
|
||||||
|
|
||||||
|
### Configuracion de Tema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Theme = "system" | "light" | "dark";
|
||||||
|
|
||||||
|
const applyTheme = (theme: Theme) => {
|
||||||
|
if (theme === "system") {
|
||||||
|
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
document.documentElement.classList.toggle("dark", prefersDark);
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clases CSS con Tailwind
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Ejemplo de clases con soporte dark mode */
|
||||||
|
.card {
|
||||||
|
@apply bg-white dark:bg-gray-800;
|
||||||
|
@apply text-gray-900 dark:text-white;
|
||||||
|
@apply border-gray-200 dark:border-gray-700;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal de Configuracion
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// SettingsModals.tsx
|
||||||
|
interface SettingsModalsProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opciones de tema
|
||||||
|
const themeOptions = [
|
||||||
|
{ value: "system", label: "Sistema" },
|
||||||
|
{ value: "light", label: "Claro" },
|
||||||
|
{ value: "dark", label: "Oscuro" },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guia de Desarrollo
|
||||||
|
|
||||||
|
### Agregar una Nueva Pagina
|
||||||
|
|
||||||
|
1. **Crear el archivo de pagina**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/pages/nueva/NuevaPage.tsx
|
||||||
|
export default function NuevaPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1>Nueva Pagina</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Agregar al Sidebar**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Sidebar.tsx - agregar al menuItems
|
||||||
|
{
|
||||||
|
label: "Nueva Pagina",
|
||||||
|
page: "nueva",
|
||||||
|
icon: <IconComponent className="w-5 h-5" />,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Agregar al renderizado en App.tsx**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// App.tsx - agregar case en renderPage()
|
||||||
|
case "nueva":
|
||||||
|
return <NuevaPage />;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agregar un Nuevo Endpoint de API
|
||||||
|
|
||||||
|
1. **Crear archivo de API**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/api/nuevo.ts
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
const API_TOKEN = import.meta.env.VITE_API_TOKEN;
|
||||||
|
|
||||||
|
export interface NuevoItem {
|
||||||
|
id: string;
|
||||||
|
// ... campos
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNuevos(): Promise<NuevoItem[]> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/endpoint`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${API_TOKEN}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Error fetching data");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.records.map((r: any) => ({ id: r.id, ...r.fields }));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Crear hook personalizado (opcional)**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/pages/nuevo/useNuevo.ts
|
||||||
|
export function useNuevo() {
|
||||||
|
const [items, setItems] = useState<NuevoItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNuevos()
|
||||||
|
.then(setItems)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { items, loading };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agregar un Nuevo Componente Reutilizable
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/components/layout/common/NuevoComponente.tsx
|
||||||
|
interface NuevoComponenteProps {
|
||||||
|
title: string;
|
||||||
|
onAction: () => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NuevoComponente({
|
||||||
|
title,
|
||||||
|
onAction,
|
||||||
|
children,
|
||||||
|
}: NuevoComponenteProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||||
|
<h3 className="text-lg font-semibold">{title}</h3>
|
||||||
|
{children}
|
||||||
|
<button onClick={onAction}>Accion</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Convenios de Codigo
|
||||||
|
|
||||||
|
1. **Nombres de archivos**: PascalCase para componentes, camelCase para utilidades
|
||||||
|
2. **Interfaces**: Prefijo descriptivo (ej: `MeterFormData`, `UserSettings`)
|
||||||
|
3. **Hooks**: Prefijo `use` (ej: `useMeters`, `useAuth`)
|
||||||
|
4. **Constantes**: UPPER_SNAKE_CASE (ej: `API_BASE_URL`)
|
||||||
|
5. **CSS**: Utilizar Tailwind CSS, evitar CSS custom
|
||||||
|
|
||||||
|
### Testing (Pendiente de Implementacion)
|
||||||
|
|
||||||
|
El proyecto actualmente no tiene tests configurados. Para agregar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D vitest @testing-library/react @testing-library/jest-dom
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// vitest.config.ts
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variables de Entorno
|
||||||
|
|
||||||
|
| Variable | Descripcion | Requerida |
|
||||||
|
|----------|-------------|-----------|
|
||||||
|
| `VITE_API_BASE_URL` | URL base de la API | Si |
|
||||||
|
| `VITE_API_TOKEN` | Token de autenticacion API | Si |
|
||||||
|
|
||||||
|
### Ejemplo .env
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_BASE_URL=https://api.example.com
|
||||||
|
VITE_API_TOKEN=your-api-token-here
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencias Principales
|
||||||
|
|
||||||
|
| Paquete | Version | Uso |
|
||||||
|
|---------|---------|-----|
|
||||||
|
| react | 18.2.0 | Framework UI |
|
||||||
|
| react-dom | 18.2.0 | Renderizado DOM |
|
||||||
|
| typescript | 5.2.2 | Type safety |
|
||||||
|
| vite | 5.2.0 | Build tool |
|
||||||
|
| tailwindcss | 4.1.18 | Estilos |
|
||||||
|
| @mui/material | 7.3.6 | Componentes UI |
|
||||||
|
| @material-table/core | 6.5.2 | Tablas avanzadas |
|
||||||
|
| recharts | 3.6.0 | Graficas |
|
||||||
|
| lucide-react | 0.559.0 | Iconos |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comandos Utiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Desarrollo
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build produccion
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Preview del build
|
||||||
|
npm run preview
|
||||||
|
|
||||||
|
# Linting
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Error: CORS al conectar con API
|
||||||
|
|
||||||
|
Verificar que el backend tenga configurados los headers CORS correctos o usar un proxy en desarrollo.
|
||||||
|
|
||||||
|
### Error: Module not found
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf node_modules
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error: TypeScript type errors
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
Revisar los errores y corregir los tipos.
|
||||||
|
|
||||||
|
### La aplicacion no carga
|
||||||
|
|
||||||
|
1. Verificar que las variables de entorno estan configuradas
|
||||||
|
2. Verificar la consola del navegador por errores
|
||||||
|
3. Verificar que la API esta accesible
|
||||||
385
README.md
385
README.md
@@ -1,30 +1,375 @@
|
|||||||
# React + TypeScript + Vite
|
# Water Project - Sistema de Gestion de Recursos Hidricos (GRH)
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
Sistema de gestion y monitoreo de infraestructura hidrica desarrollado con React, TypeScript y Vite.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
## Descripcion General
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
El **Sistema de Gestion de Recursos Hidricos (GRH)** es una aplicacion web frontend disenada para el monitoreo, administracion y control de infraestructura de toma de agua. Permite gestionar medidores, concentradores, proyectos, usuarios y roles a traves de una interfaz moderna y responsiva.
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
### Caracteristicas Principales
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
- **Dashboard interactivo** con KPIs, alertas e historial de actividades
|
||||||
|
- **Gestion de Medidores (Tomas de Agua)** - CRUD completo con filtros por proyecto
|
||||||
|
- **Gestion de Concentradores** - Configuracion de gateways LoRa/LoRaWAN
|
||||||
|
- **Gestion de Proyectos** - Administracion de proyectos de infraestructura
|
||||||
|
- **Gestion de Usuarios y Roles** - Control de acceso al sistema
|
||||||
|
- **Tema claro/oscuro** - Personalizacion de la interfaz
|
||||||
|
- **Diseno responsive** - Compatible con desktop, tablet y movil
|
||||||
|
|
||||||
- Configure the top-level `parserOptions` property like this:
|
---
|
||||||
|
|
||||||
```js
|
## Stack Tecnologico
|
||||||
export default {
|
|
||||||
// other rules...
|
### Frontend
|
||||||
parserOptions: {
|
| Tecnologia | Version | Proposito |
|
||||||
ecmaVersion: 'latest',
|
|------------|---------|-----------|
|
||||||
sourceType: 'module',
|
| React | 18.2.0 | Framework UI |
|
||||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
| TypeScript | 5.2.2 | Type safety |
|
||||||
tsconfigRootDir: __dirname,
|
| Vite | 5.2.0 | Build tool y dev server |
|
||||||
},
|
| Tailwind CSS | 4.1.18 | Estilos utility-first |
|
||||||
|
| Material-UI | 7.3.6 | Componentes UI |
|
||||||
|
| Recharts | 3.6.0 | Visualizacion de datos |
|
||||||
|
| Lucide React | 0.559.0 | Iconos SVG |
|
||||||
|
|
||||||
|
### Herramientas de Desarrollo
|
||||||
|
- **ESLint** - Linting de codigo
|
||||||
|
- **TypeScript ESLint** - Analisis estatico
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instalacion
|
||||||
|
|
||||||
|
### Prerrequisitos
|
||||||
|
|
||||||
|
- Node.js >= 18.x
|
||||||
|
- npm >= 9.x o yarn >= 1.22
|
||||||
|
|
||||||
|
### Pasos de Instalacion
|
||||||
|
|
||||||
|
1. **Clonar el repositorio**
|
||||||
|
```bash
|
||||||
|
git clone <url-del-repositorio>
|
||||||
|
cd water-project
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Instalar dependencias**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configurar variables de entorno**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Editar el archivo `.env`:
|
||||||
|
```env
|
||||||
|
VITE_API_BASE_URL=https://tu-api-url.com
|
||||||
|
VITE_API_TOKEN=tu-token-de-api
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Iniciar servidor de desarrollo**
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
La aplicacion estara disponible en `http://localhost:5173`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scripts Disponibles
|
||||||
|
|
||||||
|
| Comando | Descripcion |
|
||||||
|
|---------|-------------|
|
||||||
|
| `npm run dev` | Inicia el servidor de desarrollo |
|
||||||
|
| `npm run build` | Compila TypeScript y genera build de produccion |
|
||||||
|
| `npm run preview` | Previsualiza el build de produccion |
|
||||||
|
| `npm run lint` | Ejecuta ESLint en el codigo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
water-project/
|
||||||
|
├── public/ # Assets estaticos
|
||||||
|
│ └── grhWatermark.jpg
|
||||||
|
│
|
||||||
|
├── src/
|
||||||
|
│ ├── api/ # Capa de comunicacion con API
|
||||||
|
│ │ ├── me.ts # Endpoints de perfil
|
||||||
|
│ │ ├── meters.ts # CRUD de medidores
|
||||||
|
│ │ ├── concentrators.ts # CRUD de concentradores
|
||||||
|
│ │ └── projects.ts # CRUD de proyectos
|
||||||
|
│ │
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── layout/ # Componentes de layout
|
||||||
|
│ │ │ ├── Sidebar.tsx # Menu lateral
|
||||||
|
│ │ │ ├── TopMenu.tsx # Barra superior
|
||||||
|
│ │ │ └── common/ # Componentes reutilizables
|
||||||
|
│ │ │ ├── ProfileModal.tsx
|
||||||
|
│ │ │ ├── ConfirmModal.tsx
|
||||||
|
│ │ │ └── Watermark.tsx
|
||||||
|
│ │ └── SettingsModals.tsx
|
||||||
|
│ │
|
||||||
|
│ ├── pages/ # Paginas principales
|
||||||
|
│ │ ├── Home.tsx # Dashboard
|
||||||
|
│ │ ├── LoginPage.tsx # Login
|
||||||
|
│ │ ├── UsersPage.tsx # Gestion de usuarios
|
||||||
|
│ │ ├── RolesPage.tsx # Gestion de roles
|
||||||
|
│ │ ├── projects/
|
||||||
|
│ │ │ └── ProjectsPage.tsx
|
||||||
|
│ │ ├── meters/ # Modulo de medidores
|
||||||
|
│ │ │ ├── MeterPage.tsx
|
||||||
|
│ │ │ ├── useMeters.ts # Hook personalizado
|
||||||
|
│ │ │ ├── MetersTable.tsx
|
||||||
|
│ │ │ ├── MetersModal.tsx
|
||||||
|
│ │ │ └── MetersSidebar.tsx
|
||||||
|
│ │ └── concentrators/ # Modulo de concentradores
|
||||||
|
│ │ ├── ConcentratorsPage.tsx
|
||||||
|
│ │ ├── useConcentrators.ts
|
||||||
|
│ │ ├── ConcentratorsTable.tsx
|
||||||
|
│ │ ├── ConcentratorsModal.tsx
|
||||||
|
│ │ └── ConcentratorsSidebar.tsx
|
||||||
|
│ │
|
||||||
|
│ ├── assets/
|
||||||
|
│ │ └── images/
|
||||||
|
│ │
|
||||||
|
│ ├── App.tsx # Componente raiz
|
||||||
|
│ ├── main.tsx # Punto de entrada
|
||||||
|
│ └── index.css # Estilos globales
|
||||||
|
│
|
||||||
|
├── index.html
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── vite.config.ts
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modulos Funcionales
|
||||||
|
|
||||||
|
### 1. Dashboard (Home)
|
||||||
|
|
||||||
|
El dashboard principal ofrece:
|
||||||
|
- Selector de organismos operadores (CESPT TIJUANA, TECATE, MEXICALI)
|
||||||
|
- Grafico de barras: "Numero de Medidores por Proyecto"
|
||||||
|
- Tarjetas de acceso rapido: Tomas, Alertas, Mantenimiento, Reportes
|
||||||
|
- Historial reciente de actividades
|
||||||
|
- Panel de ultimas alertas
|
||||||
|
|
||||||
|
### 2. Gestion de Medidores
|
||||||
|
|
||||||
|
Modulo completo para administrar medidores/tomas de agua:
|
||||||
|
|
||||||
|
**Funcionalidades:**
|
||||||
|
- Listado con busqueda y filtros
|
||||||
|
- Filtrado por proyecto
|
||||||
|
- Tipos de toma: GENERAL, LORA, LORAWAN, GRANDES
|
||||||
|
- CRUD completo (Crear, Leer, Actualizar, Eliminar)
|
||||||
|
|
||||||
|
**Campos principales:**
|
||||||
|
- Area, Numero de cuenta, Usuario, Direccion
|
||||||
|
- Serial del medidor, Nombre, Estado
|
||||||
|
- Tipo de protocolo, Particion DMA
|
||||||
|
- Configuracion de dispositivo (Device EUI, AppKey, etc.)
|
||||||
|
|
||||||
|
### 3. Gestion de Concentradores
|
||||||
|
|
||||||
|
Administracion de concentradores y gateways:
|
||||||
|
|
||||||
|
**Funcionalidades:**
|
||||||
|
- Listado con filtros por proyecto
|
||||||
|
- Configuracion de Gateway (ID, EUI, Nombre)
|
||||||
|
- Seleccion de ubicacion de antena (Indoor/Outdoor)
|
||||||
|
- CRUD completo
|
||||||
|
|
||||||
|
### 4. Gestion de Proyectos
|
||||||
|
|
||||||
|
Administracion de proyectos de infraestructura:
|
||||||
|
- Tabla con busqueda integrada
|
||||||
|
- Estados: ACTIVE/INACTIVE
|
||||||
|
- Informacion de operador y tiempos
|
||||||
|
|
||||||
|
### 5. Gestion de Usuarios
|
||||||
|
|
||||||
|
Control de usuarios del sistema:
|
||||||
|
- Listado de usuarios
|
||||||
|
- Asignacion de roles
|
||||||
|
- Estados: ACTIVE/INACTIVE
|
||||||
|
|
||||||
|
### 6. Gestion de Roles
|
||||||
|
|
||||||
|
Administracion de roles de acceso:
|
||||||
|
- Roles predefinidos: SUPER_ADMIN, USER
|
||||||
|
- Descripcion de permisos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API y Comunicacion
|
||||||
|
|
||||||
|
### Configuracion
|
||||||
|
|
||||||
|
La aplicacion se conecta a una API REST externa. Configurar en `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_BASE_URL=https://tu-api.com
|
||||||
|
VITE_API_TOKEN=tu-token
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoints Principales
|
||||||
|
|
||||||
|
| Recurso | Endpoint Base |
|
||||||
|
|---------|---------------|
|
||||||
|
| Medidores | `/api/v3/data/.../m4hzpnopjkppaav/records` |
|
||||||
|
| Concentradores | `/api/v3/data/.../mheif1vdgnyt8x2/records` |
|
||||||
|
| Proyectos | `/api/v3/data/.../m9882vn3xb31e29/records` |
|
||||||
|
|
||||||
|
### Estructura de Respuesta
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
records: Array<{
|
||||||
|
id: string;
|
||||||
|
fields: T;
|
||||||
|
}>;
|
||||||
|
next?: string;
|
||||||
|
prev?: string;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
---
|
||||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
|
||||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
## Modelos de Datos
|
||||||
|
|
||||||
|
### Meter (Medidor)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Meter {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
areaName: string;
|
||||||
|
accountNumber: string | null;
|
||||||
|
userName: string | null;
|
||||||
|
userAddress: string | null;
|
||||||
|
meterSerialNumber: string;
|
||||||
|
meterName: string;
|
||||||
|
meterStatus: string;
|
||||||
|
protocolType: string;
|
||||||
|
priceNo: string | null;
|
||||||
|
priceName: string | null;
|
||||||
|
dmaPartition: string | null;
|
||||||
|
supplyTypes: string;
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
deviceType: string;
|
||||||
|
usageAnalysisType: string;
|
||||||
|
installedTime: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Concentrator
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Concentrator {
|
||||||
|
id: string;
|
||||||
|
"Area Name": string;
|
||||||
|
"Device S/N": string;
|
||||||
|
"Device Name": string;
|
||||||
|
"Device Time": string;
|
||||||
|
"Device Status": string;
|
||||||
|
"Operator": string;
|
||||||
|
"Installed Time": string;
|
||||||
|
"Communication Time": string;
|
||||||
|
"Instruction Manual": string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
areaName: string;
|
||||||
|
deviceSN: string;
|
||||||
|
deviceName: string;
|
||||||
|
deviceType: string;
|
||||||
|
deviceStatus: "ACTIVE" | "INACTIVE";
|
||||||
|
operator: string;
|
||||||
|
installedTime: string;
|
||||||
|
communicationTime: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Autenticacion
|
||||||
|
|
||||||
|
### Flujo de Login
|
||||||
|
|
||||||
|
1. Usuario ingresa credenciales
|
||||||
|
2. Validacion del checkbox "No soy un robot"
|
||||||
|
3. Token almacenado en `localStorage` (`grh_auth`)
|
||||||
|
4. Redireccion al Dashboard
|
||||||
|
|
||||||
|
### Almacenamiento
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// localStorage keys
|
||||||
|
grh_auth: { token: string, ts: number }
|
||||||
|
water_project_settings_v1: { theme: string, compactMode: boolean }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuracion de Temas
|
||||||
|
|
||||||
|
El sistema soporta tres modos de tema:
|
||||||
|
- **Sistema** - Detecta preferencia del OS
|
||||||
|
- **Claro** - Tema light
|
||||||
|
- **Oscuro** - Tema dark
|
||||||
|
|
||||||
|
Configuracion persistida en `localStorage` bajo `water_project_settings_v1`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
### Build de Produccion
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Los archivos compilados se generan en la carpeta `dist/`.
|
||||||
|
|
||||||
|
### Configuracion de Vite
|
||||||
|
|
||||||
|
El servidor de desarrollo esta configurado para:
|
||||||
|
- Puerto: 5173
|
||||||
|
- Host: habilitado para acceso remoto
|
||||||
|
- Hosts permitidos: localhost, 127.0.0.1, dominios personalizados
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contribucion
|
||||||
|
|
||||||
|
1. Fork del repositorio
|
||||||
|
2. Crear rama feature (`git checkout -b feature/nueva-funcionalidad`)
|
||||||
|
3. Commit de cambios (`git commit -m 'Agregar nueva funcionalidad'`)
|
||||||
|
4. Push a la rama (`git push origin feature/nueva-funcionalidad`)
|
||||||
|
5. Crear Pull Request
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
Este proyecto es privado y pertenece a GRH - Gestion de Recursos Hidricos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contacto
|
||||||
|
|
||||||
|
Para soporte o consultas sobre el sistema, contactar al equipo de desarrollo.
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/jpeg" href="/grhWatermark.jpg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>GRH</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
104
src/App.tsx
104
src/App.tsx
@@ -9,13 +9,19 @@ import ProjectsPage from "./pages/projects/ProjectsPage";
|
|||||||
import UsersPage from "./pages/UsersPage";
|
import UsersPage from "./pages/UsersPage";
|
||||||
import RolesPage from "./pages/RolesPage";
|
import RolesPage from "./pages/RolesPage";
|
||||||
import ProfileModal from "./components/layout/common/ProfileModal";
|
import ProfileModal from "./components/layout/common/ProfileModal";
|
||||||
import { uploadMyAvatar, updateMyProfile } from "./api/me";
|
import { updateMyProfile } from "./api/me";
|
||||||
|
|
||||||
import SettingsModal, {
|
import SettingsModal, {
|
||||||
type AppSettings,
|
type AppSettings,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
} from "./components/SettingsModals";
|
} from "./components/SettingsModals";
|
||||||
|
|
||||||
|
import LoginPage from "./pages/LoginPage";
|
||||||
|
|
||||||
|
// ✅ NUEVO
|
||||||
|
import ConfirmModal from "./components/layout/common/ConfirmModal";
|
||||||
|
import Watermark from "./components/layout/common/Watermark";
|
||||||
|
|
||||||
export type Page =
|
export type Page =
|
||||||
| "home"
|
| "home"
|
||||||
| "projects"
|
| "projects"
|
||||||
@@ -24,12 +30,38 @@ export type Page =
|
|||||||
| "users"
|
| "users"
|
||||||
| "roles";
|
| "roles";
|
||||||
|
|
||||||
|
const AUTH_KEY = "grh_auth";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [isAuth, setIsAuth] = useState<boolean>(() => {
|
||||||
|
return Boolean(localStorage.getItem(AUTH_KEY));
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLogin = (payload?: { token?: string }) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
AUTH_KEY,
|
||||||
|
JSON.stringify({ token: payload?.token ?? "demo", ts: Date.now() })
|
||||||
|
);
|
||||||
|
setIsAuth(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem(AUTH_KEY);
|
||||||
|
setIsAuth(false);
|
||||||
|
// opcional: reset de navegación
|
||||||
|
setPage("home");
|
||||||
|
setSubPage("default");
|
||||||
|
setSelectedProject("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ confirm logout modal state
|
||||||
|
const [logoutOpen, setLogoutOpen] = useState(false);
|
||||||
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
|
|
||||||
const [page, setPage] = useState<Page>("home");
|
const [page, setPage] = useState<Page>("home");
|
||||||
const [subPage, setSubPage] = useState<string>("default");
|
const [subPage, setSubPage] = useState<string>("default");
|
||||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||||
|
|
||||||
// ✅ perfil usuario + modal
|
|
||||||
const [profileOpen, setProfileOpen] = useState(false);
|
const [profileOpen, setProfileOpen] = useState(false);
|
||||||
const [savingProfile, setSavingProfile] = useState(false);
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
|
|
||||||
@@ -37,27 +69,22 @@ export default function App() {
|
|||||||
name: "CESPT Admin",
|
name: "CESPT Admin",
|
||||||
email: "admin@cespt.gob.mx",
|
email: "admin@cespt.gob.mx",
|
||||||
avatarUrl: null as string | null,
|
avatarUrl: null as string | null,
|
||||||
organismName: "CESPT", // ✅ NUEVO: Empresa/Organismo
|
organismName: "CESPT",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Settings state
|
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [settings, setSettings] = useState<AppSettings>(() => loadSettings());
|
const [settings, setSettings] = useState<AppSettings>(() => loadSettings());
|
||||||
|
|
||||||
const navigateToMetersWithProject = (projectName: string) => {
|
const navigateToMetersWithProject = (projectName: string) => {
|
||||||
setSelectedProject(projectName);
|
setSelectedProject(projectName);
|
||||||
setSubPage(projectName); // útil para breadcrumb si lo usas
|
setSubPage(projectName);
|
||||||
setPage("meters");
|
setPage("meters");
|
||||||
};
|
};
|
||||||
|
|
||||||
// ✅ handlers
|
|
||||||
const handleUploadAvatar = async (file: File) => {
|
const handleUploadAvatar = async (file: File) => {
|
||||||
// 1) Guardar como base64 en localStorage (demo)
|
|
||||||
const base64 = await fileToBase64(file);
|
const base64 = await fileToBase64(file);
|
||||||
localStorage.setItem("mock_avatar", base64 as string);
|
localStorage.setItem("mock_avatar", base64);
|
||||||
|
setUser((prev) => ({ ...prev, avatarUrl: base64 }));
|
||||||
// 2) Guardar en state para que se vea inmediato
|
|
||||||
setUser((prev) => ({ ...prev, avatarUrl: base64 as string }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function fileToBase64(file: File) {
|
function fileToBase64(file: File) {
|
||||||
@@ -69,7 +96,6 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ ahora también recibe organismName
|
|
||||||
const handleSaveProfile = async (next: {
|
const handleSaveProfile = async (next: {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -81,11 +107,11 @@ export default function App() {
|
|||||||
|
|
||||||
setUser((prev) => ({
|
setUser((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
// si backend regresa valores, los usamos; si no, usamos "next" o lo anterior
|
|
||||||
name: updated.name ?? next.name ?? prev.name,
|
name: updated.name ?? next.name ?? prev.name,
|
||||||
email: updated.email ?? next.email ?? prev.email,
|
email: updated.email ?? next.email ?? prev.email,
|
||||||
avatarUrl: updated.avatarUrl ?? prev.avatarUrl,
|
avatarUrl: updated.avatarUrl ?? prev.avatarUrl,
|
||||||
organismName: updated.organismName ?? next.organismName ?? prev.organismName,
|
organismName:
|
||||||
|
updated.organismName ?? next.organismName ?? prev.organismName,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setProfileOpen(false);
|
setProfileOpen(false);
|
||||||
@@ -94,7 +120,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Aplica theme al cargar / cambiar (para cubrir refresh)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.remove("dark");
|
root.classList.remove("dark");
|
||||||
@@ -134,15 +159,17 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!isAuth) {
|
||||||
|
return <LoginPage onSuccess={handleLogin} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Blindaje global del layout
|
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
"flex h-screen w-full overflow-hidden",
|
"flex h-screen w-full overflow-hidden",
|
||||||
settings.compactMode ? "text-sm" : "",
|
settings.compactMode ? "text-sm" : "",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{/* Sidebar no debe encogerse */}
|
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
setPage={(p) => {
|
setPage={(p) => {
|
||||||
@@ -153,29 +180,28 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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="flex min-w-0 flex-1 flex-col">
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<TopMenu
|
<TopMenu
|
||||||
page={page}
|
page={page}
|
||||||
subPage={subPage}
|
subPage={subPage}
|
||||||
setSubPage={setSubPage}
|
setSubPage={setSubPage}
|
||||||
setPage={setPage}
|
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
|
||||||
// props de perfil
|
|
||||||
userName={user.name}
|
userName={user.name}
|
||||||
userEmail={user.email}
|
userEmail={user.email}
|
||||||
avatarUrl={user.avatarUrl}
|
avatarUrl={user.avatarUrl}
|
||||||
onOpenProfile={() => setProfileOpen(true)}
|
onOpenProfile={() => setProfileOpen(true)}
|
||||||
onUploadAvatar={handleUploadAvatar}
|
// ✅ en vez de cerrar, abrimos confirm modal
|
||||||
|
onRequestLogout={() => setLogoutOpen(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll solo aquí */}
|
{/* ✅ AQUÍ VA LA MARCA DE AGUA */}
|
||||||
<main className="min-w-0 flex-1 overflow-auto">{renderPage()}</main>
|
<main className="relative min-w-0 flex-1 overflow-auto">
|
||||||
|
<Watermark />
|
||||||
|
<div className="relative z-10">{renderPage()}</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings modal */}
|
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
open={settingsOpen}
|
open={settingsOpen}
|
||||||
onClose={() => setSettingsOpen(false)}
|
onClose={() => setSettingsOpen(false)}
|
||||||
@@ -183,19 +209,39 @@ export default function App() {
|
|||||||
setSettings={setSettings}
|
setSettings={setSettings}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ✅ Profile modal (con avatar + cambiar img + empresa) */}
|
|
||||||
<ProfileModal
|
<ProfileModal
|
||||||
open={profileOpen}
|
open={profileOpen}
|
||||||
loading={savingProfile}
|
loading={savingProfile}
|
||||||
avatarUrl={user.avatarUrl} // ✅ NUEVO
|
avatarUrl={user.avatarUrl}
|
||||||
initial={{
|
initial={{
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
organismName: user.organismName, // ✅ NUEVO
|
organismName: user.organismName,
|
||||||
}}
|
}}
|
||||||
onClose={() => setProfileOpen(false)}
|
onClose={() => setProfileOpen(false)}
|
||||||
onSave={handleSaveProfile}
|
onSave={handleSaveProfile}
|
||||||
onUploadAvatar={handleUploadAvatar} // ✅ NUEVO (botón Cambiar img en modal)
|
onUploadAvatar={handleUploadAvatar}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ✅ ConfirmModal: Cerrar sesión */}
|
||||||
|
<ConfirmModal
|
||||||
|
open={logoutOpen}
|
||||||
|
title="Cerrar sesión"
|
||||||
|
message="¿Estás seguro que deseas cerrar sesión?"
|
||||||
|
confirmText="Cerrar sesión"
|
||||||
|
cancelText="Cancelar"
|
||||||
|
danger
|
||||||
|
loading={loggingOut}
|
||||||
|
onClose={() => setLogoutOpen(false)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
setLoggingOut(true);
|
||||||
|
try {
|
||||||
|
handleLogout();
|
||||||
|
setLogoutOpen(false);
|
||||||
|
} finally {
|
||||||
|
setLoggingOut(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
BIN
src/assets/images/grhWatermark.png
Normal file
BIN
src/assets/images/grhWatermark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@@ -10,8 +10,10 @@ interface TopMenuProps {
|
|||||||
userEmail?: string;
|
userEmail?: string;
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
|
|
||||||
onLogout?: () => void;
|
|
||||||
onOpenProfile?: () => void;
|
onOpenProfile?: () => void;
|
||||||
|
|
||||||
|
// ✅ NUEVO: en vez de cerrar, pedimos confirmación desde App
|
||||||
|
onRequestLogout?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TopMenu: React.FC<TopMenuProps> = ({
|
const TopMenu: React.FC<TopMenuProps> = ({
|
||||||
@@ -23,11 +25,10 @@ const TopMenu: React.FC<TopMenuProps> = ({
|
|||||||
userEmail,
|
userEmail,
|
||||||
avatarUrl = null,
|
avatarUrl = null,
|
||||||
|
|
||||||
onLogout,
|
|
||||||
onOpenProfile,
|
onOpenProfile,
|
||||||
|
onRequestLogout,
|
||||||
}) => {
|
}) => {
|
||||||
const [openUserMenu, setOpenUserMenu] = useState(false);
|
const [openUserMenu, setOpenUserMenu] = useState(false);
|
||||||
|
|
||||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const initials = useMemo(() => {
|
const initials = useMemo(() => {
|
||||||
@@ -37,7 +38,6 @@ const TopMenu: React.FC<TopMenuProps> = ({
|
|||||||
return (a + b).toUpperCase();
|
return (a + b).toUpperCase();
|
||||||
}, [userName]);
|
}, [userName]);
|
||||||
|
|
||||||
// Cerrar al click afuera
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(e: MouseEvent) {
|
function handleClickOutside(e: MouseEvent) {
|
||||||
if (!openUserMenu) return;
|
if (!openUserMenu) return;
|
||||||
@@ -48,7 +48,6 @@ const TopMenu: React.FC<TopMenuProps> = ({
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [openUserMenu]);
|
}, [openUserMenu]);
|
||||||
|
|
||||||
// Cerrar con ESC
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleEsc(e: KeyboardEvent) {
|
function handleEsc(e: KeyboardEvent) {
|
||||||
if (e.key === "Escape") setOpenUserMenu(false);
|
if (e.key === "Escape") setOpenUserMenu(false);
|
||||||
@@ -59,7 +58,7 @@ const TopMenu: React.FC<TopMenuProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className="relative z-40 h-14 shrink-0 flex items-center justify-between px-4 text-white"
|
className="relative z-20 h-14 shrink-0 flex items-center justify-between px-4 text-white"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
"linear-gradient(135deg, #4c5f9e, #2a355d, #566bb8, #3d4e87)",
|
"linear-gradient(135deg, #4c5f9e, #2a355d, #566bb8, #3d4e87)",
|
||||||
@@ -117,12 +116,8 @@ const TopMenu: React.FC<TopMenuProps> = ({
|
|||||||
role="menu"
|
role="menu"
|
||||||
className="
|
className="
|
||||||
absolute right-0 mt-2 w-80
|
absolute right-0 mt-2 w-80
|
||||||
rounded-2xl
|
rounded-2xl bg-white border border-slate-200
|
||||||
bg-white
|
shadow-xl overflow-hidden z-50
|
||||||
border border-slate-200
|
|
||||||
shadow-xl
|
|
||||||
overflow-hidden
|
|
||||||
z-50
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{/* Header usuario */}
|
{/* Header usuario */}
|
||||||
@@ -157,7 +152,6 @@ const TopMenu: React.FC<TopMenuProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Items (solo 2) */}
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
label="Ver / editar perfil"
|
label="Ver / editar perfil"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -174,11 +168,7 @@ const TopMenu: React.FC<TopMenuProps> = ({
|
|||||||
tone="danger"
|
tone="danger"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpenUserMenu(false);
|
setOpenUserMenu(false);
|
||||||
if (onLogout) onLogout();
|
onRequestLogout?.(); // ✅ abre confirm modal en App
|
||||||
else {
|
|
||||||
localStorage.removeItem("token");
|
|
||||||
window.location.href = "/login";
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
left={<LogOut size={16} />}
|
left={<LogOut size={16} />}
|
||||||
/>
|
/>
|
||||||
@@ -225,4 +215,3 @@ function MenuItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default TopMenu;
|
export default TopMenu;
|
||||||
|
|
||||||
|
|||||||
@@ -54,12 +54,17 @@ export default function ProfileModal({
|
|||||||
// Limpieza de object URLs
|
// Limpieza de object URLs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (lastPreviewUrlRef.current) URL.revokeObjectURL(lastPreviewUrlRef.current);
|
if (lastPreviewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(lastPreviewUrlRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const initials = useMemo(() => {
|
const initials = useMemo(() => {
|
||||||
const parts = (name || "").trim().split(/\s+/).filter(Boolean);
|
const parts = (name || "")
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean);
|
||||||
const a = parts[0]?.[0] ?? "U";
|
const a = parts[0]?.[0] ?? "U";
|
||||||
const b = parts[1]?.[0] ?? "";
|
const b = parts[1]?.[0] ?? "";
|
||||||
return (a + b).toUpperCase();
|
return (a + b).toUpperCase();
|
||||||
@@ -148,7 +153,9 @@ export default function ProfileModal({
|
|||||||
<div className="rounded-2xl bg-white shadow-xl border border-slate-200 overflow-hidden">
|
<div className="rounded-2xl bg-white shadow-xl border border-slate-200 overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-6 py-4 border-b border-slate-200">
|
<div className="px-6 py-4 border-b border-slate-200">
|
||||||
<div className="text-base font-semibold text-slate-900">Editar perfil</div>
|
<div className="text-base font-semibold text-slate-900">
|
||||||
|
Editar perfil
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
@@ -206,7 +213,6 @@ export default function ProfileModal({
|
|||||||
|
|
||||||
{/* RIGHT: Form */}
|
{/* RIGHT: Form */}
|
||||||
<div className="rounded-2xl border border-slate-200 p-5">
|
<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">
|
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
correo electrónico
|
correo electrónico
|
||||||
</div>
|
</div>
|
||||||
@@ -253,6 +259,7 @@ export default function ProfileModal({
|
|||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
@@ -266,7 +273,6 @@ export default function ProfileModal({
|
|||||||
>
|
>
|
||||||
{loading ? "Guardando..." : "Guardar"}
|
{loading ? "Guardando..." : "Guardar"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
51
src/components/layout/common/Watermark.tsx
Normal file
51
src/components/layout/common/Watermark.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from "react";
|
||||||
|
import grhWatermark from "../../../assets/images/grhWatermark.png";
|
||||||
|
|
||||||
|
export default function Watermark({
|
||||||
|
opacity = 0.08,
|
||||||
|
size = 520,
|
||||||
|
}: {
|
||||||
|
opacity?: number;
|
||||||
|
size?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed inset-0 z-20 overflow-hidden">
|
||||||
|
{/* Marca centrada (SIN rotación) */}
|
||||||
|
<div
|
||||||
|
className="absolute left-1/2 top-1/2"
|
||||||
|
style={{
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={grhWatermark}
|
||||||
|
alt="GRH Watermark"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className="select-none object-contain"
|
||||||
|
draggable={false}
|
||||||
|
style={{ filter: "grayscale(100%)" }} // opcional
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Marca secundaria (SIN rotación) */}
|
||||||
|
<div
|
||||||
|
className="absolute right-[-140px] bottom-[-180px]"
|
||||||
|
style={{
|
||||||
|
opacity: Math.max(0.04, opacity * 0.55),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={grhWatermark}
|
||||||
|
alt="GRH Watermark"
|
||||||
|
width={Math.round(size * 0.75)}
|
||||||
|
height={Math.round(size * 0.75)}
|
||||||
|
className="select-none object-contain"
|
||||||
|
draggable={false}
|
||||||
|
style={{ filter: "grayscale(100%)" }} // opcional
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { fetchMeters, type Meter } from "../api/meters";
|
import { fetchMeters, type Meter } from "../api/meters";
|
||||||
import type { Page } from "../App";
|
import type { Page } from "../App";
|
||||||
import grhWatermark from "../assets/images/grhWatermark.jpg";
|
import grhWatermark from "../assets/images/grhWatermark.png";
|
||||||
|
|
||||||
/* ================= TYPES ================= */
|
/* ================= TYPES ================= */
|
||||||
|
|
||||||
@@ -270,7 +270,7 @@ export default function Home({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showOrganisms && (
|
{showOrganisms && (
|
||||||
<div className="fixed inset-0 z-50">
|
<div className="fixed inset-0 z-30">
|
||||||
{/* Overlay */}
|
{/* Overlay */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/40"
|
className="absolute inset-0 bg-black/40"
|
||||||
|
|||||||
218
src/pages/LoginPage.tsx
Normal file
218
src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Lock, User, Eye, EyeOff, Loader2, Check } from "lucide-react";
|
||||||
|
import grhWatermark from "../assets/images/grhWatermark.png";
|
||||||
|
|
||||||
|
type Form = { usuario: string; contrasena: string };
|
||||||
|
|
||||||
|
type LoginPageProps = {
|
||||||
|
onSuccess: (payload?: { token?: string }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LoginPage({ onSuccess }: LoginPageProps) {
|
||||||
|
const [form, setForm] = useState<Form>({ usuario: "", contrasena: "" });
|
||||||
|
const [showPass, setShowPass] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [serverError, setServerError] = useState("");
|
||||||
|
const [notRobot, setNotRobot] = useState(false);
|
||||||
|
|
||||||
|
const errors = useMemo(() => {
|
||||||
|
const e: Partial<Record<keyof Form | "robot", string>> = {};
|
||||||
|
if (!form.usuario.trim()) e.usuario = "El usuario es obligatorio.";
|
||||||
|
if (!form.contrasena) e.contrasena = "La contraseña es obligatoria.";
|
||||||
|
if (!notRobot) e.robot = "Confirma que no eres un robot.";
|
||||||
|
return e;
|
||||||
|
}, [form.usuario, form.contrasena, notRobot]);
|
||||||
|
|
||||||
|
const canSubmit = Object.keys(errors).length === 0 && !loading;
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setServerError("");
|
||||||
|
if (!canSubmit) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await new Promise((r) => setTimeout(r, 700));
|
||||||
|
onSuccess({ token: "demo" });
|
||||||
|
} catch {
|
||||||
|
setServerError("No se pudo iniciar sesión. Verifica tus datos.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen font-sans bg-slate-50">
|
||||||
|
<div className="relative h-full w-full overflow-hidden bg-white">
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 h-[3px] w-full opacity-90"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(90deg, transparent, rgba(86,107,184,0.9), rgba(76,95,158,0.9), transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid h-full grid-cols-1 md:grid-cols-2">
|
||||||
|
{/* IZQUIERDA */}
|
||||||
|
<section className="relative overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, #2a355d 10%, #4c5f9e 55%, #566bb8 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
clipPath: "polygon(0 0, 80% 0, 55% 100%, 0 100%)",
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, rgba(255,255,255,0.10), rgba(255,255,255,0.02))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative h-full flex items-center px-10 md:px-12">
|
||||||
|
<div className="max-w-sm text-white">
|
||||||
|
<h2 className="text-4xl font-semibold tracking-tight">
|
||||||
|
¡Bienvenido!
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm leading-relaxed text-white/90">
|
||||||
|
Ingresa con tus credenciales para acceder al panel GRH.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* DERECHA */}
|
||||||
|
<section className="bg-white flex items-center justify-center">
|
||||||
|
<div className="w-full max-w-lg px-6">
|
||||||
|
<div className="rounded-3xl border border-slate-200 bg-white/90 p-10 md:p-12 shadow-lg">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<img
|
||||||
|
src={grhWatermark}
|
||||||
|
alt="GRH"
|
||||||
|
className="h-20 w-20 object-contain rounded-lg"
|
||||||
|
/>
|
||||||
|
<div className="leading-tight">
|
||||||
|
<h1 className="text-2xl font-semibold text-slate-900">
|
||||||
|
Iniciar sesión
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Gestión de Recursos Hídricos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="mt-8 space-y-6">
|
||||||
|
{serverError && (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{serverError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Usuario */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700">
|
||||||
|
Usuario
|
||||||
|
</label>
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<input
|
||||||
|
value={form.usuario}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((s) => ({ ...s, usuario: e.target.value }))
|
||||||
|
}
|
||||||
|
className="w-full border-b border-slate-300 py-2 pr-10 outline-none focus:border-slate-600"
|
||||||
|
/>
|
||||||
|
<User
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-500"
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.usuario && (
|
||||||
|
<p className="mt-1 text-xs text-red-600">
|
||||||
|
{errors.usuario}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contraseña */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700">
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<input
|
||||||
|
value={form.contrasena}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((s) => ({ ...s, contrasena: e.target.value }))
|
||||||
|
}
|
||||||
|
type={showPass ? "text" : "password"}
|
||||||
|
className="w-full border-b border-slate-300 py-2 pr-16 outline-none focus:border-slate-600"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPass((v) => !v)}
|
||||||
|
className="absolute right-8 top-1/2 -translate-y-1/2 text-slate-500"
|
||||||
|
>
|
||||||
|
{showPass ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
<Lock
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-500"
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.contrasena && (
|
||||||
|
<p className="mt-1 text-xs text-red-600">
|
||||||
|
{errors.contrasena}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* NO SOY UN ROBOT */}
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNotRobot((v) => !v)}
|
||||||
|
className={`h-5 w-5 rounded border flex items-center justify-center ${
|
||||||
|
notRobot
|
||||||
|
? "bg-blue-600 border-blue-600 text-white"
|
||||||
|
: "bg-white border-slate-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{notRobot && <Check size={14} />}
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-slate-700">No soy un robot</span>
|
||||||
|
<span className="ml-auto text-xs text-slate-400">
|
||||||
|
reCAPTCHA
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errors.robot && (
|
||||||
|
<p className="text-xs text-red-600">{errors.robot}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botón */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="mt-2 w-full rounded-full bg-gradient-to-r from-[#4c5f9e] to-[#566bb8] py-2.5 text-white shadow-md flex items-center justify-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin" size={18} />
|
||||||
|
Entrando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Iniciar sesión"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
384
src/pages/concentrators/ConcentratorsModal.tsx
Normal file
384
src/pages/concentrators/ConcentratorsModal.tsx
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
// src/pages/concentrators/ConcentratorsModal.tsx
|
||||||
|
import type React from "react";
|
||||||
|
import type { Concentrator } from "../../api/concentrators";
|
||||||
|
import type { GatewayData } from "./ConcentratorsPage";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
editingSerial: string | null;
|
||||||
|
|
||||||
|
form: Omit<Concentrator, "id">;
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<Omit<Concentrator, "id">>>;
|
||||||
|
|
||||||
|
gatewayForm: GatewayData;
|
||||||
|
setGatewayForm: React.Dispatch<React.SetStateAction<GatewayData>>;
|
||||||
|
|
||||||
|
errors: Record<string, boolean>;
|
||||||
|
setErrors: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||||
|
|
||||||
|
toDatetimeLocalValue: (value?: string) => string;
|
||||||
|
fromDatetimeLocalValue: (value: string) => string;
|
||||||
|
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ConcentratorsModal({
|
||||||
|
editingSerial,
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
gatewayForm,
|
||||||
|
setGatewayForm,
|
||||||
|
errors,
|
||||||
|
setErrors,
|
||||||
|
toDatetimeLocalValue,
|
||||||
|
fromDatetimeLocalValue,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: Props) {
|
||||||
|
const title = editingSerial ? "Edit Concentrator" : "Add Concentrator";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-xl p-6 w-[700px] max-h-[90vh] overflow-y-auto space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
|
|
||||||
|
{/* FORM */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
|
||||||
|
Concentrator Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-gray-50"
|
||||||
|
placeholder="Area Name"
|
||||||
|
value={form["Area Name"] ?? ""}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
El proyecto seleccionado define el Area Name.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["Device S/N"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="Device S/N *"
|
||||||
|
value={form["Device S/N"]}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, "Device S/N": e.target.value });
|
||||||
|
if (errors["Device S/N"])
|
||||||
|
setErrors({ ...errors, "Device S/N": false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["Device S/N"] && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
This field is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["Device Name"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="Device Name *"
|
||||||
|
value={form["Device Name"]}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, "Device Name": e.target.value });
|
||||||
|
if (errors["Device Name"])
|
||||||
|
setErrors({ ...errors, "Device Name": false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["Device Name"] && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
This field is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
value={form["Device Status"]}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
"Device Status": e.target.value as any,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="ACTIVE">ACTIVE</option>
|
||||||
|
<option value="INACTIVE">INACTIVE</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["Operator"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="Operator *"
|
||||||
|
value={form["Operator"]}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, Operator: e.target.value });
|
||||||
|
if (errors["Operator"])
|
||||||
|
setErrors({ ...errors, Operator: false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["Operator"] && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
This field is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["Installed Time"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
value={(form["Installed Time"] ?? "").slice(0, 10)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, "Installed Time": e.target.value });
|
||||||
|
if (errors["Installed Time"])
|
||||||
|
setErrors({ ...errors, "Installed Time": false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["Installed Time"] && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
This field is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["Device Time"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
value={toDatetimeLocalValue(form["Device Time"])}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
"Device Time": fromDatetimeLocalValue(e.target.value),
|
||||||
|
});
|
||||||
|
if (errors["Device Time"])
|
||||||
|
setErrors({ ...errors, "Device Time": false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["Device Time"] && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
This field is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["Communication Time"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
value={toDatetimeLocalValue(form["Communication Time"])}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
"Communication Time": fromDatetimeLocalValue(e.target.value),
|
||||||
|
});
|
||||||
|
if (errors["Communication Time"])
|
||||||
|
setErrors({ ...errors, "Communication Time": false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["Communication Time"] && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
This field is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["Instruction Manual"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="Instruction Manual *"
|
||||||
|
value={form["Instruction Manual"]}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, "Instruction Manual": e.target.value });
|
||||||
|
if (errors["Instruction Manual"])
|
||||||
|
setErrors({ ...errors, "Instruction Manual": false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["Instruction Manual"] && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
This field is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GATEWAY */}
|
||||||
|
<div className="space-y-3 pt-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
|
||||||
|
Gateway Configuration
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["Gateway ID"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="Gateway ID *"
|
||||||
|
value={gatewayForm["Gateway ID"] || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
setGatewayForm({
|
||||||
|
...gatewayForm,
|
||||||
|
"Gateway ID": parseInt(e.target.value) || 0,
|
||||||
|
});
|
||||||
|
if (errors["Gateway ID"])
|
||||||
|
setErrors({ ...errors, "Gateway ID": false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
{errors["Gateway ID"] && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
This field is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["Gateway EUI"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="Gateway EUI *"
|
||||||
|
value={gatewayForm["Gateway EUI"]}
|
||||||
|
onChange={(e) => {
|
||||||
|
setGatewayForm({
|
||||||
|
...gatewayForm,
|
||||||
|
"Gateway EUI": e.target.value,
|
||||||
|
});
|
||||||
|
if (errors["Gateway EUI"])
|
||||||
|
setErrors({ ...errors, "Gateway EUI": false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["Gateway EUI"] && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
This field is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["Gateway Name"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="Gateway Name *"
|
||||||
|
value={gatewayForm["Gateway Name"]}
|
||||||
|
onChange={(e) => {
|
||||||
|
setGatewayForm({
|
||||||
|
...gatewayForm,
|
||||||
|
"Gateway Name": e.target.value,
|
||||||
|
});
|
||||||
|
if (errors["Gateway Name"])
|
||||||
|
setErrors({ ...errors, "Gateway Name": false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["Gateway Name"] && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
This field is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
value={gatewayForm["Antenna Placement"]}
|
||||||
|
onChange={(e) =>
|
||||||
|
setGatewayForm({
|
||||||
|
...gatewayForm,
|
||||||
|
"Antenna Placement": e.target.value as "Indoor" | "Outdoor",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="Indoor">Indoor</option>
|
||||||
|
<option value="Outdoor">Outdoor</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["Gateway Description"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="Gateway Description *"
|
||||||
|
value={gatewayForm["Gateway Description"]}
|
||||||
|
onChange={(e) => {
|
||||||
|
setGatewayForm({
|
||||||
|
...gatewayForm,
|
||||||
|
"Gateway Description": e.target.value,
|
||||||
|
});
|
||||||
|
if (errors["Gateway Description"])
|
||||||
|
setErrors({ ...errors, "Gateway Description": false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["Gateway Description"] && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
This field is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ACTIONS */}
|
||||||
|
<div className="flex justify-end gap-2 pt-3 border-t">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
232
src/pages/concentrators/ConcentratorsSidebar.tsx
Normal file
232
src/pages/concentrators/ConcentratorsSidebar.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
// src/pages/concentrators/ConcentratorsSidebar.tsx
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { ChevronDown, Check, RefreshCcw } from "lucide-react";
|
||||||
|
import type { ProjectCard, SampleView } from "./ConcentratorsPage";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
loadingProjects: boolean;
|
||||||
|
|
||||||
|
sampleView: SampleView;
|
||||||
|
sampleViewLabel: string;
|
||||||
|
|
||||||
|
// ✅ ahora lo controla el Page
|
||||||
|
typesMenuOpen: boolean;
|
||||||
|
setTypesMenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
|
||||||
|
onChangeSampleView: (next: SampleView) => void;
|
||||||
|
|
||||||
|
selectedProject: string;
|
||||||
|
onSelectProject: (name: string) => void;
|
||||||
|
|
||||||
|
// ✅ el Page manda projects={c.projectsData}
|
||||||
|
projects: ProjectCard[];
|
||||||
|
|
||||||
|
onRefresh: () => void;
|
||||||
|
refreshDisabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ConcentratorsSidebar({
|
||||||
|
loadingProjects,
|
||||||
|
sampleView,
|
||||||
|
sampleViewLabel,
|
||||||
|
typesMenuOpen,
|
||||||
|
setTypesMenuOpen,
|
||||||
|
onChangeSampleView,
|
||||||
|
selectedProject,
|
||||||
|
onSelectProject,
|
||||||
|
projects,
|
||||||
|
onRefresh,
|
||||||
|
refreshDisabled,
|
||||||
|
}: Props) {
|
||||||
|
const options = useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
{ key: "GENERAL", label: "General" },
|
||||||
|
{ key: "LORA", label: "LoRa" },
|
||||||
|
{ key: "LORAWAN", label: "LoRaWAN" },
|
||||||
|
{ key: "GRANDES", label: "Grandes consumidores" },
|
||||||
|
] as Array<{ key: SampleView; label: string }>,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
Tipo: <span className="font-semibold">{sampleViewLabel}</span>
|
||||||
|
{" • "}
|
||||||
|
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={onRefresh}
|
||||||
|
disabled={loadingProjects || refreshDisabled}
|
||||||
|
title="Actualizar"
|
||||||
|
>
|
||||||
|
<RefreshCcw size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tipos de tomas (dropdown) */}
|
||||||
|
<div className="mt-4 relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTypesMenuOpen((v) => !v)}
|
||||||
|
className="w-full inline-flex items-center justify-between rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-700 shadow-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
Tipos de tomas
|
||||||
|
<span className="text-xs font-semibold text-gray-500">
|
||||||
|
({sampleViewLabel})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`${typesMenuOpen ? "rotate-180" : ""} transition`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{typesMenuOpen && (
|
||||||
|
<div className="absolute z-50 mt-2 w-full rounded-xl border border-gray-200 bg-white shadow-lg overflow-hidden">
|
||||||
|
{options.map((opt) => {
|
||||||
|
const active = sampleView === opt.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChangeSampleView(opt.key)}
|
||||||
|
className={[
|
||||||
|
"w-full px-3 py-2 text-left text-sm flex items-center justify-between hover:bg-gray-50",
|
||||||
|
active ? "bg-blue-50/60" : "bg-white",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`font-semibold ${
|
||||||
|
active ? "text-blue-700" : "text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
{active && <Check size={16} className="text-blue-700" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="mt-4 overflow-y-auto flex-1 space-y-3 pr-1">
|
||||||
|
{loadingProjects && sampleView === "GENERAL" ? (
|
||||||
|
<div className="text-sm text-gray-500">Loading projects...</div>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
No projects available. Please contact your administrator.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
projects.map((p) => {
|
||||||
|
const active = p.name === selectedProject;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.name}
|
||||||
|
onClick={() => onSelectProject(p.name)}
|
||||||
|
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">Concentradores</span>
|
||||||
|
<span className="font-medium text-gray-800">
|
||||||
|
{p.concentrators}
|
||||||
|
</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) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectProject(p.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/pages/concentrators/ConcentratorsTable.tsx
Normal file
86
src/pages/concentrators/ConcentratorsTable.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// src/pages/concentrators/ConcentratorsTable.tsx
|
||||||
|
import MaterialTable from "@material-table/core";
|
||||||
|
import type { Concentrator } from "../../api/concentrators";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isLoading: boolean; // ✅ ahora se llama así (como en Page)
|
||||||
|
data: Concentrator[];
|
||||||
|
activeRowId?: string;
|
||||||
|
onRowClick: (row: Concentrator) => void;
|
||||||
|
emptyMessage: string; // ✅ mensaje ya viene resuelto desde Page
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ConcentratorsTable({
|
||||||
|
isLoading,
|
||||||
|
data,
|
||||||
|
activeRowId,
|
||||||
|
onRowClick,
|
||||||
|
emptyMessage,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<MaterialTable
|
||||||
|
title="Concentrators"
|
||||||
|
isLoading={isLoading}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: "Device Name",
|
||||||
|
field: "Device Name",
|
||||||
|
render: (rowData: any) => rowData["Device Name"] || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Device S/N",
|
||||||
|
field: "Device S/N",
|
||||||
|
render: (rowData: any) => rowData["Device S/N"] || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Device Status",
|
||||||
|
field: "Device Status",
|
||||||
|
render: (rowData: any) => (
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
|
||||||
|
rowData["Device Status"] === "ACTIVE"
|
||||||
|
? "text-blue-600 border-blue-600"
|
||||||
|
: "text-red-600 border-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{rowData["Device Status"] || "-"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Operator",
|
||||||
|
field: "Operator",
|
||||||
|
render: (rowData: any) => rowData["Operator"] || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Area Name",
|
||||||
|
field: "Area Name",
|
||||||
|
render: (rowData: any) => rowData["Area Name"] || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Installed Time",
|
||||||
|
field: "Installed Time",
|
||||||
|
type: "date",
|
||||||
|
render: (rowData: any) => rowData["Installed Time"] || "-",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={data}
|
||||||
|
onRowClick={(_, rowData) => onRowClick(rowData as Concentrator)}
|
||||||
|
options={{
|
||||||
|
actionsColumnIndex: -1,
|
||||||
|
search: false,
|
||||||
|
paging: true,
|
||||||
|
sorting: true,
|
||||||
|
rowStyle: (rowData) => ({
|
||||||
|
backgroundColor:
|
||||||
|
activeRowId === (rowData as Concentrator).id
|
||||||
|
? "#EEF2FF"
|
||||||
|
: "#FFFFFF",
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
localization={{
|
||||||
|
body: { emptyDataSourceMessage: emptyMessage },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
src/pages/concentrators/useConcentrators.ts
Normal file
255
src/pages/concentrators/useConcentrators.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
fetchConcentrators,
|
||||||
|
type Concentrator,
|
||||||
|
} from "../../api/concentrators";
|
||||||
|
import type { ProjectCard, SampleView } from "./ConcentratorsPage";
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
role: "SUPER_ADMIN" | "USER";
|
||||||
|
project?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useConcentrators(currentUser: User) {
|
||||||
|
const [sampleView, setSampleView] = useState<SampleView>("GENERAL");
|
||||||
|
|
||||||
|
const [loadingProjects, setLoadingProjects] = useState(true);
|
||||||
|
const [loadingConcentrators, setLoadingConcentrators] = useState(true);
|
||||||
|
|
||||||
|
const [allProjects, setAllProjects] = useState<string[]>([]);
|
||||||
|
const [selectedProject, setSelectedProject] = useState("");
|
||||||
|
|
||||||
|
const [concentrators, setConcentrators] = useState<Concentrator[]>([]);
|
||||||
|
const [filteredConcentrators, setFilteredConcentrators] = useState<
|
||||||
|
Concentrator[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const isGeneral = sampleView === "GENERAL";
|
||||||
|
|
||||||
|
const sampleViewLabel = useMemo(() => {
|
||||||
|
switch (sampleView) {
|
||||||
|
case "GENERAL":
|
||||||
|
return "General";
|
||||||
|
case "LORA":
|
||||||
|
return "LoRa";
|
||||||
|
case "LORAWAN":
|
||||||
|
return "LoRaWAN";
|
||||||
|
case "GRANDES":
|
||||||
|
return "Grandes consumidores";
|
||||||
|
default:
|
||||||
|
return "General";
|
||||||
|
}
|
||||||
|
}, [sampleView]);
|
||||||
|
|
||||||
|
const visibleProjects = useMemo(
|
||||||
|
() =>
|
||||||
|
currentUser.role === "SUPER_ADMIN"
|
||||||
|
? allProjects
|
||||||
|
: currentUser.project
|
||||||
|
? [currentUser.project]
|
||||||
|
: [],
|
||||||
|
[allProjects, currentUser.role, currentUser.project]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadConcentrators = async () => {
|
||||||
|
if (!isGeneral) return;
|
||||||
|
|
||||||
|
setLoadingConcentrators(true);
|
||||||
|
setLoadingProjects(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await fetchConcentrators();
|
||||||
|
|
||||||
|
const normalized = raw.map((c: any) => {
|
||||||
|
const preferredName =
|
||||||
|
c["Device Alias"] ||
|
||||||
|
c["Device Label"] ||
|
||||||
|
c["Device Display Name"] ||
|
||||||
|
c.deviceName ||
|
||||||
|
c.name ||
|
||||||
|
c["Device Name"] ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
"Device Name": preferredName,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectsArray = [
|
||||||
|
...new Set(normalized.map((r: any) => r["Area Name"])),
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
setAllProjects(projectsArray);
|
||||||
|
setConcentrators(normalized);
|
||||||
|
|
||||||
|
setSelectedProject((prev) => {
|
||||||
|
if (prev) return prev;
|
||||||
|
if (currentUser.role !== "SUPER_ADMIN" && currentUser.project) {
|
||||||
|
return currentUser.project;
|
||||||
|
}
|
||||||
|
return projectsArray[0] ?? "";
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading concentrators:", err);
|
||||||
|
setAllProjects([]);
|
||||||
|
setConcentrators([]);
|
||||||
|
setSelectedProject("");
|
||||||
|
} finally {
|
||||||
|
setLoadingConcentrators(false);
|
||||||
|
setLoadingProjects(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// init
|
||||||
|
useEffect(() => {
|
||||||
|
loadConcentrators();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// view changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isGeneral) {
|
||||||
|
loadConcentrators();
|
||||||
|
} else {
|
||||||
|
setLoadingProjects(false);
|
||||||
|
setLoadingConcentrators(false);
|
||||||
|
setSelectedProject("");
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [sampleView]);
|
||||||
|
|
||||||
|
// auto select single visible project
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isGeneral) return;
|
||||||
|
if (!selectedProject && visibleProjects.length === 1) {
|
||||||
|
setSelectedProject(visibleProjects[0]);
|
||||||
|
}
|
||||||
|
}, [visibleProjects, selectedProject, isGeneral]);
|
||||||
|
|
||||||
|
// filter by project
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isGeneral) {
|
||||||
|
setFilteredConcentrators([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedProject) {
|
||||||
|
setFilteredConcentrators(
|
||||||
|
concentrators.filter((c) => c["Area Name"] === selectedProject)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setFilteredConcentrators(concentrators);
|
||||||
|
}
|
||||||
|
}, [selectedProject, concentrators, isGeneral]);
|
||||||
|
|
||||||
|
// sidebar cards (general)
|
||||||
|
const projectsDataGeneral: ProjectCard[] = useMemo(() => {
|
||||||
|
const counts = concentrators.reduce<Record<string, number>>((acc, c) => {
|
||||||
|
const area = c["Area Name"] ?? "SIN PROYECTO";
|
||||||
|
acc[area] = (acc[area] ?? 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const baseRegion = "Baja California";
|
||||||
|
const baseContact = "Operaciones";
|
||||||
|
const baseLastSync = "Hace 1 h";
|
||||||
|
|
||||||
|
return visibleProjects.map((name) => ({
|
||||||
|
name,
|
||||||
|
region: baseRegion,
|
||||||
|
projects: 1,
|
||||||
|
concentrators: counts[name] ?? 0,
|
||||||
|
activeAlerts: 0,
|
||||||
|
lastSync: baseLastSync,
|
||||||
|
contact: baseContact,
|
||||||
|
status: "ACTIVO",
|
||||||
|
}));
|
||||||
|
}, [concentrators, visibleProjects]);
|
||||||
|
|
||||||
|
// sidebar cards (mock)
|
||||||
|
const projectsDataMock: Record<Exclude<SampleView, "GENERAL">, ProjectCard[]> =
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
LORA: [
|
||||||
|
{
|
||||||
|
name: "LoRa - Zona Centro",
|
||||||
|
region: "Baja California",
|
||||||
|
projects: 1,
|
||||||
|
concentrators: 12,
|
||||||
|
activeAlerts: 1,
|
||||||
|
lastSync: "Hace 15 min",
|
||||||
|
contact: "Operaciones",
|
||||||
|
status: "ACTIVO",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LoRa - Zona Este",
|
||||||
|
region: "Baja California",
|
||||||
|
projects: 1,
|
||||||
|
concentrators: 8,
|
||||||
|
activeAlerts: 0,
|
||||||
|
lastSync: "Hace 40 min",
|
||||||
|
contact: "Operaciones",
|
||||||
|
status: "ACTIVO",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
LORAWAN: [
|
||||||
|
{
|
||||||
|
name: "LoRaWAN - Industrial",
|
||||||
|
region: "Baja California",
|
||||||
|
projects: 1,
|
||||||
|
concentrators: 5,
|
||||||
|
activeAlerts: 0,
|
||||||
|
lastSync: "Hace 1 h",
|
||||||
|
contact: "Operaciones",
|
||||||
|
status: "ACTIVO",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
GRANDES: [
|
||||||
|
{
|
||||||
|
name: "Grandes - Convenios",
|
||||||
|
region: "Baja California",
|
||||||
|
projects: 1,
|
||||||
|
concentrators: 3,
|
||||||
|
activeAlerts: 0,
|
||||||
|
lastSync: "Hace 2 h",
|
||||||
|
contact: "Operaciones",
|
||||||
|
status: "ACTIVO",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectsData: ProjectCard[] = useMemo(() => {
|
||||||
|
if (isGeneral) return projectsDataGeneral;
|
||||||
|
return projectsDataMock[sampleView as Exclude<SampleView, "GENERAL">];
|
||||||
|
}, [isGeneral, projectsDataGeneral, projectsDataMock, sampleView]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// view
|
||||||
|
sampleView,
|
||||||
|
setSampleView,
|
||||||
|
sampleViewLabel,
|
||||||
|
isGeneral,
|
||||||
|
|
||||||
|
// loading
|
||||||
|
loadingProjects,
|
||||||
|
loadingConcentrators,
|
||||||
|
|
||||||
|
// projects
|
||||||
|
allProjects,
|
||||||
|
visibleProjects,
|
||||||
|
projectsData,
|
||||||
|
selectedProject,
|
||||||
|
setSelectedProject,
|
||||||
|
|
||||||
|
// data
|
||||||
|
concentrators,
|
||||||
|
setConcentrators,
|
||||||
|
filteredConcentrators,
|
||||||
|
|
||||||
|
// actions
|
||||||
|
loadConcentrators,
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
269
src/pages/meters/MetersModal.tsx
Normal file
269
src/pages/meters/MetersModal.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import type { Meter } from "../../api/meters";
|
||||||
|
import type { DeviceData } from "./MeterPage";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
editingId: string | null;
|
||||||
|
|
||||||
|
form: Omit<Meter, "id">;
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<Omit<Meter, "id">>>;
|
||||||
|
|
||||||
|
deviceForm: DeviceData;
|
||||||
|
setDeviceForm: React.Dispatch<React.SetStateAction<DeviceData>>;
|
||||||
|
|
||||||
|
errors: Record<string, boolean>;
|
||||||
|
setErrors: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||||
|
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MetersModal({
|
||||||
|
editingId,
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
deviceForm,
|
||||||
|
setDeviceForm,
|
||||||
|
errors,
|
||||||
|
setErrors,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: Props) {
|
||||||
|
const title = editingId ? "Edit Meter" : "Add Meter";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-xl p-6 w-[700px] max-h-[90vh] overflow-y-auto space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
|
|
||||||
|
{/* FORM */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
|
||||||
|
Meter Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["areaName"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="Area Name *"
|
||||||
|
value={form.areaName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, areaName: e.target.value });
|
||||||
|
if (errors["areaName"]) setErrors({ ...errors, areaName: false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["areaName"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="Account Number (optional)"
|
||||||
|
value={form.accountNumber ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, accountNumber: e.target.value || null })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="User Name (optional)"
|
||||||
|
value={form.userName ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, userName: e.target.value || null })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="User Address (optional)"
|
||||||
|
value={form.userAddress ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, userAddress: e.target.value || null })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["meterSerialNumber"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="Meter S/N *"
|
||||||
|
value={form.meterSerialNumber}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, meterSerialNumber: e.target.value });
|
||||||
|
if (errors["meterSerialNumber"])
|
||||||
|
setErrors({ ...errors, meterSerialNumber: false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["meterSerialNumber"] && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">This field is required</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["meterName"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="Meter Name *"
|
||||||
|
value={form.meterName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, meterName: e.target.value });
|
||||||
|
if (errors["meterName"]) setErrors({ ...errors, meterName: false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["meterName"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["protocolType"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="Protocol Type *"
|
||||||
|
value={form.protocolType}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, protocolType: e.target.value });
|
||||||
|
if (errors["protocolType"]) setErrors({ ...errors, protocolType: false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["protocolType"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="Device ID (optional)"
|
||||||
|
value={form.deviceId ?? ""}
|
||||||
|
onChange={(e) => setForm({ ...form, deviceId: e.target.value || "" })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["deviceName"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="Device Name *"
|
||||||
|
value={form.deviceName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, deviceName: e.target.value });
|
||||||
|
if (errors["deviceName"]) setErrors({ ...errors, deviceName: false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["deviceName"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DEVICE CONFIG */}
|
||||||
|
<div className="space-y-3 pt-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 border-b pb-2">
|
||||||
|
Device Configuration
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["Device ID"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="Device ID *"
|
||||||
|
value={deviceForm["Device ID"] || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDeviceForm({ ...deviceForm, "Device ID": parseInt(e.target.value) || 0 });
|
||||||
|
if (errors["Device ID"]) setErrors({ ...errors, "Device ID": false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
{errors["Device ID"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["Device EUI"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="Device EUI *"
|
||||||
|
value={deviceForm["Device EUI"]}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDeviceForm({ ...deviceForm, "Device EUI": e.target.value });
|
||||||
|
if (errors["Device EUI"]) setErrors({ ...errors, "Device EUI": false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["Device EUI"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["Join EUI"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="Join EUI *"
|
||||||
|
value={deviceForm["Join EUI"]}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDeviceForm({ ...deviceForm, "Join EUI": e.target.value });
|
||||||
|
if (errors["Join EUI"]) setErrors({ ...errors, "Join EUI": false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["Join EUI"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
className={`w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
errors["AppKey"] ? "border-red-500" : ""
|
||||||
|
}`}
|
||||||
|
placeholder="AppKey *"
|
||||||
|
value={deviceForm["AppKey"]}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDeviceForm({ ...deviceForm, AppKey: e.target.value });
|
||||||
|
if (errors["AppKey"]) setErrors({ ...errors, AppKey: false });
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{errors["AppKey"] && <p className="text-red-500 text-xs mt-1">This field is required</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ACTIONS */}
|
||||||
|
<div className="flex justify-end gap-2 pt-3 border-t">
|
||||||
|
<button onClick={onClose} className="px-4 py-2 rounded hover:bg-gray-100">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
293
src/pages/meters/MetersSidebar.tsx
Normal file
293
src/pages/meters/MetersSidebar.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
// src/pages/meters/MetersSidebar.tsx
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { ChevronDown, RefreshCcw, Check } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import type { ProjectCard, TakeType } from "./MeterPage";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
loadingProjects: boolean;
|
||||||
|
|
||||||
|
takeType: TakeType;
|
||||||
|
setTakeType: (t: TakeType) => void;
|
||||||
|
|
||||||
|
selectedProject: string;
|
||||||
|
setSelectedProject: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
|
||||||
|
isMockMode: boolean;
|
||||||
|
projects: ProjectCard[];
|
||||||
|
|
||||||
|
onRefresh: () => void;
|
||||||
|
refreshDisabled?: boolean;
|
||||||
|
|
||||||
|
allProjects: string[];
|
||||||
|
onResetSelection?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TakeTypeOption = { key: TakeType; label: string };
|
||||||
|
|
||||||
|
const TAKE_TYPE_OPTIONS: TakeTypeOption[] = [
|
||||||
|
{ key: "GENERAL", label: "General" },
|
||||||
|
{ key: "LORA", label: "LoRa" },
|
||||||
|
{ key: "LORAWAN", label: "LoRaWAN" },
|
||||||
|
{ key: "GRANDES", label: "Grandes consumidores" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MetersSidebar({
|
||||||
|
loadingProjects,
|
||||||
|
takeType,
|
||||||
|
setTakeType,
|
||||||
|
selectedProject,
|
||||||
|
setSelectedProject,
|
||||||
|
isMockMode,
|
||||||
|
projects,
|
||||||
|
onRefresh,
|
||||||
|
refreshDisabled,
|
||||||
|
allProjects,
|
||||||
|
onResetSelection,
|
||||||
|
}: Props) {
|
||||||
|
const [typesMenuOpen, setTypesMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
// para detectar click fuera (igual a tu implementación)
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onClickOutside = (e: MouseEvent) => {
|
||||||
|
if (!menuRef.current) return;
|
||||||
|
if (!menuRef.current.contains(e.target as Node)) {
|
||||||
|
setTypesMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", onClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const takeTypeLabel = useMemo(
|
||||||
|
() => TAKE_TYPE_OPTIONS.find((o) => o.key === takeType)?.label ?? "General",
|
||||||
|
[takeType]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
Tipo: <span className="font-semibold">{takeTypeLabel}</span>
|
||||||
|
{" • "}
|
||||||
|
Seleccionado:{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{selectedProject || (isMockMode ? "— (modo demo)" : "—")}
|
||||||
|
</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={onRefresh}
|
||||||
|
disabled={loadingProjects || !!refreshDisabled}
|
||||||
|
title="Actualizar"
|
||||||
|
>
|
||||||
|
<RefreshCcw size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ✅ Tipos de tomas (dropdown) — mismo UI que Concentrators */}
|
||||||
|
<div className="mt-4 relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTypesMenuOpen((v) => !v)}
|
||||||
|
disabled={loadingProjects}
|
||||||
|
className="w-full inline-flex items-center justify-between rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-700 shadow-sm hover:bg-gray-50 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
Tipos de tomas
|
||||||
|
<span className="text-xs font-semibold text-gray-500">
|
||||||
|
({takeTypeLabel})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`${typesMenuOpen ? "rotate-180" : ""} transition`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{typesMenuOpen && (
|
||||||
|
<div className="absolute z-50 mt-2 w-full rounded-xl border border-gray-200 bg-white shadow-lg overflow-hidden">
|
||||||
|
{TAKE_TYPE_OPTIONS.map((opt) => {
|
||||||
|
const active = takeType === opt.key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTakeType(opt.key);
|
||||||
|
setTypesMenuOpen(false);
|
||||||
|
|
||||||
|
// Reset selection/search desde el parent
|
||||||
|
onResetSelection?.();
|
||||||
|
|
||||||
|
if (opt.key !== "GENERAL") {
|
||||||
|
// mock mode -> limpia selección real
|
||||||
|
setSelectedProject("");
|
||||||
|
} else {
|
||||||
|
// vuelve a GENERAL -> autoselecciona real si no hay
|
||||||
|
setSelectedProject((prev) => prev || allProjects[0] || "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
"w-full px-3 py-2 text-left text-sm flex items-center justify-between hover:bg-gray-50",
|
||||||
|
active ? "bg-blue-50/60" : "bg-white",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`font-semibold ${
|
||||||
|
active ? "text-blue-700" : "text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{active && <Check size={16} className="text-blue-700" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<div className="text-sm text-gray-500 text-center py-10">
|
||||||
|
{isMockMode
|
||||||
|
? "No hay datos demo para este tipo."
|
||||||
|
: "No se encontraron proyectos."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
projects.map((p) => {
|
||||||
|
const active = !isMockMode && p.name === selectedProject;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.name}
|
||||||
|
onClick={() => {
|
||||||
|
if (isMockMode) return;
|
||||||
|
setSelectedProject(p.name);
|
||||||
|
onResetSelection?.();
|
||||||
|
}}
|
||||||
|
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",
|
||||||
|
isMockMode ? "opacity-90" : "",
|
||||||
|
].join(" ")}
|
||||||
|
title={
|
||||||
|
isMockMode
|
||||||
|
? "Modo demo: estas tarjetas son mocks"
|
||||||
|
: "Seleccionar proyecto"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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",
|
||||||
|
isMockMode ? "opacity-50 cursor-not-allowed" : "",
|
||||||
|
].join(" ")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isMockMode) return;
|
||||||
|
setSelectedProject(p.name);
|
||||||
|
onResetSelection?.();
|
||||||
|
}}
|
||||||
|
disabled={isMockMode}
|
||||||
|
>
|
||||||
|
{isMockMode
|
||||||
|
? "Demo"
|
||||||
|
: 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/pages/meters/MetersTable.tsx
Normal file
67
src/pages/meters/MetersTable.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import MaterialTable from "@material-table/core";
|
||||||
|
import type { Meter } from "../../api/meters";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: Meter[];
|
||||||
|
isLoading: boolean;
|
||||||
|
|
||||||
|
isMockMode: boolean;
|
||||||
|
selectedProject: string;
|
||||||
|
|
||||||
|
activeMeter: Meter | null;
|
||||||
|
onRowClick: (row: Meter) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MetersTable({
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isMockMode,
|
||||||
|
selectedProject,
|
||||||
|
activeMeter,
|
||||||
|
onRowClick,
|
||||||
|
}: Props) {
|
||||||
|
const disabled = isMockMode || !selectedProject;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={disabled ? "opacity-60 pointer-events-none" : ""}>
|
||||||
|
<MaterialTable
|
||||||
|
title="Meters"
|
||||||
|
isLoading={isLoading}
|
||||||
|
columns={[
|
||||||
|
{ title: "Area Name", field: "areaName", render: (r: any) => r.areaName || "-" },
|
||||||
|
{ title: "Account Number", field: "accountNumber", render: (r: any) => r.accountNumber || "-" },
|
||||||
|
{ title: "User Name", field: "userName", render: (r: any) => r.userName || "-" },
|
||||||
|
{ title: "User Address", field: "userAddress", render: (r: any) => r.userAddress || "-" },
|
||||||
|
{ title: "Meter S/N", field: "meterSerialNumber", render: (r: any) => r.meterSerialNumber || "-" },
|
||||||
|
{ title: "Meter Name", field: "meterName", render: (r: any) => r.meterName || "-" },
|
||||||
|
{ title: "Protocol Type", field: "protocolType", render: (r: any) => r.protocolType || "-" },
|
||||||
|
{ title: "Device ID", field: "deviceId", render: (r: any) => r.deviceId || "-" },
|
||||||
|
{ title: "Device Name", field: "deviceName", render: (r: any) => r.deviceName || "-" },
|
||||||
|
]}
|
||||||
|
data={disabled ? [] : data}
|
||||||
|
onRowClick={(_, rowData) => onRowClick(rowData as Meter)}
|
||||||
|
options={{
|
||||||
|
actionsColumnIndex: -1,
|
||||||
|
search: false,
|
||||||
|
paging: true,
|
||||||
|
sorting: true,
|
||||||
|
rowStyle: (rowData) => ({
|
||||||
|
backgroundColor:
|
||||||
|
activeMeter?.id === (rowData as Meter).id ? "#EEF2FF" : "#FFFFFF",
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
localization={{
|
||||||
|
body: {
|
||||||
|
emptyDataSourceMessage: isMockMode
|
||||||
|
? "Modo demo: selecciona 'General' para ver datos reales."
|
||||||
|
: !selectedProject
|
||||||
|
? "Select a project to view meters."
|
||||||
|
: isLoading
|
||||||
|
? "Loading meters..."
|
||||||
|
: "No meters found. Click 'Add' to create your first meter.",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/pages/meters/useMeters.ts
Normal file
94
src/pages/meters/useMeters.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { fetchMeters, type Meter } from "../../api/meters";
|
||||||
|
|
||||||
|
type UseMetersArgs = {
|
||||||
|
initialProject?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useMeters({ initialProject }: UseMetersArgs) {
|
||||||
|
const [allProjects, setAllProjects] = useState<string[]>([]);
|
||||||
|
const [loadingProjects, setLoadingProjects] = useState(true);
|
||||||
|
|
||||||
|
const [selectedProject, setSelectedProject] = useState(initialProject || "");
|
||||||
|
|
||||||
|
const [meters, setMeters] = useState<Meter[]>([]);
|
||||||
|
const [filteredMeters, setFilteredMeters] = useState<Meter[]>([]);
|
||||||
|
const [loadingMeters, setLoadingMeters] = useState(true);
|
||||||
|
|
||||||
|
const loadMeters = async () => {
|
||||||
|
setLoadingMeters(true);
|
||||||
|
setLoadingProjects(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchMeters();
|
||||||
|
|
||||||
|
const projectsArray = [...new Set(data.map((r) => r.areaName))]
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
|
||||||
|
setAllProjects(projectsArray);
|
||||||
|
setMeters(data);
|
||||||
|
|
||||||
|
setSelectedProject((prev) => {
|
||||||
|
if (prev) return prev;
|
||||||
|
if (initialProject) return initialProject;
|
||||||
|
return projectsArray[0] ?? "";
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading meters:", error);
|
||||||
|
setAllProjects([]);
|
||||||
|
setMeters([]);
|
||||||
|
setSelectedProject("");
|
||||||
|
} finally {
|
||||||
|
setLoadingMeters(false);
|
||||||
|
setLoadingProjects(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// init
|
||||||
|
useEffect(() => {
|
||||||
|
loadMeters();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// keep selectedProject synced if parent changes initialProject
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialProject) setSelectedProject(initialProject);
|
||||||
|
}, [initialProject]);
|
||||||
|
|
||||||
|
// filter by project
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProject) {
|
||||||
|
setFilteredMeters([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFilteredMeters(meters.filter((m) => m.areaName === selectedProject));
|
||||||
|
}, [selectedProject, meters]);
|
||||||
|
|
||||||
|
const projectsCounts = useMemo(() => {
|
||||||
|
return meters.reduce<Record<string, number>>((acc, m) => {
|
||||||
|
const area = m.areaName ?? "SIN PROYECTO";
|
||||||
|
acc[area] = (acc[area] ?? 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}, [meters]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// loading
|
||||||
|
loadingProjects,
|
||||||
|
loadingMeters,
|
||||||
|
|
||||||
|
// projects
|
||||||
|
allProjects,
|
||||||
|
projectsCounts,
|
||||||
|
selectedProject,
|
||||||
|
setSelectedProject,
|
||||||
|
|
||||||
|
// data
|
||||||
|
meters,
|
||||||
|
setMeters,
|
||||||
|
filteredMeters,
|
||||||
|
|
||||||
|
// actions
|
||||||
|
loadMeters,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,13 +11,13 @@ export default defineConfig({
|
|||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
host: '127.0.0.1',
|
host: true,
|
||||||
port: 5173,
|
port: 5173,
|
||||||
allowedHosts: [
|
allowedHosts: [
|
||||||
"localhost",
|
"localhost",
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
"reyna-compressive-shaunna.ngrok-free.dev",
|
"reyna-compressive-shaunna.ngrok-free.dev",
|
||||||
"https://sistema.gestionrecursoshidricos.com/"
|
"sistema.gestionrecursoshidricos.com"
|
||||||
],
|
],
|
||||||
// proxy:{
|
// proxy:{
|
||||||
// '/api':{
|
// '/api':{
|
||||||
|
|||||||
Reference in New Issue
Block a user