Compare commits

..

10 Commits

Author SHA1 Message Date
Exteban08
2b5735d78d Agregar documentacion completa del proyecto
- README.md actualizado con descripcion detallada del sistema
- DOCUMENTATION.md con documentacion tecnica exhaustiva

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 04:58:02 +00:00
Exteban08
143cd77cee Conflicts 2026-01-14 21:40:17 +00:00
Esteban González
c63e6085bd Merge pull request #9 from luanngel/DevMarlene
Refactor Concentrators & Meters – modularización y tipos de tomas
2026-01-14 15:36:29 -06:00
Marlene-Angel
9af06addad Ajustes UI 2026-01-14 12:34:12 -08:00
Marlene-Angel
16f1f68499 Refactor meters: dividido en hook, sidebar, tabla y modal 2026-01-14 12:32:23 -08:00
Marlene-Angel
3681725b8f Refactor concentrators: dividido en hook, sidebar, tabla y modal 2026-01-14 12:30:08 -08:00
Exteban08
48df8a2bfd Merge branch 'main' of github.com:luanngel/water-project 2026-01-13 04:05:42 +00:00
IvanAS94
f8c75c3c9c Merge pull request #8 from luanngel/DevMarlene
Login GRH, marca de agua tipo membretado y logo en PNG sin fondo
2026-01-12 18:20:34 -08:00
Marlene-Angel
dd3997a3a8 Login GRH, marca de agua tipo membretado y logo en PNG sin fondo 2026-01-12 13:45:25 -08:00
Exteban08
59067b387c Vite config 2026-01-08 19:43:25 +00:00
22 changed files with 3507 additions and 1829 deletions

611
DOCUMENTATION.md Normal file
View 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
View File

@@ -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.

View File

@@ -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>

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -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;

View File

@@ -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}
@@ -263,10 +270,9 @@ export default function ProfileModal({
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2", "focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
loading ? "opacity-60 cursor-not-allowed" : "", loading ? "opacity-60 cursor-not-allowed" : "",
].join(" ")} ].join(" ")}
> >
{loading ? "Guardando..." : "Guardar"} {loading ? "Guardando..." : "Guardar"}
</button> </button>
</div> </div>
</div> </div>
</div> </div>

View 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>
);
}

View File

@@ -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
View 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>
);
}

View 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

View 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>
);
}

View 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 },
}}
/>
);
}

View 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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
};
}

View File

@@ -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':{