Add 3-level role permissions, organismos operadores, and Histórico de Tomas page

Implements the full ADMIN → ORGANISMO_OPERADOR → OPERATOR permission hierarchy
with scope-filtered data access across all backend services. Adds organismos
operadores management (ADMIN only) and a new Histórico page for viewing
per-meter reading history with chart, consumption stats, and CSV export.

Key changes:
- Backend: 3-level scope filtering on all services (meters, readings, projects, users)
- Backend: Protect GET /meters routes with authenticateToken for role-based filtering
- Backend: Pass requestingUser to reading service for scoped meter readings
- Frontend: New HistoricoPage with meter selector, AreaChart, paginated table
- Frontend: Consumption cards (Actual, Pasado, Diferencial) above date filters
- Frontend: Meter search by name, serial, location, CESPT account, cadastral key
- Frontend: OrganismosPage, updated Sidebar with 3-level visibility
- SQL migrations for organismos_operadores table and FK columns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Exteban08
2026-02-09 10:21:33 +00:00
parent 61dafa83ac
commit 613fb2d787
43 changed files with 3049 additions and 324 deletions

View File

@@ -16,6 +16,8 @@ import TTSPage from "./pages/conectores/TTSPage";
import AnalyticsMapPage from "./pages/analytics/AnalyticsMapPage"; import AnalyticsMapPage from "./pages/analytics/AnalyticsMapPage";
import AnalyticsReportsPage from "./pages/analytics/AnalyticsReportsPage"; import AnalyticsReportsPage from "./pages/analytics/AnalyticsReportsPage";
import AnalyticsServerPage from "./pages/analytics/AnalyticsServerPage"; import AnalyticsServerPage from "./pages/analytics/AnalyticsServerPage";
import OrganismosPage from "./pages/OrganismosPage";
import HistoricoPage from "./pages/historico/HistoricoPage";
import ProfileModal from "./components/layout/common/ProfileModal"; import ProfileModal from "./components/layout/common/ProfileModal";
import { updateMyProfile } from "./api/me"; import { updateMyProfile } from "./api/me";
@@ -52,7 +54,9 @@ export type Page =
| "tts" | "tts"
| "analytics-map" | "analytics-map"
| "analytics-reports" | "analytics-reports"
| "analytics-server"; | "analytics-server"
| "organismos"
| "historico";
export default function App() { export default function App() {
const [isAuth, setIsAuth] = useState<boolean>(false); const [isAuth, setIsAuth] = useState<boolean>(false);
@@ -207,6 +211,10 @@ export default function App() {
return <AnalyticsReportsPage />; return <AnalyticsReportsPage />;
case "analytics-server": case "analytics-server":
return <AnalyticsServerPage />; return <AnalyticsServerPage />;
case "organismos":
return <OrganismosPage />;
case "historico":
return <HistoricoPage />;
case "home": case "home":
default: default:
return ( return (

View File

@@ -35,6 +35,8 @@ export interface AuthUser {
name: string; name: string;
role: string; role: string;
projectId?: string | null; projectId?: string | null;
organismoOperadorId?: string | null;
organismoName?: string | null;
avatar_url?: string; avatar_url?: string;
} }
@@ -43,6 +45,7 @@ export interface JwtPayload {
roleId: string; roleId: string;
roleName: string; roleName: string;
projectId?: string | null; projectId?: string | null;
organismoOperadorId?: string | null;
exp?: number; exp?: number;
iat?: number; iat?: number;
} }
@@ -396,3 +399,37 @@ export function isCurrentUserAdmin(): boolean {
const role = getCurrentUserRole(); const role = getCurrentUserRole();
return role?.toUpperCase() === 'ADMIN'; return role?.toUpperCase() === 'ADMIN';
} }
/**
* Get current user's organismo operador ID from JWT token
* @returns The organismo operador ID or null
*/
export function getCurrentUserOrganismoId(): string | null {
const token = getAccessToken();
if (!token) return null;
try {
const payload = parseJwtPayload(token) as JwtPayload | null;
return payload?.organismoOperadorId || null;
} catch {
return null;
}
}
/**
* Check if current user is an Organismo Operador
* @returns boolean indicating if user is organismo operador
*/
export function isCurrentUserOrganismo(): boolean {
const role = getCurrentUserRole();
return role?.toUpperCase() === 'ORGANISMO_OPERADOR';
}
/**
* Check if current user is an Operador (OPERATOR)
* @returns boolean indicating if user is operador
*/
export function isCurrentUserOperador(): boolean {
const role = getCurrentUserRole();
return role?.toUpperCase() === 'OPERATOR';
}

View File

@@ -68,6 +68,9 @@ export interface Meter {
manufacturer?: string | null; manufacturer?: string | null;
latitude?: number | null; latitude?: number | null;
longitude?: number | null; longitude?: number | null;
address?: string | null;
cesptAccount?: string | null;
cadastralKey?: string | null;
} }
/** /**
@@ -97,19 +100,47 @@ export interface MeterInput {
manufacturer?: string; manufacturer?: string;
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
address?: string;
cesptAccount?: string;
cadastralKey?: string;
} }
/** /**
* Meter reading entity * Meter reading entity (from /api/meters/:id/readings)
*/ */
export interface MeterReading { export interface MeterReading {
id: string; id: string;
meterId: string; meterId: string;
value: number; readingValue: number;
unit: string;
readingType: string; readingType: string;
readAt: string; batteryLevel: number | null;
signalStrength: number | null;
receivedAt: string;
createdAt: string; createdAt: string;
meterSerialNumber: string;
meterName: string;
meterLocation: string | null;
concentratorId: string;
concentratorName: string;
projectId: string;
projectName: string;
}
export interface MeterReadingFilters {
startDate?: string;
endDate?: string;
page?: number;
pageSize?: number;
}
export interface PaginatedMeterReadings {
data: MeterReading[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
} }
/** /**
@@ -163,6 +194,9 @@ export async function createMeter(data: MeterInput): Promise<Meter> {
type: data.type, type: data.type,
status: data.status, status: data.status,
installation_date: data.installationDate, installation_date: data.installationDate,
address: data.address,
cespt_account: data.cesptAccount,
cadastral_key: data.cadastralKey,
}; };
const response = await apiClient.post<Record<string, unknown>>('/api/meters', backendData); const response = await apiClient.post<Record<string, unknown>>('/api/meters', backendData);
return transformKeys<Meter>(response); return transformKeys<Meter>(response);
@@ -185,6 +219,9 @@ export async function updateMeter(id: string, data: Partial<MeterInput>): Promis
if (data.type !== undefined) backendData.type = data.type; if (data.type !== undefined) backendData.type = data.type;
if (data.status !== undefined) backendData.status = data.status; if (data.status !== undefined) backendData.status = data.status;
if (data.installationDate !== undefined) backendData.installation_date = data.installationDate; if (data.installationDate !== undefined) backendData.installation_date = data.installationDate;
if (data.address !== undefined) backendData.address = data.address;
if (data.cesptAccount !== undefined) backendData.cespt_account = data.cesptAccount;
if (data.cadastralKey !== undefined) backendData.cadastral_key = data.cadastralKey;
const response = await apiClient.patch<Record<string, unknown>>(`/api/meters/${id}`, backendData); const response = await apiClient.patch<Record<string, unknown>>(`/api/meters/${id}`, backendData);
return transformKeys<Meter>(response); return transformKeys<Meter>(response);
@@ -200,12 +237,24 @@ export async function deleteMeter(id: string): Promise<void> {
} }
/** /**
* Fetch readings for a specific meter * Fetch readings for a specific meter with pagination and date filters
* @param id - The meter ID * @param id - The meter ID
* @returns Promise resolving to an array of meter readings * @param filters - Optional pagination and date filters
* @returns Promise resolving to paginated meter readings
*/ */
export async function fetchMeterReadings(id: string): Promise<MeterReading[]> { export async function fetchMeterReadings(id: string, filters?: MeterReadingFilters): Promise<PaginatedMeterReadings> {
return apiClient.get<MeterReading[]>(`/api/meters/${id}/readings`); const params: Record<string, string> = {};
if (filters?.startDate) params.start_date = filters.startDate;
if (filters?.endDate) params.end_date = filters.endDate;
if (filters?.page) params.page = String(filters.page);
if (filters?.pageSize) params.pageSize = String(filters.pageSize);
const response = await apiClient.get<{ data: Record<string, unknown>[]; pagination: { page: number; pageSize: number; total: number; totalPages: number } }>(`/api/meters/${id}/readings`, { params });
return {
data: transformArray<MeterReading>(response.data),
pagination: response.pagination,
};
} }
/** /**

99
src/api/organismos.ts Normal file
View File

@@ -0,0 +1,99 @@
/**
* Organismos Operadores API
* Handles all organismo-related API requests
*/
import { apiClient } from './client';
export interface OrganismoOperador {
id: string;
name: string;
description: string | null;
region: string | null;
contact_name: string | null;
contact_email: string | null;
is_active: boolean;
project_count: number;
user_count: number;
created_at: string;
updated_at: string;
}
export interface CreateOrganismoInput {
name: string;
description?: string;
region?: string;
contact_name?: string;
contact_email?: string;
is_active?: boolean;
}
export interface UpdateOrganismoInput {
name?: string;
description?: string;
region?: string;
contact_name?: string;
contact_email?: string;
is_active?: boolean;
}
export interface OrganismoListResponse {
data: OrganismoOperador[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
export interface OrganismoProject {
id: string;
name: string;
status: string;
}
/**
* Get all organismos operadores
*/
export async function getAllOrganismos(params?: {
page?: number;
pageSize?: number;
}): Promise<OrganismoListResponse> {
return apiClient.get<OrganismoListResponse>('/api/organismos-operadores', { params });
}
/**
* Get a single organismo by ID
*/
export async function getOrganismoById(id: string): Promise<OrganismoOperador> {
return apiClient.get<OrganismoOperador>(`/api/organismos-operadores/${id}`);
}
/**
* Get projects belonging to an organismo
*/
export async function getOrganismoProjects(id: string): Promise<OrganismoProject[]> {
return apiClient.get<OrganismoProject[]>(`/api/organismos-operadores/${id}/projects`);
}
/**
* Create a new organismo operador
*/
export async function createOrganismo(data: CreateOrganismoInput): Promise<OrganismoOperador> {
return apiClient.post<OrganismoOperador>('/api/organismos-operadores', data);
}
/**
* Update an organismo operador
*/
export async function updateOrganismo(id: string, data: UpdateOrganismoInput): Promise<OrganismoOperador> {
return apiClient.put<OrganismoOperador>(`/api/organismos-operadores/${id}`, data);
}
/**
* Delete an organismo operador
*/
export async function deleteOrganismo(id: string): Promise<void> {
return apiClient.delete<void>(`/api/organismos-operadores/${id}`);
}

View File

@@ -41,6 +41,7 @@ export interface Project {
location: string | null; location: string | null;
status: string; status: string;
meterTypeId: string | null; meterTypeId: string | null;
organismoOperadorId: string | null;
createdBy: string; createdBy: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -56,6 +57,7 @@ export interface ProjectInput {
location?: string; location?: string;
status?: string; status?: string;
meterTypeId?: string | null; meterTypeId?: string | null;
organismoOperadorId?: string | null;
} }
/** /**
@@ -97,6 +99,7 @@ export async function createProject(data: ProjectInput): Promise<Project> {
location: data.location, location: data.location,
status: data.status, status: data.status,
meter_type_id: data.meterTypeId, meter_type_id: data.meterTypeId,
organismo_operador_id: data.organismoOperadorId,
}; };
const response = await apiClient.post<Record<string, unknown>>('/api/projects', backendData); const response = await apiClient.post<Record<string, unknown>>('/api/projects', backendData);
return transformKeys<Project>(response); return transformKeys<Project>(response);
@@ -116,6 +119,7 @@ export async function updateProject(id: string, data: Partial<ProjectInput>): Pr
if (data.location !== undefined) backendData.location = data.location; if (data.location !== undefined) backendData.location = data.location;
if (data.status !== undefined) backendData.status = data.status; if (data.status !== undefined) backendData.status = data.status;
if (data.meterTypeId !== undefined) backendData.meter_type_id = data.meterTypeId; if (data.meterTypeId !== undefined) backendData.meter_type_id = data.meterTypeId;
if (data.organismoOperadorId !== undefined) backendData.organismo_operador_id = data.organismoOperadorId;
const response = await apiClient.patch<Record<string, unknown>>(`/api/projects/${id}`, backendData); const response = await apiClient.patch<Record<string, unknown>>(`/api/projects/${id}`, backendData);
return transformKeys<Project>(response); return transformKeys<Project>(response);

View File

@@ -18,8 +18,15 @@ export interface User {
permissions: Record<string, Record<string, boolean>>; permissions: Record<string, Record<string, boolean>>;
}; };
project_id: string | null; project_id: string | null;
organismo_operador_id: string | null;
organismo_name: string | null;
is_active: boolean; is_active: boolean;
last_login: string | null; last_login: string | null;
phone: string | null;
street: string | null;
city: string | null;
state: string | null;
zip_code: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -30,7 +37,13 @@ export interface CreateUserInput {
name: string; name: string;
role_id: string; role_id: string;
project_id?: string | null; project_id?: string | null;
organismo_operador_id?: string | null;
is_active?: boolean; is_active?: boolean;
phone?: string | null;
street?: string | null;
city?: string | null;
state?: string | null;
zip_code?: string | null;
} }
export interface UpdateUserInput { export interface UpdateUserInput {
@@ -38,7 +51,13 @@ export interface UpdateUserInput {
name?: string; name?: string;
role_id?: string; role_id?: string;
project_id?: string | null; project_id?: string | null;
organismo_operador_id?: string | null;
is_active?: boolean; is_active?: boolean;
phone?: string | null;
street?: string | null;
city?: string | null;
state?: string | null;
zip_code?: string | null;
} }
export interface ChangePasswordInput { export interface ChangePasswordInput {

View File

@@ -8,6 +8,7 @@ import {
People, People,
Cable, Cable,
BarChart, BarChart,
Business,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { Page } from "../../App"; import { Page } from "../../App";
import { getCurrentUserRole } from "../../api/auth"; import { getCurrentUserRole } from "../../api/auth";
@@ -25,7 +26,9 @@ export default function Sidebar({ setPage }: SidebarProps) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const userRole = useMemo(() => getCurrentUserRole(), []); const userRole = useMemo(() => getCurrentUserRole(), []);
const isOperator = userRole?.toUpperCase() === 'OPERATOR'; const isAdmin = userRole?.toUpperCase() === 'ADMIN';
const isOrganismo = userRole?.toUpperCase() === 'ORGANISMO_OPERADOR';
const isOperador = userRole?.toUpperCase() === 'OPERATOR';
const isExpanded = pinned || hovered; const isExpanded = pinned || hovered;
@@ -57,7 +60,7 @@ export default function Sidebar({ setPage }: SidebarProps) {
{/* MENU */} {/* MENU */}
<div className="flex-1 py-4 px-2 overflow-y-auto"> <div className="flex-1 py-4 px-2 overflow-y-auto">
<ul className="space-y-1 text-white text-sm"> <ul className="space-y-1 text-white text-sm">
{/* DASHBOARD */} {/* DASHBOARD - visible to all */}
<li> <li>
<button <button
onClick={() => setPage("home")} onClick={() => setPage("home")}
@@ -68,7 +71,7 @@ export default function Sidebar({ setPage }: SidebarProps) {
</button> </button>
</li> </li>
{/* PROJECT MANAGEMENT */} {/* PROJECT MANAGEMENT - visible to all */}
<li> <li>
<button <button
onClick={() => isExpanded && setSystemOpen(!systemOpen)} onClick={() => isExpanded && setSystemOpen(!systemOpen)}
@@ -123,7 +126,17 @@ export default function Sidebar({ setPage }: SidebarProps) {
</button> </button>
</li> </li>
{!isOperator && ( <li>
<button
onClick={() => setPage("historico")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
Histórico
</button>
</li>
{/* Auditoria - ADMIN only */}
{isAdmin && (
<li> <li>
<button <button
onClick={() => setPage("auditoria")} onClick={() => setPage("auditoria")}
@@ -137,7 +150,8 @@ export default function Sidebar({ setPage }: SidebarProps) {
)} )}
</li> </li>
{!isOperator && ( {/* USERS MANAGEMENT - ADMIN and ORGANISMO_OPERADOR */}
{(isAdmin || isOrganismo) && (
<li> <li>
<button <button
onClick={() => isExpanded && setUsersOpen(!usersOpen)} onClick={() => isExpanded && setUsersOpen(!usersOpen)}
@@ -164,21 +178,37 @@ export default function Sidebar({ setPage }: SidebarProps) {
Users Users
</button> </button>
</li> </li>
<li> {/* Roles - ADMIN only */}
<button {isAdmin && (
onClick={() => setPage("roles")} <li>
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10" <button
> onClick={() => setPage("roles")}
Roles className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
</button> >
</li> Roles
</button>
</li>
)}
</ul> </ul>
)} )}
</li> </li>
)} )}
{/* CONECTORES */} {/* ORGANISMOS OPERADORES - ADMIN only */}
{!isOperator && ( {isAdmin && (
<li>
<button
onClick={() => setPage("organismos")}
className="flex items-center w-full px-2 py-2 rounded-md hover:bg-white/10 font-bold"
>
<Business className="w-5 h-5 shrink-0" />
{isExpanded && <span className="ml-3">Organismos Operadores</span>}
</button>
</li>
)}
{/* CONECTORES - ADMIN only */}
{isAdmin && (
<li> <li>
<button <button
onClick={() => isExpanded && setConectoresOpen(!conectoresOpen)} onClick={() => isExpanded && setConectoresOpen(!conectoresOpen)}
@@ -226,8 +256,8 @@ export default function Sidebar({ setPage }: SidebarProps) {
</li> </li>
)} )}
{/* ANALYTICS - ADMIN ONLY */} {/* ANALYTICS - ADMIN and ORGANISMO_OPERADOR */}
{!isOperator && ( {(isAdmin || isOrganismo) && (
<li> <li>
<button <button
onClick={() => isExpanded && setAnalyticsOpen(!analyticsOpen)} onClick={() => isExpanded && setAnalyticsOpen(!analyticsOpen)}

View File

@@ -12,29 +12,14 @@ import {
import { fetchMeters, type Meter } from "../api/meters"; import { fetchMeters, type Meter } from "../api/meters";
import { getAuditLogs, type AuditLog } from "../api/audit"; import { getAuditLogs, type AuditLog } from "../api/audit";
import { fetchNotifications, type Notification } from "../api/notifications"; import { fetchNotifications, type Notification } from "../api/notifications";
import { getAllUsers, type User } from "../api/users";
import { fetchProjects, type Project } from "../api/projects"; import { fetchProjects, type Project } from "../api/projects";
import { getCurrentUserRole, getCurrentUserProjectId } from "../api/auth"; import { getCurrentUserRole, getCurrentUserProjectId, getCurrentUserOrganismoId } from "../api/auth";
import { getAllOrganismos, type OrganismoOperador } from "../api/organismos";
import type { Page } from "../App"; import type { Page } from "../App";
import grhWatermark from "../assets/images/grhWatermark.png"; import grhWatermark from "../assets/images/grhWatermark.png";
/* ================= TYPES ================= */ /* ================= TYPES ================= */
type OrganismStatus = "ACTIVO" | "INACTIVO";
type Organism = {
id: string;
name: string;
region: string;
projects: number;
meters: number;
activeAlerts: number;
lastSync: string;
contact: string;
status: OrganismStatus;
projectId: string | null;
};
type AlertItem = { company: string; type: string; time: string }; type AlertItem = { company: string; type: string; time: string };
type HistoryItem = { type HistoryItem = {
@@ -56,8 +41,10 @@ export default function Home({
const userRole = useMemo(() => getCurrentUserRole(), []); const userRole = useMemo(() => getCurrentUserRole(), []);
const userProjectId = useMemo(() => getCurrentUserProjectId(), []); const userProjectId = useMemo(() => getCurrentUserProjectId(), []);
const userOrganismoId = useMemo(() => getCurrentUserOrganismoId(), []);
const isOperator = userRole?.toUpperCase() === 'OPERATOR'; const isOperator = userRole?.toUpperCase() === 'OPERATOR';
const isAdmin = userRole?.toUpperCase() === 'ADMIN'; const isAdmin = userRole?.toUpperCase() === 'ADMIN';
const isOrganismo = userRole?.toUpperCase() === 'ORGANISMO_OPERADOR';
/* ================= METERS ================= */ /* ================= METERS ================= */
@@ -93,56 +80,36 @@ export default function Home({
loadProjects(); loadProjects();
}, []); }, []);
const [users, setUsers] = useState<User[]>([]); const [organismos, setOrganismos] = useState<OrganismoOperador[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false); const [loadingOrganismos, setLoadingOrganismos] = useState(false);
const [selectedOrganism, setSelectedOrganism] = useState<string>("Todos"); const [selectedOrganism, setSelectedOrganism] = useState<string>(() => {
// ORGANISMO_OPERADOR: auto-filter to their organismo
if (userOrganismoId) return userOrganismoId;
return "Todos";
});
const [showOrganisms, setShowOrganisms] = useState(false); const [showOrganisms, setShowOrganisms] = useState(false);
const [organismQuery, setOrganismQuery] = useState(""); const [organismQuery, setOrganismQuery] = useState("");
const loadUsers = async () => { const loadOrganismos = async () => {
setLoadingUsers(true); setLoadingOrganismos(true);
try { try {
const response = await getAllUsers({ is_active: true }); const response = await getAllOrganismos({ pageSize: 100 });
setUsers(response.data); setOrganismos(response.data);
} catch (err) { } catch (err) {
console.error("Error loading users:", err); console.error("Error loading organismos:", err);
setUsers([]); setOrganismos([]);
} finally { } finally {
setLoadingUsers(false); setLoadingOrganismos(false);
} }
}; };
useEffect(() => { useEffect(() => {
if (!isOperator) { if (isAdmin || isOrganismo) {
loadUsers(); loadOrganismos();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const organismsData: Organism[] = useMemo(() => {
return users.map(user => {
const userMeters = user.project_id
? meters.filter(m => m.projectId === user.project_id).length
: 0;
const userProjects = user.project_id ? 1 : 0;
return {
id: user.id,
name: user.name,
region: user.email,
projects: userProjects,
meters: userMeters,
activeAlerts: 0,
lastSync: user.last_login ? `Último acceso: ${new Date(user.last_login).toLocaleDateString()}` : "Nunca",
contact: user.role?.name || "N/A",
status: user.is_active ? "ACTIVO" : "INACTIVO",
projectId: user.project_id,
};
});
}, [users, meters]);
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]); const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false); const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
@@ -160,7 +127,7 @@ export default function Home({
}; };
useEffect(() => { useEffect(() => {
if (!isOperator) { if (isAdmin) {
loadAuditLogs(); loadAuditLogs();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -171,70 +138,52 @@ export default function Home({
if (isOperator && userProjectId) { if (isOperator && userProjectId) {
return meters.filter((m) => m.projectId === userProjectId); return meters.filter((m) => m.projectId === userProjectId);
} }
// For ORGANISMO_OPERADOR, filter by projects that belong to their organismo
if (isOrganismo && userOrganismoId) {
const orgProjects = projects.filter(p => p.organismoOperadorId === userOrganismoId);
const orgProjectIds = new Set(orgProjects.map(p => p.id));
return meters.filter((m) => m.projectId && orgProjectIds.has(m.projectId));
}
// For ADMIN users with organism selector // For ADMIN users with organism selector
if (selectedOrganism === "Todos") { if (selectedOrganism === "Todos") {
return meters; return meters;
} }
const selectedUser = users.find(u => u.id === selectedOrganism); // ADMIN selected a specific organismo - filter by that organismo's projects
if (!selectedUser || !selectedUser.project_id) { const orgProjects = projects.filter(p => p.organismoOperadorId === selectedOrganism);
return []; const orgProjectIds = new Set(orgProjects.map(p => p.id));
} return meters.filter((m) => m.projectId && orgProjectIds.has(m.projectId));
}, [meters, selectedOrganism, projects, isOperator, isOrganismo, userProjectId, userOrganismoId]);
return meters.filter((m) => m.projectId === selectedUser.project_id);
}, [meters, selectedOrganism, users, isOperator, userProjectId]);
const filteredProjects = useMemo( const filteredProjects = useMemo(
() => [...new Set(filteredMeters.map((m) => m.projectName))].filter(Boolean) as string[], () => [...new Set(filteredMeters.map((m) => m.projectName))].filter(Boolean) as string[],
[filteredMeters] [filteredMeters]
); );
const selectedUserProjectName = useMemo(() => { const selectedOrganismoName = useMemo(() => {
// If user is OPERATOR, get their project name
if (isOperator && userProjectId) {
const project = projects.find(p => p.id === userProjectId);
return project?.name || null;
}
// For ADMIN users with organism selector
if (selectedOrganism === "Todos") return null; if (selectedOrganism === "Todos") return null;
const org = organismos.find(o => o.id === selectedOrganism);
const selectedUser = users.find(u => u.id === selectedOrganism); return org?.name || null;
if (!selectedUser || !selectedUser.project_id) return null; }, [selectedOrganism, organismos]);
const project = projects.find(p => p.id === selectedUser.project_id);
return project?.name || null;
}, [selectedOrganism, users, projects, isOperator, userProjectId]);
const chartData = useMemo(() => { const chartData = useMemo(() => {
// If user is OPERATOR, show only their project // If user is OPERATOR, show only their project
if (isOperator && selectedUserProjectName) { if (isOperator && userProjectId) {
const project = projects.find(p => p.id === userProjectId);
return [{ return [{
name: selectedUserProjectName, name: project?.name || "Mi Proyecto",
meterCount: filteredMeters.length, meterCount: filteredMeters.length,
}]; }];
} }
// For ADMIN users // Show meters grouped by project name
if (selectedOrganism === "Todos") { return filteredProjects.map((projectName) => ({
return filteredProjects.map((projectName) => ({ name: projectName,
name: projectName, meterCount: filteredMeters.filter((m) => m.projectName === projectName).length,
meterCount: filteredMeters.filter((m) => m.projectName === projectName).length, }));
})); }, [filteredProjects, filteredMeters, isOperator, userProjectId, projects]);
}
if (selectedUserProjectName) {
const meterCount = filteredMeters.length;
return [{
name: selectedUserProjectName,
meterCount: meterCount,
}];
}
return [];
}, [selectedOrganism, filteredProjects, filteredMeters, selectedUserProjectName, isOperator]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleBarClick = (data: any) => { const handleBarClick = (data: any) => {
@@ -247,9 +196,9 @@ export default function Home({
const filteredOrganisms = useMemo(() => { const filteredOrganisms = useMemo(() => {
const q = organismQuery.trim().toLowerCase(); const q = organismQuery.trim().toLowerCase();
if (!q) return organismsData; if (!q) return organismos;
return organismsData.filter((o) => o.name.toLowerCase().includes(q)); return organismos.filter((o) => o.name.toLowerCase().includes(q));
}, [organismQuery, organismsData]); }, [organismQuery, organismos]);
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const [loadingNotifications, setLoadingNotifications] = useState(false); const [loadingNotifications, setLoadingNotifications] = useState(false);
@@ -268,7 +217,7 @@ export default function Home({
}; };
useEffect(() => { useEffect(() => {
if (!isOperator) { if (isAdmin || isOrganismo) {
loadNotifications(); loadNotifications();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -443,7 +392,8 @@ export default function Home({
</div> </div>
</div> </div>
{isAdmin && ( {/* Organismo selector - ADMIN can pick any, ORGANISMO_OPERADOR sees their own */}
{(isAdmin || isOrganismo) && (
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-4"> <div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div>
@@ -453,21 +403,24 @@ export default function Home({
<span className="font-semibold dark:text-zinc-300"> <span className="font-semibold dark:text-zinc-300">
{selectedOrganism === "Todos" {selectedOrganism === "Todos"
? "Todos" ? "Todos"
: organismsData.find(o => o.id === selectedOrganism)?.name || "Ninguno"} : selectedOrganismoName || "Ninguno"}
</span> </span>
</p> </p>
</div> </div>
<button {/* Only ADMIN can change the selector */}
type="button" {isAdmin && (
className="inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-700 transition" <button
onClick={() => setShowOrganisms(true)} type="button"
> className="inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-700 transition"
Organismos Operadores onClick={() => setShowOrganisms(true)}
</button> >
Organismos Operadores
</button>
)}
</div> </div>
{showOrganisms && ( {showOrganisms && isAdmin && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Overlay */} {/* Overlay */}
<div <div
@@ -515,7 +468,7 @@ export default function Home({
{/* List */} {/* List */}
<div className="p-5 overflow-y-auto flex-1 space-y-3"> <div className="p-5 overflow-y-auto flex-1 space-y-3">
{loadingUsers ? ( {loadingOrganismos ? (
<div className="flex items-center justify-center py-10"> <div className="flex items-center justify-center py-10">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div> </div>
@@ -580,54 +533,54 @@ export default function Home({
<p className="text-sm font-semibold text-gray-800 dark:text-white"> <p className="text-sm font-semibold text-gray-800 dark:text-white">
{o.name} {o.name}
</p> </p>
<p className="text-xs text-gray-500 dark:text-zinc-400">{o.region}</p> <p className="text-xs text-gray-500 dark:text-zinc-400">{o.region || "-"}</p>
</div> </div>
<span <span
className={[ className={[
"text-xs font-semibold px-2 py-1 rounded-full", "text-xs font-semibold px-2 py-1 rounded-full",
o.status === "ACTIVO" o.is_active
? "bg-green-100 text-green-700" ? "bg-green-100 text-green-700"
: "bg-gray-200 text-gray-700", : "bg-gray-200 text-gray-700",
].join(" ")} ].join(" ")}
> >
{o.status} {o.is_active ? "ACTIVO" : "INACTIVO"}
</span> </span>
</div> </div>
<div className="mt-3 space-y-2 text-xs"> <div className="mt-3 space-y-2 text-xs">
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span className="text-gray-500 dark:text-zinc-400">Rol</span> <span className="text-gray-500 dark:text-zinc-400">Contacto</span>
<span className="font-medium text-gray-800 dark:text-zinc-200"> <span className="font-medium text-gray-800 dark:text-zinc-200">
{o.contact} {o.contact_name || "-"}
</span> </span>
</div> </div>
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span className="text-gray-500 dark:text-zinc-400">Email</span> <span className="text-gray-500 dark:text-zinc-400">Email</span>
<span className="font-medium text-gray-800 dark:text-zinc-200 truncate max-w-[200px]"> <span className="font-medium text-gray-800 dark:text-zinc-200 truncate max-w-[200px]">
{o.region} {o.contact_email || "-"}
</span> </span>
</div> </div>
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span className="text-gray-500 dark:text-zinc-400">Proyectos</span> <span className="text-gray-500 dark:text-zinc-400">Proyectos</span>
<span className="font-medium text-gray-800 dark:text-zinc-200"> <span className="font-medium text-gray-800 dark:text-zinc-200">
{o.projects} {o.project_count}
</span> </span>
</div> </div>
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span className="text-gray-500 dark:text-zinc-400">Medidores</span> <span className="text-gray-500 dark:text-zinc-400">Usuarios</span>
<span className="font-medium text-gray-800 dark:text-zinc-200"> <span className="font-medium text-gray-800 dark:text-zinc-200">
{o.meters} {o.user_count}
</span> </span>
</div> </div>
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span className="text-gray-500 dark:text-zinc-400">Último acceso</span> <span className="text-gray-500 dark:text-zinc-400">Región</span>
<span className="font-medium text-gray-800 dark:text-zinc-200"> <span className="font-medium text-gray-800 dark:text-zinc-200">
{o.lastSync} {o.region || "-"}
</span> </span>
</div> </div>
</div> </div>
@@ -656,7 +609,7 @@ export default function Home({
</> </>
)} )}
{!loadingUsers && filteredOrganisms.length === 0 && ( {!loadingOrganismos && filteredOrganisms.length === 0 && (
<div className="text-sm text-gray-500 dark:text-zinc-400 text-center py-10"> <div className="text-sm text-gray-500 dark:text-zinc-400 text-center py-10">
No se encontraron organismos. No se encontraron organismos.
</div> </div>
@@ -665,7 +618,7 @@ export default function Home({
{/* Footer */} {/* Footer */}
<div className="p-5 border-t dark:border-zinc-800 text-xs text-gray-500 dark:text-zinc-400"> <div className="p-5 border-t dark:border-zinc-800 text-xs text-gray-500 dark:text-zinc-400">
Mostrando {filteredOrganisms.length} organismo{filteredOrganisms.length !== 1 ? 's' : ''} de {users.length} total{users.length !== 1 ? 'es' : ''} Mostrando {filteredOrganisms.length} organismo{filteredOrganisms.length !== 1 ? 's' : ''} de {organismos.length} total{organismos.length !== 1 ? 'es' : ''}
</div> </div>
</div> </div>
</div> </div>
@@ -688,13 +641,11 @@ export default function Home({
{chartData.length === 0 && selectedOrganism !== "Todos" ? ( {chartData.length === 0 && selectedOrganism !== "Todos" ? (
<div className="h-60 flex flex-col items-center justify-center"> <div className="h-60 flex flex-col items-center justify-center">
<p className="text-sm text-gray-500 dark:text-zinc-400 mb-2"> <p className="text-sm text-gray-500 dark:text-zinc-400 mb-2">
{selectedUserProjectName Este organismo no tiene medidores registrados
? "Este organismo no tiene medidores registrados"
: "Este organismo no tiene un proyecto asignado"}
</p> </p>
{selectedUserProjectName && ( {selectedOrganismoName && (
<p className="text-xs text-gray-400 dark:text-zinc-500"> <p className="text-xs text-gray-400 dark:text-zinc-500">
Proyecto asignado: <span className="font-semibold dark:text-zinc-300">{selectedUserProjectName}</span> Organismo: <span className="font-semibold dark:text-zinc-300">{selectedOrganismoName}</span>
</p> </p>
)} )}
</div> </div>
@@ -720,12 +671,12 @@ export default function Home({
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
{selectedOrganism !== "Todos" && selectedUserProjectName && ( {selectedOrganism !== "Todos" && selectedOrganismoName && (
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-zinc-800"> <div className="mt-4 pt-4 border-t border-gray-200 dark:border-zinc-800">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<div> <div>
<span className="text-gray-500 dark:text-zinc-400">Proyecto del organismo:</span> <span className="text-gray-500 dark:text-zinc-400">Organismo:</span>
<span className="ml-2 font-semibold text-gray-800 dark:text-white">{selectedUserProjectName}</span> <span className="ml-2 font-semibold text-gray-800 dark:text-white">{selectedOrganismoName}</span>
</div> </div>
<div> <div>
<span className="text-gray-500 dark:text-zinc-400">Total de medidores:</span> <span className="text-gray-500 dark:text-zinc-400">Total de medidores:</span>
@@ -738,7 +689,7 @@ export default function Home({
)} )}
</div> </div>
{!isOperator && ( {isAdmin && (
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6"> <div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6">
<h2 className="text-lg font-semibold mb-4 dark:text-white">Historial Reciente de Auditoria</h2> <h2 className="text-lg font-semibold mb-4 dark:text-white">Historial Reciente de Auditoria</h2>
{loadingAuditLogs ? ( {loadingAuditLogs ? (
@@ -768,7 +719,7 @@ export default function Home({
</div> </div>
)} )}
{!isOperator && ( {(isAdmin || isOrganismo) && (
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6"> <div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6">
<h2 className="text-lg font-semibold mb-4 dark:text-white">Ultimas Alertas</h2> <h2 className="text-lg font-semibold mb-4 dark:text-white">Ultimas Alertas</h2>
{loadingNotifications ? ( {loadingNotifications ? (

View File

@@ -0,0 +1,372 @@
import { useState, useEffect } from "react";
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
import MaterialTable from "@material-table/core";
import {
getAllOrganismos,
createOrganismo,
updateOrganismo,
deleteOrganismo,
type OrganismoOperador,
type CreateOrganismoInput,
type UpdateOrganismoInput,
} from "../api/organismos";
interface OrganismoForm {
name: string;
description: string;
region: string;
contact_name: string;
contact_email: string;
is_active: boolean;
}
export default function OrganismosPage() {
const [organismos, setOrganismos] = useState<OrganismoOperador[]>([]);
const [activeOrganismo, setActiveOrganismo] = useState<OrganismoOperador | null>(null);
const [search, setSearch] = useState("");
const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const emptyForm: OrganismoForm = {
name: "",
description: "",
region: "",
contact_name: "",
contact_email: "",
is_active: true,
};
const [form, setForm] = useState<OrganismoForm>(emptyForm);
useEffect(() => {
loadOrganismos();
}, []);
const loadOrganismos = async () => {
try {
setLoading(true);
const response = await getAllOrganismos({ pageSize: 100 });
setOrganismos(response.data);
} catch (err) {
console.error("Failed to fetch organismos:", err);
setOrganismos([]);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setError(null);
if (!form.name) {
setError("El nombre es requerido");
return;
}
try {
setSaving(true);
if (editingId) {
const updateData: UpdateOrganismoInput = {
name: form.name,
description: form.description || undefined,
region: form.region || undefined,
contact_name: form.contact_name || undefined,
contact_email: form.contact_email || undefined,
is_active: form.is_active,
};
await updateOrganismo(editingId, updateData);
} else {
const createData: CreateOrganismoInput = {
name: form.name,
description: form.description || undefined,
region: form.region || undefined,
contact_name: form.contact_name || undefined,
contact_email: form.contact_email || undefined,
is_active: form.is_active,
};
await createOrganismo(createData);
}
await loadOrganismos();
setShowModal(false);
setEditingId(null);
setForm(emptyForm);
} catch (err) {
console.error("Failed to save organismo:", err);
setError(err instanceof Error ? err.message : "Failed to save organismo");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!activeOrganismo) return;
if (!window.confirm(`Are you sure you want to delete "${activeOrganismo.name}"?`)) {
return;
}
try {
setSaving(true);
await deleteOrganismo(activeOrganismo.id);
await loadOrganismos();
setActiveOrganismo(null);
} catch (err) {
console.error("Failed to delete organismo:", err);
alert(err instanceof Error ? err.message : "Failed to delete organismo");
} finally {
setSaving(false);
}
};
const handleOpenAddModal = () => {
setForm(emptyForm);
setEditingId(null);
setError(null);
setShowModal(true);
};
const handleOpenEditModal = (organismo: OrganismoOperador) => {
setEditingId(organismo.id);
setForm({
name: organismo.name,
description: organismo.description || "",
region: organismo.region || "",
contact_name: organismo.contact_name || "",
contact_email: organismo.contact_email || "",
is_active: organismo.is_active,
});
setError(null);
setShowModal(true);
};
const filtered = organismos.filter((o) =>
`${o.name} ${o.region || ""} ${o.description || ""}`
.toLowerCase()
.includes(search.toLowerCase())
);
return (
<div className="flex gap-6 p-6 w-full bg-gray-100 dark:bg-zinc-950">
<div className="flex-1 flex flex-col gap-6">
{/* HEADER */}
<div
className="rounded-xl shadow p-6 text-white flex justify-between items-center"
style={{ background: "linear-gradient(135deg, #4c5f9e, #2a355d, #566bb8)" }}
>
<div>
<h1 className="text-2xl font-bold">Organismos Operadores</h1>
<p className="text-sm text-blue-100">Gestión de organismos operadores del sistema</p>
</div>
<div className="flex gap-3">
<button
onClick={handleOpenAddModal}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg"
>
<Plus size={16} /> Agregar
</button>
<button
onClick={() => activeOrganismo && handleOpenEditModal(activeOrganismo)}
disabled={!activeOrganismo}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Pencil size={16} /> Editar
</button>
<button
onClick={handleDelete}
disabled={!activeOrganismo || saving}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Trash2 size={16} /> Eliminar
</button>
<button
onClick={loadOrganismos}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg"
disabled={loading}
>
<RefreshCcw size={16} /> Actualizar
</button>
</div>
</div>
{/* SEARCH */}
<input
className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 dark:text-zinc-100 rounded-lg shadow px-4 py-2 text-sm dark:placeholder-zinc-500"
placeholder="Buscar organismo..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{/* TABLE */}
<MaterialTable
title="Organismos Operadores"
isLoading={loading}
columns={[
{ title: "Nombre", field: "name" },
{ title: "Región", field: "region", render: (row: OrganismoOperador) => row.region || "-" },
{ title: "Contacto", field: "contact_name", render: (row: OrganismoOperador) => row.contact_name || "-" },
{ title: "Email", field: "contact_email", render: (row: OrganismoOperador) => row.contact_email || "-" },
{
title: "Proyectos",
field: "project_count",
render: (row: OrganismoOperador) => (
<span className="px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-700">
{row.project_count}
</span>
),
},
{
title: "Usuarios",
field: "user_count",
render: (row: OrganismoOperador) => (
<span className="px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-700">
{row.user_count}
</span>
),
},
{
title: "Estado",
field: "is_active",
render: (row: OrganismoOperador) => (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold border ${
row.is_active
? "text-blue-600 border-blue-600"
: "text-red-600 border-red-600"
}`}
>
{row.is_active ? "ACTIVO" : "INACTIVO"}
</span>
),
},
]}
data={filtered}
onRowClick={(_, rowData) => setActiveOrganismo(rowData as OrganismoOperador)}
options={{
search: false,
paging: true,
pageSize: 10,
pageSizeOptions: [10, 20, 50],
sorting: true,
rowStyle: (rowData) => ({
backgroundColor:
activeOrganismo?.id === (rowData as OrganismoOperador).id
? "#EEF2FF"
: "#FFFFFF",
}),
}}
localization={{
body: {
emptyDataSourceMessage: loading
? "Cargando organismos..."
: "No hay organismos. Haz clic en 'Agregar' para crear uno.",
},
}}
/>
</div>
{/* MODAL */}
{showModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-700 rounded-xl p-6 w-[450px] space-y-4">
<h2 className="text-lg font-semibold dark:text-white">
{editingId ? "Editar Organismo" : "Agregar Organismo"}
</h2>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Nombre *</label>
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Nombre del organismo"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
disabled={saving}
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Descripción</label>
<textarea
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Descripción (opcional)"
rows={2}
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
disabled={saving}
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Región</label>
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Región (opcional)"
value={form.region}
onChange={(e) => setForm({ ...form, region: e.target.value })}
disabled={saving}
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Nombre de contacto</label>
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Nombre de contacto (opcional)"
value={form.contact_name}
onChange={(e) => setForm({ ...form, contact_name: e.target.value })}
disabled={saving}
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Email de contacto</label>
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
type="email"
placeholder="Email de contacto (opcional)"
value={form.contact_email}
onChange={(e) => setForm({ ...form, contact_email: e.target.value })}
disabled={saving}
/>
</div>
<button
onClick={() => setForm({ ...form, is_active: !form.is_active })}
className="w-full border rounded px-3 py-2 dark:border-zinc-700 dark:text-zinc-100"
disabled={saving}
>
Estado: {form.is_active ? "ACTIVO" : "INACTIVO"}
</button>
<div className="flex justify-end gap-2 pt-3 border-t dark:border-zinc-700">
<button
onClick={() => setShowModal(false)}
className="px-4 py-2 rounded hover:bg-gray-100 dark:hover:bg-zinc-800 dark:text-zinc-300"
disabled={saving}
>
Cancelar
</button>
<button
onClick={handleSave}
className="bg-[#4c5f9e] text-white px-4 py-2 rounded disabled:opacity-50"
disabled={saving}
>
{saving ? "Guardando..." : "Guardar"}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,9 +1,11 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react"; import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
import MaterialTable from "@material-table/core"; import MaterialTable from "@material-table/core";
import { createUser, updateUser, deleteUser, getAllUsers, CreateUserInput, UpdateUserInput, User as ApiUser } from "../api/users"; import { createUser, updateUser, deleteUser, getAllUsers, CreateUserInput, UpdateUserInput, User as ApiUser } from "../api/users";
import { getAllRoles, Role as ApiRole } from "../api/roles"; import { getAllRoles, Role as ApiRole } from "../api/roles";
import { fetchProjects, type Project } from "../api/projects"; import { fetchProjects, type Project } from "../api/projects";
import { getAllOrganismos, getOrganismoProjects, type OrganismoOperador, type OrganismoProject } from "../api/organismos";
import { getCurrentUserRole, getCurrentUserOrganismoId } from "../api/auth";
interface RoleOption { interface RoleOption {
id: string; id: string;
@@ -17,6 +19,8 @@ interface User {
roleId: string; roleId: string;
roleName: string; roleName: string;
projectId: string | null; projectId: string | null;
organismoOperadorId: string | null;
organismoName: string | null;
status: "ACTIVE" | "INACTIVE"; status: "ACTIVE" | "INACTIVE";
createdAt: string; createdAt: string;
} }
@@ -27,31 +31,66 @@ interface UserForm {
password?: string; password?: string;
roleId: string; roleId: string;
projectId?: string; projectId?: string;
organismoOperadorId?: string;
status: "ACTIVE" | "INACTIVE"; status: "ACTIVE" | "INACTIVE";
createdAt: string; createdAt: string;
phone: string;
street: string;
city: string;
state: string;
zipCode: string;
} }
export default function UsersPage() { export default function UsersPage() {
const userRole = useMemo(() => getCurrentUserRole(), []);
const userOrganismoId = useMemo(() => getCurrentUserOrganismoId(), []);
const isAdmin = userRole?.toUpperCase() === 'ADMIN';
const isOrganismo = userRole?.toUpperCase() === 'ORGANISMO_OPERADOR';
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [activeUser, setActiveUser] = useState<User | null>(null); const [activeUser, setActiveUser] = useState<User | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [selectedRoleFilter, setSelectedRoleFilter] = useState<string>(""); // Filter state const [selectedRoleFilter, setSelectedRoleFilter] = useState<string>("");
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [roles, setRoles] = useState<RoleOption[]>([]); const [roles, setRoles] = useState<RoleOption[]>([]);
const [modalRoles, setModalRoles] = useState<ApiRole[]>([]); const [modalRoles, setModalRoles] = useState<ApiRole[]>([]);
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [organismos, setOrganismos] = useState<OrganismoOperador[]>([]);
const [organismoProjects, setOrganismoProjects] = useState<OrganismoProject[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true); const [loadingUsers, setLoadingUsers] = useState(true);
const [loadingModalRoles, setLoadingModalRoles] = useState(false); const [loadingModalRoles, setLoadingModalRoles] = useState(false);
const [loadingProjects, setLoadingProjects] = useState(false); const [loadingProjects, setLoadingProjects] = useState(false);
const [loadingOrganismos, setLoadingOrganismos] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const emptyUser: UserForm = { name: "", email: "", roleId: "", projectId: "", password: "", status: "ACTIVE", createdAt: new Date().toISOString().slice(0,10) }; const emptyUser: UserForm = { name: "", email: "", roleId: "", projectId: "", organismoOperadorId: "", password: "", status: "ACTIVE", createdAt: new Date().toISOString().slice(0,10), phone: "", street: "", city: "", state: "", zipCode: "" };
const [form, setForm] = useState<UserForm>(emptyUser); const [form, setForm] = useState<UserForm>(emptyUser);
const activeProjects = projects.filter(p => p.status === 'ACTIVE'); const activeProjects = projects.filter(p => p.status === 'ACTIVE');
// Determine selected role name from modal roles
const selectedRoleName = useMemo(() => {
return modalRoles.find(r => r.id === form.roleId)?.name || "";
}, [modalRoles, form.roleId]);
// For ORGANISMO_OPERADOR role: show organismo selector
// For OPERATOR role: show organismo + project selector
const showOrganismoSelector = selectedRoleName === "ORGANISMO_OPERADOR" || selectedRoleName === "OPERATOR";
const showProjectSelector = selectedRoleName === "OPERATOR";
// Projects filtered by selected organismo
const filteredProjectsForForm = useMemo(() => {
if (form.organismoOperadorId && organismoProjects.length > 0) {
return organismoProjects;
}
if (form.organismoOperadorId) {
return activeProjects.filter(() => false); // No projects loaded yet for this organismo
}
return activeProjects;
}, [form.organismoOperadorId, organismoProjects, activeProjects]);
useEffect(() => { useEffect(() => {
fetchUsers(); fetchUsers();
}, []); }, []);
@@ -60,7 +99,7 @@ export default function UsersPage() {
try { try {
setLoadingUsers(true); setLoadingUsers(true);
const usersResponse = await getAllUsers(); const usersResponse = await getAllUsers();
const mappedUsers: User[] = usersResponse.data.map((apiUser: ApiUser) => ({ const mappedUsers: User[] = usersResponse.data.map((apiUser: ApiUser) => ({
id: apiUser.id, id: apiUser.id,
name: apiUser.name, name: apiUser.name,
@@ -68,12 +107,14 @@ export default function UsersPage() {
roleId: apiUser.role_id, roleId: apiUser.role_id,
roleName: apiUser.role?.name || '', roleName: apiUser.role?.name || '',
projectId: apiUser.project_id || null, projectId: apiUser.project_id || null,
organismoOperadorId: apiUser.organismo_operador_id || null,
organismoName: apiUser.organismo_name || null,
status: apiUser.is_active ? "ACTIVE" : "INACTIVE", status: apiUser.is_active ? "ACTIVE" : "INACTIVE",
createdAt: new Date(apiUser.created_at).toISOString().slice(0, 10) createdAt: new Date(apiUser.created_at).toISOString().slice(0, 10)
})); }));
setUsers(mappedUsers); setUsers(mappedUsers);
const uniqueRolesMap = new Map<string, RoleOption>(); const uniqueRolesMap = new Map<string, RoleOption>();
usersResponse.data.forEach((apiUser: ApiUser) => { usersResponse.data.forEach((apiUser: ApiUser) => {
if (apiUser.role) { if (apiUser.role) {
@@ -84,7 +125,6 @@ export default function UsersPage() {
} }
}); });
const uniqueRoles = Array.from(uniqueRolesMap.values()); const uniqueRoles = Array.from(uniqueRolesMap.values());
console.log('Unique roles extracted:', uniqueRoles);
setRoles(uniqueRoles); setRoles(uniqueRoles);
} catch (error) { } catch (error) {
console.error('Failed to fetch users:', error); console.error('Failed to fetch users:', error);
@@ -97,17 +137,19 @@ export default function UsersPage() {
const handleSave = async () => { const handleSave = async () => {
setError(null); setError(null);
if (!form.name || !form.email || !form.roleId) { if (!form.name || !form.email || !form.roleId) {
setError("Please fill in all required fields"); setError("Please fill in all required fields");
return; return;
} }
const selectedRole = modalRoles.find(r => r.id === form.roleId); if (selectedRoleName === "OPERATOR" && !form.projectId) {
const isOperatorRole = selectedRole?.name === "OPERATOR"; setError("Project is required for OPERADOR role");
return;
if (isOperatorRole && !form.projectId) { }
setError("Project is required for OPERATOR role");
if (selectedRoleName === "ORGANISMO_OPERADOR" && !form.organismoOperadorId) {
setError("Organismo is required for ORGANISMO_OPERADOR role");
return; return;
} }
@@ -123,14 +165,31 @@ export default function UsersPage() {
try { try {
setSaving(true); setSaving(true);
// Determine organismo_operador_id based on role
let organismoId: string | null = null;
if (selectedRoleName === "ORGANISMO_OPERADOR" || selectedRoleName === "OPERATOR") {
organismoId = form.organismoOperadorId || null;
}
// If current user is ORGANISMO_OPERADOR, force their own organismo
if (isOrganismo && userOrganismoId) {
organismoId = userOrganismoId;
}
if (editingId) { if (editingId) {
const updateData: UpdateUserInput = { const updateData: UpdateUserInput = {
email: form.email, email: form.email,
name: form.name.trim(), name: form.name.trim(),
role_id: form.roleId, role_id: form.roleId,
project_id: form.projectId || null, project_id: form.projectId || null,
organismo_operador_id: organismoId,
is_active: form.status === "ACTIVE", is_active: form.status === "ACTIVE",
phone: form.phone || null,
street: form.street || null,
city: form.city || null,
state: form.state || null,
zip_code: form.zipCode || null,
}; };
await updateUser(editingId, updateData); await updateUser(editingId, updateData);
@@ -141,14 +200,20 @@ export default function UsersPage() {
name: form.name.trim(), name: form.name.trim(),
role_id: form.roleId, role_id: form.roleId,
project_id: form.projectId || null, project_id: form.projectId || null,
organismo_operador_id: organismoId,
is_active: form.status === "ACTIVE", is_active: form.status === "ACTIVE",
phone: form.phone || null,
street: form.street || null,
city: form.city || null,
state: form.state || null,
zip_code: form.zipCode || null,
}; };
await createUser(createData); await createUser(createData);
} }
await handleRefresh(); await handleRefresh();
setShowModal(false); setShowModal(false);
setEditingId(null); setEditingId(null);
setForm(emptyUser); setForm(emptyUser);
@@ -166,15 +231,15 @@ export default function UsersPage() {
const handleDelete = async () => { const handleDelete = async () => {
if (!activeUser) return; if (!activeUser) return;
if (!window.confirm(`Are you sure you want to delete user "${activeUser.name}"?`)) { if (!window.confirm(`Are you sure you want to delete user "${activeUser.name}"?`)) {
return; return;
} }
try { try {
setSaving(true); setSaving(true);
await deleteUser(activeUser.id); await deleteUser(activeUser.id);
await handleRefresh(); await handleRefresh();
setActiveUser(null); setActiveUser(null);
} catch (error) { } catch (error) {
@@ -189,8 +254,12 @@ export default function UsersPage() {
try { try {
setLoadingModalRoles(true); setLoadingModalRoles(true);
const rolesData = await getAllRoles(); const rolesData = await getAllRoles();
console.log('Modal roles fetched:', rolesData); // If ORGANISMO_OPERADOR, only show OPERATOR role
setModalRoles(rolesData); if (isOrganismo) {
setModalRoles(rolesData.filter(r => r.name === 'OPERATOR'));
} else {
setModalRoles(rolesData);
}
} catch (error) { } catch (error) {
console.error('Failed to fetch modal roles:', error); console.error('Failed to fetch modal roles:', error);
} finally { } finally {
@@ -202,7 +271,6 @@ export default function UsersPage() {
try { try {
setLoadingProjects(true); setLoadingProjects(true);
const projectsData = await fetchProjects(); const projectsData = await fetchProjects();
console.log('Projects fetched:', projectsData);
setProjects(projectsData); setProjects(projectsData);
} catch (error) { } catch (error) {
console.error('Failed to fetch projects:', error); console.error('Failed to fetch projects:', error);
@@ -211,35 +279,91 @@ export default function UsersPage() {
} }
}; };
const fetchOrganismosData = async () => {
if (!isAdmin) return; // Only ADMIN loads organismos list
try {
setLoadingOrganismos(true);
const response = await getAllOrganismos({ pageSize: 100 });
setOrganismos(response.data);
} catch (error) {
console.error('Failed to fetch organismos:', error);
} finally {
setLoadingOrganismos(false);
}
};
const handleOrganismoChange = async (organismoId: string) => {
setForm({ ...form, organismoOperadorId: organismoId, projectId: "" });
setOrganismoProjects([]);
if (organismoId) {
try {
const projects = await getOrganismoProjects(organismoId);
setOrganismoProjects(projects);
} catch (error) {
console.error('Failed to fetch organismo projects:', error);
}
}
};
const handleOpenAddModal = () => { const handleOpenAddModal = () => {
setForm(emptyUser); setForm(emptyUser);
setEditingId(null); setEditingId(null);
setError(null); setError(null);
setOrganismoProjects([]);
setShowModal(true); setShowModal(true);
fetchModalRoles(); fetchModalRoles();
fetchModalProjects(); fetchModalProjects();
fetchOrganismosData();
}; };
const handleOpenEditModal = (user: User) => { const handleOpenEditModal = async (user: User) => {
setEditingId(user.id); setEditingId(user.id);
// Fetch full user details to get address fields
let phone = "", street = "", city = "", state = "", zipCode = "";
try {
const fullUser = await import("../api/users").then(m => m.getUserById(user.id));
phone = fullUser.phone || "";
street = fullUser.street || "";
city = fullUser.city || "";
state = fullUser.state || "";
zipCode = fullUser.zip_code || "";
} catch (err) {
console.error('Failed to fetch user details:', err);
}
setForm({ setForm({
name: user.name, name: user.name,
email: user.email, email: user.email,
roleId: user.roleId, roleId: user.roleId,
projectId: user.projectId || "", projectId: user.projectId || "",
organismoOperadorId: user.organismoOperadorId || "",
status: user.status, status: user.status,
createdAt: user.createdAt, createdAt: user.createdAt,
password: "" password: "",
phone,
street,
city,
state,
zipCode,
}); });
setError(null); setError(null);
setOrganismoProjects([]);
setShowModal(true); setShowModal(true);
fetchModalRoles(); fetchModalRoles();
fetchModalProjects(); fetchModalProjects();
fetchOrganismosData();
// Load organismo projects if user has an organismo
if (user.organismoOperadorId) {
getOrganismoProjects(user.organismoOperadorId)
.then(setOrganismoProjects)
.catch(console.error);
}
}; };
// Filter users by search and selected role // Filter users by search and selected role
const filtered = users.filter(u => { const filtered = users.filter(u => {
const matchesSearch = u.name.toLowerCase().includes(search.toLowerCase()) || const matchesSearch = u.name.toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase()); u.email.toLowerCase().includes(search.toLowerCase());
const matchesRole = !selectedRoleFilter || u.roleId === selectedRoleFilter; const matchesRole = !selectedRoleFilter || u.roleId === selectedRoleFilter;
return matchesSearch && matchesRole; return matchesSearch && matchesRole;
@@ -251,20 +375,20 @@ export default function UsersPage() {
<div className="w-72 bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-4"> <div className="w-72 bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-4">
<h3 className="text-xs font-semibold text-gray-500 dark:text-zinc-400 mb-3">Filter Options</h3> <h3 className="text-xs font-semibold text-gray-500 dark:text-zinc-400 mb-3">Filter Options</h3>
<p className="text-sm text-gray-700 dark:text-zinc-300 mb-4">Filter users by role</p> <p className="text-sm text-gray-700 dark:text-zinc-300 mb-4">Filter users by role</p>
<label className="text-xs font-semibold text-gray-500 mb-2 block">Role</label> <label className="text-xs font-semibold text-gray-500 mb-2 block">Role</label>
<select <select
value={selectedRoleFilter} value={selectedRoleFilter}
onChange={e => setSelectedRoleFilter(e.target.value)} onChange={e => setSelectedRoleFilter(e.target.value)}
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded" className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
disabled={loadingUsers} disabled={loadingUsers}
> >
<option value="">All Roles</option> <option value="">All Roles</option>
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)} {roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select> </select>
{selectedRoleFilter && ( {selectedRoleFilter && (
<button <button
onClick={() => setSelectedRoleFilter("")} onClick={() => setSelectedRoleFilter("")}
className="mt-2 text-xs text-blue-600 hover:text-blue-800" className="mt-2 text-xs text-blue-600 hover:text-blue-800"
> >
@@ -284,7 +408,7 @@ export default function UsersPage() {
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<button onClick={handleOpenAddModal} className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg"><Plus size={16} /> Add</button> <button onClick={handleOpenAddModal} className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg"><Plus size={16} /> Add</button>
<button onClick={() => { <button onClick={() => {
if(!activeUser) return; if(!activeUser) return;
handleOpenEditModal(activeUser); handleOpenEditModal(activeUser);
}} disabled={!activeUser} className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"><Pencil size={16}/> Edit</button> }} disabled={!activeUser} className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"><Pencil size={16}/> Edit</button>
@@ -308,19 +432,20 @@ export default function UsersPage() {
{ title: "Name", field: "name" }, { title: "Name", field: "name" },
{ title: "Email", field: "email" }, { title: "Email", field: "email" },
{ title: "Role", field: "roleName" }, { title: "Role", field: "roleName" },
{ title: "Status", field: "status", render: rowData => <span className={`px-3 py-1 rounded-full text-xs font-semibold border ${rowData.status === "ACTIVE" ? "text-blue-600 border-blue-600" : "text-red-600 border-red-600"}`}>{rowData.status}</span> }, { title: "Organismo", field: "organismoName", render: (rowData: User) => rowData.organismoName || "-" },
{ title: "Status", field: "status", render: (rowData: User) => <span className={`px-3 py-1 rounded-full text-xs font-semibold border ${rowData.status === "ACTIVE" ? "text-blue-600 border-blue-600" : "text-red-600 border-red-600"}`}>{rowData.status}</span> },
{ title: "Created", field: "createdAt", type: "date" } { title: "Created", field: "createdAt", type: "date" }
]} ]}
data={filtered} data={filtered}
onRowClick={(_, rowData) => setActiveUser(rowData as User)} onRowClick={(_, rowData) => setActiveUser(rowData as User)}
options={{ options={{
actionsColumnIndex: -1, actionsColumnIndex: -1,
search: false, search: false,
paging: true, paging: true,
pageSize: 10, pageSize: 10,
pageSizeOptions: [10, 20, 50], pageSizeOptions: [10, 20, 50],
sorting: true, sorting: true,
rowStyle: rowData => ({ backgroundColor: activeUser?.id === (rowData as User).id ? "#EEF2FF" : "#FFFFFF" }) rowStyle: (rowData) => ({ backgroundColor: activeUser?.id === (rowData as User).id ? "#EEF2FF" : "#FFFFFF" })
}} }}
/> />
)} )}
@@ -328,84 +453,150 @@ export default function UsersPage() {
{/* MODAL */} {/* MODAL */}
{showModal && ( {showModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center"> <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl p-6 w-96 space-y-3"> <div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl p-6 w-96 space-y-3 max-h-[90vh] overflow-y-auto">
<h2 className="text-lg font-semibold dark:text-white">{editingId ? "Edit User" : "Add User"}</h2> <h2 className="text-lg font-semibold dark:text-white">{editingId ? "Edit User" : "Add User"}</h2>
{error && ( {error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded text-sm"> <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded text-sm">
{error} {error}
</div> </div>
)} )}
<input <input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded" className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Full Name *" placeholder="Full Name *"
value={form.name} value={form.name}
onChange={e => setForm({...form, name: e.target.value})} onChange={e => setForm({...form, name: e.target.value})}
disabled={saving} disabled={saving}
/> />
<input <input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded" className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
type="email" type="email"
placeholder="Email *" placeholder="Email *"
value={form.email} value={form.email}
onChange={e => setForm({...form, email: e.target.value})} onChange={e => setForm({...form, email: e.target.value})}
disabled={saving} disabled={saving}
/> />
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Telefono"
value={form.phone}
onChange={e => setForm({...form, phone: e.target.value})}
disabled={saving}
/>
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Calle"
value={form.street}
onChange={e => setForm({...form, street: e.target.value})}
disabled={saving}
/>
<div className="grid grid-cols-2 gap-2">
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Ciudad"
value={form.city}
onChange={e => setForm({...form, city: e.target.value})}
disabled={saving}
/>
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Estado"
value={form.state}
onChange={e => setForm({...form, state: e.target.value})}
disabled={saving}
/>
</div>
<input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
placeholder="Codigo Postal"
value={form.zipCode}
onChange={e => setForm({...form, zipCode: e.target.value})}
disabled={saving}
/>
{!editingId && ( {!editingId && (
<input <input
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded" className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
type="password" type="password"
placeholder="Password * (min 8 characters)" placeholder="Password * (min 8 characters)"
value={form.password || ""} value={form.password || ""}
onChange={e => setForm({...form, password: e.target.value})} onChange={e => setForm({...form, password: e.target.value})}
disabled={saving} disabled={saving}
/> />
)} )}
<select {/* Role selector */}
value={form.roleId} <select
onChange={e => setForm({...form, roleId: e.target.value, projectId: ""})} value={form.roleId}
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded" onChange={e => setForm({...form, roleId: e.target.value, projectId: "", organismoOperadorId: isOrganismo && userOrganismoId ? userOrganismoId : ""})}
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
disabled={loadingModalRoles || saving} disabled={loadingModalRoles || saving}
> >
<option value="">{loadingModalRoles ? "Loading roles..." : "Select Role *"}</option> <option value="">{loadingModalRoles ? "Loading roles..." : "Select Role *"}</option>
{modalRoles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)} {modalRoles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select> </select>
{modalRoles.find(r => r.id === form.roleId)?.name === "OPERATOR" && ( {/* Organismo selector - shown for ORGANISMO_OPERADOR and OPERATOR roles */}
<select {showOrganismoSelector && isAdmin && (
value={form.projectId || ""} <select
onChange={e => setForm({...form, projectId: e.target.value})} value={form.organismoOperadorId || ""}
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded" onChange={e => handleOrganismoChange(e.target.value)}
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
disabled={loadingOrganismos || saving}
>
<option value="">{loadingOrganismos ? "Loading organismos..." : "Select Organismo *"}</option>
{organismos.filter(o => o.is_active).map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
)}
{/* Show organismo name for ORGANISMO_OPERADOR users (they can't change it) */}
{showOrganismoSelector && isOrganismo && userOrganismoId && (
<div className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 px-3 py-2 rounded bg-gray-50 text-sm">
Organismo: Asignado a tu organismo
</div>
)}
{/* Project selector - shown for OPERATOR role */}
{showProjectSelector && (
<select
value={form.projectId || ""}
onChange={e => setForm({...form, projectId: e.target.value})}
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
disabled={loadingProjects || saving} disabled={loadingProjects || saving}
> >
<option value="">{loadingProjects ? "Loading projects..." : "Select Project *"}</option> <option value="">{loadingProjects ? "Loading projects..." : "Select Project *"}</option>
{activeProjects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)} {form.organismoOperadorId && organismoProjects.length > 0
? organismoProjects.filter(p => p.status === 'ACTIVE').map(p => <option key={p.id} value={p.id}>{p.name}</option>)
: activeProjects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)
}
</select> </select>
)} )}
<button <button
onClick={() => setForm({...form, status: form.status === "ACTIVE" ? "INACTIVE" : "ACTIVE"})} onClick={() => setForm({...form, status: form.status === "ACTIVE" ? "INACTIVE" : "ACTIVE"})}
className="w-full border rounded px-3 py-2" className="w-full border rounded px-3 py-2 dark:border-zinc-700 dark:text-zinc-100"
disabled={saving} disabled={saving}
> >
Status: {form.status} Status: {form.status}
</button> </button>
<div className="flex justify-end gap-2 pt-3"> <div className="flex justify-end gap-2 pt-3">
<button <button
onClick={() => { setShowModal(false); setError(null); }} onClick={() => { setShowModal(false); setError(null); }}
className="px-4 py-2" className="px-4 py-2 dark:text-zinc-300"
disabled={saving} disabled={saving}
> >
Cancel Cancel
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
className="bg-[#4c5f9e] text-white px-4 py-2 rounded disabled:opacity-50" className="bg-[#4c5f9e] text-white px-4 py-2 rounded disabled:opacity-50"
disabled={saving || loadingModalRoles} disabled={saving || loadingModalRoles}
> >

View File

@@ -0,0 +1,990 @@
import { useEffect, useState, useMemo, useRef } from "react";
import {
History,
RefreshCw,
Download,
Search,
X,
ChevronLeft,
ChevronRight,
Droplets,
MapPin,
Radio,
Calendar,
TrendingUp,
TrendingDown,
Minus,
} from "lucide-react";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import {
fetchMeters,
fetchMeterReadings,
type Meter,
type MeterReading,
type PaginatedMeterReadings,
} from "../../api/meters";
export default function HistoricoPage() {
const [meters, setMeters] = useState<Meter[]>([]);
const [metersLoading, setMetersLoading] = useState(true);
const [meterSearch, setMeterSearch] = useState("");
const [dropdownOpen, setDropdownOpen] = useState(false);
const [selectedMeter, setSelectedMeter] = useState<Meter | null>(null);
const [readings, setReadings] = useState<MeterReading[]>([]);
const [pagination, setPagination] = useState({
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
});
const [loadingReadings, setLoadingReadings] = useState(false);
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [consumoActual, setConsumoActual] = useState<number | null>(null);
const [consumoPasado, setConsumoPasado] = useState<number | null>(null);
const [loadingStats, setLoadingStats] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Load meters on mount
useEffect(() => {
const load = async () => {
try {
const data = await fetchMeters();
setMeters(data);
} catch (err) {
console.error("Error loading meters:", err);
} finally {
setMetersLoading(false);
}
};
load();
}, []);
// Close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
// Filter meters by search
const filteredMeters = useMemo(() => {
if (!meterSearch.trim()) return meters;
const q = meterSearch.toLowerCase();
return meters.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
m.serialNumber.toLowerCase().includes(q) ||
(m.location ?? "").toLowerCase().includes(q) ||
(m.cesptAccount ?? "").toLowerCase().includes(q) ||
(m.cadastralKey ?? "").toLowerCase().includes(q)
);
}, [meters, meterSearch]);
// Load readings when meter or filters change
const loadReadings = async (page = 1, pageSize?: number) => {
if (!selectedMeter) return;
setLoadingReadings(true);
try {
const result: PaginatedMeterReadings = await fetchMeterReadings(
selectedMeter.id,
{
startDate: startDate || undefined,
endDate: endDate || undefined,
page,
pageSize: pageSize ?? pagination.pageSize,
}
);
setReadings(result.data);
setPagination(result.pagination);
} catch (err) {
console.error("Error loading readings:", err);
} finally {
setLoadingReadings(false);
}
};
useEffect(() => {
if (selectedMeter) {
loadReadings(1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedMeter, startDate, endDate]);
// Load consumption stats when meter changes
useEffect(() => {
if (!selectedMeter) {
setConsumoActual(null);
setConsumoPasado(null);
return;
}
const loadStats = async () => {
setLoadingStats(true);
try {
// Consumo Actual: latest reading (today or most recent)
const today = new Date();
const todayStr = today.toISOString().split("T")[0];
const latestResult = await fetchMeterReadings(selectedMeter.id, {
endDate: todayStr,
page: 1,
pageSize: 1,
});
const actual = latestResult.data.length > 0
? Number(latestResult.data[0].readingValue)
: null;
setConsumoActual(actual);
// Consumo Pasado: reading closest to first day of last month
const firstOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const secondOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 2);
const pastResult = await fetchMeterReadings(selectedMeter.id, {
startDate: firstOfLastMonth.toISOString().split("T")[0],
endDate: secondOfLastMonth.toISOString().split("T")[0],
page: 1,
pageSize: 1,
});
if (pastResult.data.length > 0) {
setConsumoPasado(Number(pastResult.data[0].readingValue));
} else {
// Fallback: get the oldest reading around that date range
const endOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
const fallbackResult = await fetchMeterReadings(selectedMeter.id, {
startDate: firstOfLastMonth.toISOString().split("T")[0],
endDate: endOfLastMonth.toISOString().split("T")[0],
page: 1,
pageSize: 1,
});
setConsumoPasado(
fallbackResult.data.length > 0
? Number(fallbackResult.data[0].readingValue)
: null
);
}
} catch (err) {
console.error("Error loading consumption stats:", err);
} finally {
setLoadingStats(false);
}
};
loadStats();
}, [selectedMeter]);
const diferencial = useMemo(() => {
if (consumoActual === null || consumoPasado === null) return null;
return consumoActual - consumoPasado;
}, [consumoActual, consumoPasado]);
const handleSelectMeter = (meter: Meter) => {
setSelectedMeter(meter);
setMeterSearch(meter.name || meter.serialNumber);
setDropdownOpen(false);
setReadings([]);
setPagination({ page: 1, pageSize: pagination.pageSize, total: 0, totalPages: 0 });
};
const handlePageChange = (newPage: number) => {
loadReadings(newPage);
};
const handlePageSizeChange = (newSize: number) => {
setPagination((prev) => ({ ...prev, pageSize: newSize, page: 1 }));
loadReadings(1, newSize);
};
// Chart data: readings sorted ascending by date
const chartData = useMemo(() => {
return [...readings]
.sort((a, b) => new Date(a.receivedAt).getTime() - new Date(b.receivedAt).getTime())
.map((r) => ({
date: new Date(r.receivedAt).toLocaleDateString("es-MX", {
day: "2-digit",
month: "short",
}),
fullDate: new Date(r.receivedAt).toLocaleString("es-MX", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}),
value: Number(r.readingValue),
}));
}, [readings]);
// Compute tight Y-axis domain for chart
const chartDomain = useMemo(() => {
if (chartData.length === 0) return [0, 100];
const values = chartData.map((d) => d.value);
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min;
const padding = range > 0 ? range * 0.15 : max * 0.05 || 1;
return [
Math.max(0, Math.floor(min - padding)),
Math.ceil(max + padding),
];
}, [chartData]);
const formatDate = (dateStr: string | null): string => {
if (!dateStr) return "—";
return new Date(dateStr).toLocaleString("es-MX", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const exportToCSV = () => {
if (!selectedMeter || readings.length === 0) return;
const headers = ["Fecha/Hora", "Lectura (m³)", "Tipo", "Batería", "Señal"];
const rows = readings.map((r) => [
formatDate(r.receivedAt),
Number(r.readingValue).toFixed(2),
r.readingType || "—",
r.batteryLevel !== null ? `${r.batteryLevel}%` : "—",
r.signalStrength !== null ? `${r.signalStrength} dBm` : "—",
]);
const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
const serial = selectedMeter.serialNumber || "meter";
const date = new Date().toISOString().split("T")[0];
link.download = `historico_${serial}_${date}.csv`;
link.click();
};
const clearDateFilters = () => {
setStartDate("");
setEndDate("");
};
return (
<div className="min-h-full bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/50 dark:from-zinc-950 dark:via-zinc-950 dark:to-zinc-950 p-6">
<div className="max-w-[1600px] mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-800 dark:text-white flex items-center gap-2">
<History size={28} />
{"Histórico de Tomas"}
</h1>
<p className="text-slate-500 dark:text-zinc-400 text-sm mt-0.5">
Consulta el historial de lecturas por medidor
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => loadReadings(pagination.page)}
disabled={!selectedMeter}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 dark:text-zinc-300 bg-white dark:bg-zinc-800 border border-slate-200 dark:border-zinc-700 rounded-xl hover:bg-slate-50 dark:hover:bg-zinc-700 hover:border-slate-300 dark:hover:border-zinc-600 transition-all shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw size={16} />
Actualizar
</button>
<button
onClick={exportToCSV}
disabled={readings.length === 0}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-blue-600 to-indigo-600 rounded-xl hover:from-blue-700 hover:to-indigo-700 transition-all shadow-sm shadow-blue-500/25 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download size={16} />
Exportar CSV
</button>
</div>
</div>
{/* Meter Selector */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 p-5">
<label className="block text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide mb-2">
Seleccionar Medidor
</label>
<div className="relative" ref={dropdownRef}>
<Search
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
/>
<input
type="text"
value={meterSearch}
onChange={(e) => {
setMeterSearch(e.target.value);
setDropdownOpen(true);
}}
onFocus={() => setDropdownOpen(true)}
placeholder={metersLoading ? "Cargando medidores..." : "Buscar por nombre, serial, ubicación, cuenta CESPT o clave catastral..."}
disabled={metersLoading}
className="w-full pl-10 pr-10 py-2.5 text-sm bg-slate-50 dark:bg-zinc-800 dark:text-zinc-100 border border-slate-200 dark:border-zinc-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all"
/>
{meterSearch && (
<button
onClick={() => {
setMeterSearch("");
setSelectedMeter(null);
setReadings([]);
setDropdownOpen(false);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-zinc-300"
>
<X size={16} />
</button>
)}
{/* Dropdown */}
{dropdownOpen && filteredMeters.length > 0 && (
<div className="absolute z-20 mt-1 w-full max-h-64 overflow-auto bg-white dark:bg-zinc-800 border border-slate-200 dark:border-zinc-700 rounded-xl shadow-lg">
{filteredMeters.slice(0, 50).map((meter) => (
<button
key={meter.id}
onClick={() => handleSelectMeter(meter)}
className={`w-full text-left px-4 py-3 hover:bg-blue-50 dark:hover:bg-zinc-700 transition-colors border-b border-slate-100 dark:border-zinc-700 last:border-0 ${
selectedMeter?.id === meter.id ? "bg-blue-50 dark:bg-zinc-700" : ""
}`}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-800 dark:text-zinc-100">
{meter.name}
</p>
<p className="text-xs text-slate-500 dark:text-zinc-400">
{"Serial: "}{meter.serialNumber}
{meter.location && ` · ${meter.location}`}
</p>
{(meter.cesptAccount || meter.cadastralKey) && (
<p className="text-xs text-slate-400 dark:text-zinc-500">
{meter.cesptAccount && `CESPT: ${meter.cesptAccount}`}
{meter.cesptAccount && meter.cadastralKey && " · "}
{meter.cadastralKey && `Catastral: ${meter.cadastralKey}`}
</p>
)}
</div>
{meter.projectName && (
<span className="text-xs text-slate-400 dark:text-zinc-500 shrink-0 ml-3">
{meter.projectName}
</span>
)}
</div>
</button>
))}
</div>
)}
{dropdownOpen && meterSearch && filteredMeters.length === 0 && !metersLoading && (
<div className="absolute z-20 mt-1 w-full bg-white dark:bg-zinc-800 border border-slate-200 dark:border-zinc-700 rounded-xl shadow-lg p-4 text-center text-sm text-slate-500 dark:text-zinc-400">
No se encontraron medidores
</div>
)}
</div>
</div>
{/* No meter selected state */}
{!selectedMeter && (
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 p-16 text-center">
<div className="w-20 h-20 bg-slate-100 dark:bg-zinc-800 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Droplets size={40} className="text-slate-400" />
</div>
<p className="text-slate-600 dark:text-zinc-300 font-medium text-lg">
Selecciona un medidor
</p>
<p className="text-slate-400 dark:text-zinc-500 text-sm mt-1">
Busca y selecciona un medidor para ver su historial de lecturas
</p>
</div>
)}
{/* Content when meter is selected */}
{selectedMeter && (
<>
{/* Meter Info Card */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 p-5">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
<InfoItem
icon={<Radio size={16} />}
label="Serial"
value={selectedMeter.serialNumber}
/>
<InfoItem
icon={<Droplets size={16} />}
label="Nombre"
value={selectedMeter.name}
/>
<InfoItem
icon={<MapPin size={16} />}
label="Proyecto"
value={selectedMeter.projectName || "—"}
/>
<InfoItem
icon={<MapPin size={16} />}
label="Ubicación"
value={selectedMeter.location || "—"}
/>
<InfoItem
icon={<Calendar size={16} />}
label="Última Lectura"
value={
selectedMeter.lastReadingValue !== null
? `${Number(selectedMeter.lastReadingValue).toFixed(2)}`
: "Sin datos"
}
/>
</div>
</div>
{/* Consumption Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<ConsumptionCard
label="Consumo Actual"
sublabel="Lectura más reciente"
value={consumoActual}
loading={loadingStats}
gradient="from-blue-500 to-blue-600"
/>
<ConsumptionCard
label="Consumo Pasado"
sublabel="1ro del mes anterior"
value={consumoPasado}
loading={loadingStats}
gradient="from-slate-500 to-slate-600"
/>
<div className="relative bg-white dark:bg-zinc-900 rounded-2xl p-5 shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 overflow-hidden group hover:shadow-md transition-all">
<div className="flex items-start justify-between">
<div className="space-y-2">
<p className="text-sm font-medium text-slate-500 dark:text-zinc-400">
Diferencial
</p>
{loadingStats ? (
<div className="h-8 w-24 bg-slate-100 dark:bg-zinc-700 rounded-lg animate-pulse" />
) : diferencial !== null ? (
<p className={`text-2xl font-bold tabular-nums ${
diferencial > 0
? "text-emerald-600 dark:text-emerald-400"
: diferencial < 0
? "text-red-600 dark:text-red-400"
: "text-slate-800 dark:text-white"
}`}>
{diferencial > 0 ? "+" : ""}{diferencial.toFixed(2)}
<span className="text-sm font-normal ml-1">{"m³"}</span>
</p>
) : (
<p className="text-2xl font-bold text-slate-400 dark:text-zinc-500">{"—"}</p>
)}
<p className="text-xs text-slate-400 dark:text-zinc-500">
Actual - Pasado
</p>
</div>
<div className={`w-12 h-12 rounded-xl flex items-center justify-center text-white shadow-lg group-hover:scale-110 transition-transform ${
diferencial !== null && diferencial > 0
? "bg-gradient-to-br from-emerald-500 to-emerald-600"
: diferencial !== null && diferencial < 0
? "bg-gradient-to-br from-red-500 to-red-600"
: "bg-gradient-to-br from-slate-400 to-slate-500"
}`}>
{diferencial !== null && diferencial > 0 ? (
<TrendingUp size={22} />
) : diferencial !== null && diferencial < 0 ? (
<TrendingDown size={22} />
) : (
<Minus size={22} />
)}
</div>
</div>
</div>
</div>
{/* Date Filters */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 px-5 py-4 flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide">
Desde
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="px-3 py-1.5 text-sm bg-slate-50 dark:bg-zinc-800 dark:text-zinc-100 border border-slate-200 dark:border-zinc-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide">
Hasta
</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="px-3 py-1.5 text-sm bg-slate-50 dark:bg-zinc-800 dark:text-zinc-100 border border-slate-200 dark:border-zinc-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20"
/>
</div>
{(startDate || endDate) && (
<button
onClick={clearDateFilters}
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-slate-500 dark:text-zinc-400 hover:text-slate-700 dark:hover:text-zinc-200"
>
<X size={14} />
Limpiar
</button>
)}
</div>
{/* Chart */}
{chartData.length > 1 && (
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-base font-semibold text-slate-800 dark:text-zinc-100">
{"Consumo en el Tiempo"}
</h2>
<p className="text-xs text-slate-500 dark:text-zinc-400 mt-0.5">
{`${chartData.length} lecturas en el período`}
</p>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-zinc-400">
<span className="inline-block w-3 h-3 rounded-full bg-blue-500" />
{"Lectura (m³)"}
</div>
</div>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<defs>
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" strokeOpacity={0.5} vertical={false} />
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "#94a3b8" }}
tickLine={false}
axisLine={{ stroke: "#e2e8f0" }}
dy={8}
/>
<YAxis
tick={{ fontSize: 11, fill: "#94a3b8" }}
tickLine={false}
axisLine={false}
unit=" m³"
width={70}
domain={chartDomain}
/>
<Tooltip
contentStyle={{
backgroundColor: "#1e293b",
border: "none",
borderRadius: "0.75rem",
color: "#f1f5f9",
fontSize: "0.875rem",
padding: "12px 16px",
boxShadow: "0 10px 25px rgba(0,0,0,0.2)",
}}
formatter={(value: number | undefined) => [
`${(value ?? 0).toFixed(2)}`,
"Lectura",
]}
labelFormatter={(_label, payload) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(payload as any)?.[0]?.payload?.fullDate || _label
}
/>
<Area
type="monotone"
dataKey="value"
stroke="#3b82f6"
strokeWidth={2.5}
fill="url(#colorValue)"
dot={{ r: 3, fill: "#3b82f6", stroke: "#fff", strokeWidth: 2 }}
activeDot={{ r: 6, stroke: "#3b82f6", strokeWidth: 2, fill: "#fff" }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Table */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 overflow-hidden">
<div className="px-5 py-4 border-b border-slate-100 dark:border-zinc-800 flex items-center justify-between">
<span className="text-sm text-slate-500 dark:text-zinc-400">
<span className="font-semibold text-slate-700 dark:text-zinc-200">
{pagination.total}
</span>{" "}
lecturas encontradas
</span>
{pagination.totalPages > 1 && (
<div className="flex items-center gap-1 bg-slate-50 dark:bg-zinc-800 rounded-lg p-1">
<button
onClick={() => handlePageChange(pagination.page - 1)}
disabled={pagination.page === 1}
className="p-1.5 rounded-md hover:bg-white dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={16} className="dark:text-zinc-300" />
</button>
<span className="px-2 text-xs font-medium dark:text-zinc-300">
{pagination.page} / {pagination.totalPages}
</span>
<button
onClick={() => handlePageChange(pagination.page + 1)}
disabled={pagination.page === pagination.totalPages}
className="p-1.5 rounded-md hover:bg-white dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={16} className="dark:text-zinc-300" />
</button>
</div>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50/80 dark:bg-zinc-800">
<th className="px-5 py-3 text-left text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
Fecha / Hora
</th>
<th className="px-5 py-3 text-right text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
{"Lectura (m³)"}
</th>
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
Tipo
</th>
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
{"Batería"}
</th>
<th className="px-5 py-3 text-center text-xs font-semibold text-slate-500 dark:text-zinc-400 uppercase tracking-wider">
{"Señal"}
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-zinc-700">
{loadingReadings ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 5 }).map((_, j) => (
<td key={j} className="px-5 py-4">
<div className="h-4 bg-slate-100 dark:bg-zinc-700 rounded-md animate-pulse" />
</td>
))}
</tr>
))
) : readings.length === 0 ? (
<tr>
<td colSpan={5} className="px-5 py-16 text-center">
<div className="flex flex-col items-center">
<div className="w-16 h-16 bg-slate-100 dark:bg-zinc-800 rounded-2xl flex items-center justify-center mb-4">
<Droplets size={32} className="text-slate-400" />
</div>
<p className="text-slate-600 dark:text-zinc-300 font-medium">
No hay lecturas disponibles
</p>
<p className="text-slate-400 dark:text-zinc-500 text-sm mt-1">
{startDate || endDate
? "Intenta ajustar el rango de fechas"
: "Este medidor aún no tiene lecturas registradas"}
</p>
</div>
</td>
</tr>
) : (
readings.map((reading, idx) => (
<tr
key={reading.id}
className={`group hover:bg-blue-50/40 dark:hover:bg-zinc-800 transition-colors ${
idx % 2 === 0
? "bg-white dark:bg-zinc-900"
: "bg-slate-50/30 dark:bg-zinc-800/50"
}`}
>
<td className="px-5 py-3.5">
<span className="text-sm text-slate-600 dark:text-zinc-300">
{formatDate(reading.receivedAt)}
</span>
</td>
<td className="px-5 py-3.5 text-right">
<span className="text-sm font-semibold text-slate-800 dark:text-zinc-100 tabular-nums">
{Number(reading.readingValue).toFixed(2)}
</span>
</td>
<td className="px-5 py-3.5 text-center">
<TypeBadge type={reading.readingType} />
</td>
<td className="px-5 py-3.5 text-center">
{reading.batteryLevel !== null ? (
<BatteryIndicator level={reading.batteryLevel} />
) : (
<span className="text-slate-400 dark:text-zinc-500">{"—"}</span>
)}
</td>
<td className="px-5 py-3.5 text-center">
{reading.signalStrength !== null ? (
<SignalIndicator strength={reading.signalStrength} />
) : (
<span className="text-slate-400 dark:text-zinc-500">{"—"}</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Footer pagination */}
{!loadingReadings && readings.length > 0 && (
<div className="px-5 py-4 border-t border-slate-100 dark:border-zinc-700 flex flex-wrap items-center justify-between gap-4">
<div className="text-sm text-slate-600 dark:text-zinc-300">
Mostrando{" "}
<span className="font-semibold text-slate-800 dark:text-zinc-200">
{(pagination.page - 1) * pagination.pageSize + 1}
</span>{" "}
a{" "}
<span className="font-semibold text-slate-800 dark:text-zinc-200">
{Math.min(pagination.page * pagination.pageSize, pagination.total)}
</span>{" "}
de{" "}
<span className="font-semibold text-slate-800 dark:text-zinc-200">
{pagination.total}
</span>{" "}
resultados
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-600 dark:text-zinc-300">
{"Filas por página:"}
</span>
<select
value={pagination.pageSize}
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
className="px-3 py-1.5 text-sm bg-white dark:bg-zinc-800 dark:text-zinc-100 border border-slate-200 dark:border-zinc-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handlePageChange(pagination.page - 1)}
disabled={pagination.page === 1}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-zinc-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft size={18} className="text-slate-600 dark:text-zinc-400" />
</button>
<div className="flex items-center gap-1">
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1)
.filter((pageNum) => {
if (pageNum === 1 || pageNum === pagination.totalPages) return true;
if (Math.abs(pageNum - pagination.page) <= 1) return true;
return false;
})
.map((pageNum, idx, arr) => {
const prevNum = arr[idx - 1];
const showEllipsis = prevNum && pageNum - prevNum > 1;
return (
<div key={pageNum} className="flex items-center">
{showEllipsis && (
<span className="px-2 text-slate-400 dark:text-zinc-500">
...
</span>
)}
<button
onClick={() => handlePageChange(pageNum)}
className={`min-w-[36px] px-3 py-1.5 text-sm rounded-lg transition-colors ${
pageNum === pagination.page
? "bg-blue-600 text-white font-semibold"
: "text-slate-600 dark:text-zinc-300 hover:bg-slate-100 dark:hover:bg-zinc-800"
}`}
>
{pageNum}
</button>
</div>
);
})}
</div>
<button
onClick={() => handlePageChange(pagination.page + 1)}
disabled={pagination.page === pagination.totalPages}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-zinc-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight size={18} className="text-slate-600 dark:text-zinc-400" />
</button>
</div>
</div>
</div>
)}
</div>
</>
)}
</div>
</div>
);
}
function InfoItem({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-2">
<div className="mt-0.5 text-slate-400 dark:text-zinc-500">{icon}</div>
<div>
<p className="text-xs font-medium text-slate-500 dark:text-zinc-400 uppercase tracking-wide">
{label}
</p>
<p className="text-sm font-semibold text-slate-800 dark:text-zinc-100 mt-0.5">{value}</p>
</div>
</div>
);
}
function ConsumptionCard({
label,
sublabel,
value,
loading,
gradient,
}: {
label: string;
sublabel: string;
value: number | null;
loading: boolean;
gradient: string;
}) {
return (
<div className="relative bg-white dark:bg-zinc-900 rounded-2xl p-5 shadow-sm shadow-slate-200/50 dark:shadow-none border border-slate-200/60 dark:border-zinc-800 overflow-hidden group hover:shadow-md transition-all">
<div className="flex items-start justify-between">
<div className="space-y-2">
<p className="text-sm font-medium text-slate-500 dark:text-zinc-400">{label}</p>
{loading ? (
<div className="h-8 w-24 bg-slate-100 dark:bg-zinc-700 rounded-lg animate-pulse" />
) : value !== null ? (
<p className="text-2xl font-bold text-slate-800 dark:text-white tabular-nums">
{value.toFixed(2)}
<span className="text-sm font-normal text-slate-400 dark:text-zinc-500 ml-1">{"m³"}</span>
</p>
) : (
<p className="text-2xl font-bold text-slate-400 dark:text-zinc-500">{"—"}</p>
)}
<p className="text-xs text-slate-400 dark:text-zinc-500">{sublabel}</p>
</div>
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${gradient} flex items-center justify-center text-white shadow-lg group-hover:scale-110 transition-transform`}>
<Droplets size={22} />
</div>
</div>
<div className={`absolute -right-8 -bottom-8 w-32 h-32 rounded-full bg-gradient-to-br ${gradient} opacity-5`} />
</div>
);
}
function TypeBadge({ type }: { type: string | null }) {
if (!type) return <span className="text-slate-400 dark:text-zinc-500">{"—"}</span>;
const styles: Record<string, { bg: string; text: string; dot: string }> = {
AUTOMATIC: {
bg: "bg-emerald-50 dark:bg-emerald-900/30",
text: "text-emerald-700 dark:text-emerald-400",
dot: "bg-emerald-500",
},
MANUAL: {
bg: "bg-blue-50 dark:bg-blue-900/30",
text: "text-blue-700 dark:text-blue-400",
dot: "bg-blue-500",
},
SCHEDULED: {
bg: "bg-violet-50 dark:bg-violet-900/30",
text: "text-violet-700 dark:text-violet-400",
dot: "bg-violet-500",
},
};
const style = styles[type] || {
bg: "bg-slate-50 dark:bg-zinc-800",
text: "text-slate-700 dark:text-zinc-300",
dot: "bg-slate-500",
};
return (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${style.bg} ${style.text}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${style.dot}`} />
{type}
</span>
);
}
function BatteryIndicator({ level }: { level: number }) {
const getColor = () => {
if (level > 50) return "bg-emerald-500";
if (level > 20) return "bg-amber-500";
return "bg-red-500";
};
return (
<div className="inline-flex items-center gap-1" title={`Batería: ${level}%`}>
<div className="w-6 h-3 border border-slate-300 dark:border-zinc-600 rounded-sm relative overflow-hidden">
<div
className={`absolute left-0 top-0 bottom-0 ${getColor()} transition-all`}
style={{ width: `${level}%` }}
/>
</div>
<span className="text-[10px] text-slate-500 dark:text-zinc-400 font-medium">{level}%</span>
</div>
);
}
function SignalIndicator({ strength }: { strength: number }) {
const getBars = () => {
if (strength >= -70) return 4;
if (strength >= -85) return 3;
if (strength >= -100) return 2;
return 1;
};
const bars = getBars();
return (
<div
className="inline-flex items-end gap-0.5 h-3"
title={`Señal: ${strength} dBm`}
>
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className={`w-1 rounded-sm transition-colors ${
i <= bars ? "bg-emerald-500" : "bg-slate-200 dark:bg-zinc-600"
}`}
style={{ height: `${i * 2 + 4}px` }}
/>
))}
</div>
);
}

View File

@@ -144,6 +144,38 @@ export default function MetersModal({
/> />
</div> </div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Domicilio</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Domicilio de la toma (opcional)"
value={form.address ?? ""}
onChange={(e) => setForm({ ...form, address: e.target.value || undefined })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Cuenta CESPT</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Cuenta CESPT (opcional)"
value={form.cesptAccount ?? ""}
onChange={(e) => setForm({ ...form, cesptAccount: e.target.value || undefined })}
/>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Clave Catastral</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Clave catastral (opcional)"
value={form.cadastralKey ?? ""}
onChange={(e) => setForm({ ...form, cadastralKey: e.target.value || undefined })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Tipo</label> <label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Tipo</label>

View File

@@ -10,11 +10,15 @@ import {
deactivateProject as apiDeactivateProject, deactivateProject as apiDeactivateProject,
} from "../../api/projects"; } from "../../api/projects";
import { fetchMeterTypes, type MeterType } from "../../api/meterTypes"; import { fetchMeterTypes, type MeterType } from "../../api/meterTypes";
import { getCurrentUserRole, getCurrentUserProjectId } from "../../api/auth"; import { getCurrentUserRole, getCurrentUserProjectId, getCurrentUserOrganismoId } from "../../api/auth";
import { getAllOrganismos, type OrganismoOperador } from "../../api/organismos";
export default function ProjectsPage() { export default function ProjectsPage() {
const userRole = useMemo(() => getCurrentUserRole(), []); const userRole = useMemo(() => getCurrentUserRole(), []);
const userProjectId = useMemo(() => getCurrentUserProjectId(), []); const userProjectId = useMemo(() => getCurrentUserProjectId(), []);
const userOrganismoId = useMemo(() => getCurrentUserOrganismoId(), []);
const isAdmin = userRole?.toUpperCase() === 'ADMIN';
const isOrganismo = userRole?.toUpperCase() === 'ORGANISMO_OPERADOR';
const isOperator = userRole?.toUpperCase() === 'OPERATOR'; const isOperator = userRole?.toUpperCase() === 'OPERATOR';
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
@@ -26,6 +30,7 @@ export default function ProjectsPage() {
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [meterTypes, setMeterTypes] = useState<MeterType[]>([]); const [meterTypes, setMeterTypes] = useState<MeterType[]>([]);
const [organismos, setOrganismos] = useState<OrganismoOperador[]>([]);
const emptyForm: ProjectInput = { const emptyForm: ProjectInput = {
name: "", name: "",
@@ -34,6 +39,7 @@ export default function ProjectsPage() {
location: "", location: "",
status: "ACTIVE", status: "ACTIVE",
meterTypeId: null, meterTypeId: null,
organismoOperadorId: isOrganismo ? userOrganismoId : null,
}; };
const [form, setForm] = useState<ProjectInput>(emptyForm); const [form, setForm] = useState<ProjectInput>(emptyForm);
@@ -52,16 +58,21 @@ export default function ProjectsPage() {
}; };
const visibleProjects = useMemo(() => { const visibleProjects = useMemo(() => {
if (!isOperator) { // ADMIN sees all
return projects; if (isAdmin) return projects;
// ORGANISMO_OPERADOR sees only their organismo's projects
if (isOrganismo && userOrganismoId) {
return projects.filter(p => p.organismoOperadorId === userOrganismoId);
} }
if (userProjectId) { // OPERATOR sees only their single project
if (isOperator && userProjectId) {
return projects.filter(p => p.id === userProjectId); return projects.filter(p => p.id === userProjectId);
} }
return []; return [];
}, [projects, isOperator, userProjectId]); }, [projects, isAdmin, isOrganismo, isOperator, userProjectId, userOrganismoId]);
const loadMeterTypesData = async () => { const loadMeterTypesData = async () => {
try { try {
@@ -73,9 +84,23 @@ export default function ProjectsPage() {
} }
}; };
const loadOrganismos = async () => {
try {
const response = await getAllOrganismos({ pageSize: 100 });
setOrganismos(response.data);
} catch (err) {
console.error("Error loading organismos:", err);
setOrganismos([]);
}
};
useEffect(() => { useEffect(() => {
loadProjects(); loadProjects();
loadMeterTypesData(); loadMeterTypesData();
if (isAdmin) {
loadOrganismos();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const handleSave = async () => { const handleSave = async () => {
@@ -142,6 +167,7 @@ export default function ProjectsPage() {
location: activeProject.location ?? "", location: activeProject.location ?? "",
status: activeProject.status, status: activeProject.status,
meterTypeId: activeProject.meterTypeId ?? null, meterTypeId: activeProject.meterTypeId ?? null,
organismoOperadorId: activeProject.organismoOperadorId ?? null,
}); });
setShowModal(true); setShowModal(true);
}; };
@@ -174,7 +200,7 @@ export default function ProjectsPage() {
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
{!isOperator && ( {(isAdmin || isOrganismo) && (
<button <button
onClick={openCreateModal} onClick={openCreateModal}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg" className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg"
@@ -183,7 +209,7 @@ export default function ProjectsPage() {
</button> </button>
)} )}
{!isOperator && ( {(isAdmin || isOrganismo) && (
<button <button
onClick={openEditModal} onClick={openEditModal}
disabled={!activeProject} disabled={!activeProject}
@@ -193,7 +219,7 @@ export default function ProjectsPage() {
</button> </button>
)} )}
{!isOperator && ( {(isAdmin || isOrganismo) && (
<button <button
onClick={handleDelete} onClick={handleDelete}
disabled={!activeProject} disabled={!activeProject}
@@ -227,8 +253,21 @@ export default function ProjectsPage() {
columns={[ columns={[
{ title: "Nombre", field: "name" }, { title: "Nombre", field: "name" },
{ title: "Area", field: "areaName" }, { title: "Area", field: "areaName" },
{ ...(isAdmin ? [{
title: "Tipo de Toma", title: "Organismo Operador",
field: "organismoOperadorId",
render: (rowData: Project) => {
if (!rowData.organismoOperadorId) return <span className="text-gray-400">-</span>;
const org = organismos.find(o => o.id === rowData.organismoOperadorId);
return org ? (
<span className="px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-700">
{org.name}
</span>
) : <span className="text-gray-400">-</span>;
},
}] : []),
{
title: "Tipo de Toma",
field: "meterTypeId", field: "meterTypeId",
render: (rowData: Project) => { render: (rowData: Project) => {
if (!rowData.meterTypeId) return "-"; if (!rowData.meterTypeId) return "-";
@@ -358,6 +397,25 @@ export default function ProjectsPage() {
)} )}
</div> </div>
{/* Organismo Operador selector - ADMIN only */}
{isAdmin && (
<div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Organismo Operador</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.organismoOperadorId ?? ""}
onChange={(e) => setForm({ ...form, organismoOperadorId: e.target.value || null })}
>
<option value="">Sin organismo (opcional)</option>
{organismos.filter(o => o.is_active).map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
</div>
)}
<div> <div>
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Estado</label> <label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Estado</label>
<select <select

View File

@@ -0,0 +1,66 @@
-- ============================================
-- Migration: Add Organismos Operadores (3-level hierarchy)
-- Admin → Organismo Operador → Operador
-- ============================================
-- 1. Add ORGANISMO_OPERADOR to role_name ENUM
-- NOTE: ALTER TYPE ADD VALUE cannot run inside a transaction block
ALTER TYPE role_name ADD VALUE IF NOT EXISTS 'ORGANISMO_OPERADOR';
-- 2. Create organismos_operadores table
CREATE TABLE IF NOT EXISTS organismos_operadores (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
region VARCHAR(255),
contact_name VARCHAR(255),
contact_email VARCHAR(255),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Add updated_at trigger
CREATE TRIGGER set_organismos_operadores_updated_at
BEFORE UPDATE ON organismos_operadores
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Index for active organismos
CREATE INDEX IF NOT EXISTS idx_organismos_operadores_active ON organismos_operadores (is_active);
-- 3. Add organismo_operador_id FK to projects table
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS organismo_operador_id UUID REFERENCES organismos_operadores(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_projects_organismo_operador_id ON projects (organismo_operador_id);
-- 4. Add organismo_operador_id FK to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS organismo_operador_id UUID REFERENCES organismos_operadores(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_users_organismo_operador_id ON users (organismo_operador_id);
-- 5. Insert ORGANISMO_OPERADOR role with permissions
INSERT INTO roles (name, description, permissions)
SELECT
'ORGANISMO_OPERADOR',
'Organismo operador que gestiona proyectos y operadores dentro de su jurisdicción',
'["projects:read", "projects:list", "concentrators:read", "concentrators:list", "meters:read", "meters:write", "meters:list", "readings:read", "readings:list", "users:read", "users:write", "users:list"]'::jsonb
WHERE NOT EXISTS (
SELECT 1 FROM roles WHERE name = 'ORGANISMO_OPERADOR'
);
-- 6. Migrate VIEWER users to OPERATOR role
UPDATE users
SET role_id = (SELECT id FROM roles WHERE name = 'OPERATOR' LIMIT 1)
WHERE role_id = (SELECT id FROM roles WHERE name = 'VIEWER' LIMIT 1);
-- 7. Seed example organismos operadores
INSERT INTO organismos_operadores (name, description, region, contact_name, contact_email)
SELECT 'CESPT', 'Comisión Estatal de Servicios Públicos de Tijuana', 'Tijuana, BC', 'Admin CESPT', 'admin@cespt.gob.mx'
WHERE NOT EXISTS (SELECT 1 FROM organismos_operadores WHERE name = 'CESPT');
INSERT INTO organismos_operadores (name, description, region, contact_name, contact_email)
SELECT 'XICALI', 'Organismo Operador de Mexicali', 'Mexicali, BC', 'Admin XICALI', 'admin@xicali.gob.mx'
WHERE NOT EXISTS (SELECT 1 FROM organismos_operadores WHERE name = 'XICALI');

View File

@@ -0,0 +1,11 @@
-- Add new fields to users table
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(20);
ALTER TABLE users ADD COLUMN IF NOT EXISTS street VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS city VARCHAR(100);
ALTER TABLE users ADD COLUMN IF NOT EXISTS state VARCHAR(100);
ALTER TABLE users ADD COLUMN IF NOT EXISTS zip_code VARCHAR(10);
-- Add new fields to meters table
ALTER TABLE meters ADD COLUMN IF NOT EXISTS address TEXT;
ALTER TABLE meters ADD COLUMN IF NOT EXISTS cespt_account VARCHAR(50);
ALTER TABLE meters ADD COLUMN IF NOT EXISTS cadastral_key VARCHAR(50);

View File

@@ -27,7 +27,8 @@ export async function getAll(req: AuthenticatedRequest, res: Response): Promise<
// Pass user info for role-based filtering // Pass user info for role-based filtering
const requestingUser = req.user ? { const requestingUser = req.user ? {
roleName: req.user.roleName, roleName: req.user.roleName,
projectId: req.user.projectId projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined; } : undefined;
const result = await concentratorService.getAll(filters, pagination, requestingUser); const result = await concentratorService.getAll(filters, pagination, requestingUser);

View File

@@ -38,7 +38,8 @@ export async function getAll(req: AuthenticatedRequest, res: Response): Promise<
// Pass user info for role-based filtering // Pass user info for role-based filtering
const requestingUser = req.user ? { const requestingUser = req.user ? {
roleName: req.user.roleName, roleName: req.user.roleName,
projectId: req.user.projectId projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined; } : undefined;
const result = await meterService.getAll(filters, { page, pageSize }, requestingUser); const result = await meterService.getAll(filters, { page, pageSize }, requestingUser);
@@ -243,7 +244,7 @@ export async function deleteMeter(req: AuthenticatedRequest, res: Response): Pro
* Get meter readings history with optional date range filter * Get meter readings history with optional date range filter
* Query params: start_date, end_date, page, pageSize * Query params: start_date, end_date, page, pageSize
*/ */
export async function getReadings(req: Request, res: Response): Promise<void> { export async function getReadings(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
const { id } = req.params; const { id } = req.params;
@@ -273,7 +274,14 @@ export async function getReadings(req: Request, res: Response): Promise<void> {
filters.end_date = req.query.end_date as string; filters.end_date = req.query.end_date as string;
} }
const result = await readingService.getAll(filters, { page, pageSize }); // Pass user info for role-based filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const result = await readingService.getAll(filters, { page, pageSize }, requestingUser);
res.status(200).json({ res.status(200).json({
success: true, success: true,

View File

@@ -0,0 +1,186 @@
import { Request, Response } from 'express';
import * as organismoService from '../services/organismo-operador.service';
/**
* GET /organismos-operadores
* List all organismos operadores
*/
export async function getAll(req: Request, res: Response): Promise<void> {
try {
const page = parseInt(req.query.page as string, 10) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string, 10) || 50, 100);
const result = await organismoService.getAll({ page, pageSize });
res.status(200).json({
success: true,
data: result.data,
pagination: result.pagination,
});
} catch (error) {
console.error('Error fetching organismos:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch organismos operadores',
});
}
}
/**
* GET /organismos-operadores/:id
* Get a single organismo by ID
*/
export async function getById(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const organismo = await organismoService.getById(id);
if (!organismo) {
res.status(404).json({
success: false,
error: 'Organismo operador not found',
});
return;
}
res.status(200).json({
success: true,
data: organismo,
});
} catch (error) {
console.error('Error fetching organismo:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch organismo operador',
});
}
}
/**
* POST /organismos-operadores
* Create a new organismo operador (ADMIN only)
*/
export async function create(req: Request, res: Response): Promise<void> {
try {
const data = req.body as organismoService.CreateOrganismoInput;
const organismo = await organismoService.create(data);
res.status(201).json({
success: true,
data: organismo,
});
} catch (error) {
console.error('Error creating organismo:', error);
res.status(500).json({
success: false,
error: 'Failed to create organismo operador',
});
}
}
/**
* PUT /organismos-operadores/:id
* Update an organismo operador (ADMIN only)
*/
export async function update(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const data = req.body as organismoService.UpdateOrganismoInput;
const organismo = await organismoService.update(id, data);
if (!organismo) {
res.status(404).json({
success: false,
error: 'Organismo operador not found',
});
return;
}
res.status(200).json({
success: true,
data: organismo,
});
} catch (error) {
console.error('Error updating organismo:', error);
res.status(500).json({
success: false,
error: 'Failed to update organismo operador',
});
}
}
/**
* DELETE /organismos-operadores/:id
* Delete an organismo operador (ADMIN only)
*/
export async function remove(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const deleted = await organismoService.remove(id);
if (!deleted) {
res.status(404).json({
success: false,
error: 'Organismo operador not found',
});
return;
}
res.status(200).json({
success: true,
data: { message: 'Organismo operador deleted successfully' },
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete organismo operador';
if (message.includes('Cannot delete')) {
res.status(409).json({
success: false,
error: message,
});
return;
}
console.error('Error deleting organismo:', error);
res.status(500).json({
success: false,
error: 'Failed to delete organismo operador',
});
}
}
/**
* GET /organismos-operadores/:id/projects
* Get projects belonging to an organismo
*/
export async function getProjects(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const organismo = await organismoService.getById(id);
if (!organismo) {
res.status(404).json({
success: false,
error: 'Organismo operador not found',
});
return;
}
const projects = await organismoService.getProjectsByOrganismo(id);
res.status(200).json({
success: true,
data: projects,
});
} catch (error) {
console.error('Error fetching organismo projects:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch organismo projects',
});
}
}

View File

@@ -8,7 +8,7 @@ import { CreateProjectInput, UpdateProjectInput, ProjectStatusType } from '../va
* List all projects with pagination and optional filtering * List all projects with pagination and optional filtering
* Query params: page, pageSize, status, area_name, search * Query params: page, pageSize, status, area_name, search
*/ */
export async function getAll(req: Request, res: Response): Promise<void> { export async function getAll(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
const page = parseInt(req.query.page as string, 10) || 1; const page = parseInt(req.query.page as string, 10) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string, 10) || 10, 100); const pageSize = Math.min(parseInt(req.query.pageSize as string, 10) || 10, 100);
@@ -27,7 +27,14 @@ export async function getAll(req: Request, res: Response): Promise<void> {
filters.search = req.query.search as string; filters.search = req.query.search as string;
} }
const result = await projectService.getAll(filters, { page, pageSize }); // Pass user info for role-based filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const result = await projectService.getAll(filters, { page, pageSize }, requestingUser);
res.status(200).json({ res.status(200).json({
success: true, success: true,

View File

@@ -1,11 +1,12 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { AuthenticatedRequest } from '../middleware/auth.middleware';
import * as readingService from '../services/reading.service'; import * as readingService from '../services/reading.service';
/** /**
* GET /readings * GET /readings
* List all readings with pagination and filtering * List all readings with pagination and filtering
*/ */
export async function getAll(req: Request, res: Response): Promise<void> { export async function getAll(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
const { const {
page = '1', page = '1',
@@ -31,7 +32,14 @@ export async function getAll(req: Request, res: Response): Promise<void> {
pageSize: Math.min(parseInt(pageSize as string, 10), 100), // Max 100 per page pageSize: Math.min(parseInt(pageSize as string, 10), 100), // Max 100 per page
}; };
const result = await readingService.getAll(filters, pagination); // Pass user info for role-based filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const result = await readingService.getAll(filters, pagination, requestingUser);
res.status(200).json({ res.status(200).json({
success: true, success: true,
@@ -136,12 +144,20 @@ export async function deleteReading(req: Request, res: Response): Promise<void>
* GET /readings/summary * GET /readings/summary
* Get consumption summary statistics * Get consumption summary statistics
*/ */
export async function getSummary(req: Request, res: Response): Promise<void> { export async function getSummary(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
const { project_id } = req.query; const { project_id } = req.query;
// Pass user info for role-based filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
projectId: req.user.projectId,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const summary = await readingService.getConsumptionSummary( const summary = await readingService.getConsumptionSummary(
project_id as string | undefined project_id as string | undefined,
requestingUser
); );
res.status(200).json({ res.status(200).json({

View File

@@ -41,7 +41,13 @@ export async function getAllUsers(
sortOrder: (req.query.sortOrder as 'asc' | 'desc') || 'desc', sortOrder: (req.query.sortOrder as 'asc' | 'desc') || 'desc',
}; };
const result = await userService.getAll(filters, pagination); // Pass requesting user for scope filtering
const requestingUser = req.user ? {
roleName: req.user.roleName,
organismoOperadorId: req.user.organismoOperadorId,
} : undefined;
const result = await userService.getAll(filters, pagination, requestingUser);
res.status(200).json({ res.status(200).json({
success: true, success: true,
@@ -125,12 +131,20 @@ export async function createUser(
try { try {
const data = req.body as CreateUserInput; const data = req.body as CreateUserInput;
// If ORGANISMO_OPERADOR is creating a user, force their own organismo_operador_id
let organismoOperadorId = data.organismo_operador_id;
if (req.user?.roleName === 'ORGANISMO_OPERADOR' && req.user?.organismoOperadorId) {
organismoOperadorId = req.user.organismoOperadorId;
}
const user = await userService.create({ const user = await userService.create({
email: data.email, email: data.email,
password: data.password, password: data.password,
name: data.name, name: data.name,
avatar_url: data.avatar_url, avatar_url: data.avatar_url,
role_id: data.role_id, role_id: data.role_id,
project_id: data.project_id,
organismo_operador_id: organismoOperadorId,
is_active: data.is_active, is_active: data.is_active,
}); });

View File

@@ -2,6 +2,8 @@ import { Response, NextFunction } from 'express';
import { verifyAccessToken } from '../utils/jwt'; import { verifyAccessToken } from '../utils/jwt';
import { AuthenticatedRequest } from '../types'; import { AuthenticatedRequest } from '../types';
export { AuthenticatedRequest };
/** /**
* Middleware to authenticate JWT access tokens * Middleware to authenticate JWT access tokens
* Extracts Bearer token from Authorization header, verifies it, * Extracts Bearer token from Authorization header, verifies it,
@@ -42,6 +44,7 @@ export function authenticateToken(
roleId: (decoded as any).roleId || (decoded as any).role, roleId: (decoded as any).roleId || (decoded as any).role,
roleName: (decoded as any).roleName || (decoded as any).role, roleName: (decoded as any).roleName || (decoded as any).role,
projectId: (decoded as any).projectId, projectId: (decoded as any).projectId,
organismoOperadorId: (decoded as any).organismoOperadorId,
}; };
next(); next();

View File

@@ -16,6 +16,7 @@ import bulkUploadRoutes from './bulk-upload.routes';
import csvUploadRoutes from './csv-upload.routes'; import csvUploadRoutes from './csv-upload.routes';
import auditRoutes from './audit.routes'; import auditRoutes from './audit.routes';
import notificationRoutes from './notification.routes'; import notificationRoutes from './notification.routes';
import organismoOperadorRoutes from './organismo-operador.routes';
import testRoutes from './test.routes'; import testRoutes from './test.routes';
import systemRoutes from './system.routes'; import systemRoutes from './system.routes';
@@ -119,6 +120,17 @@ router.use('/users', userRoutes);
*/ */
router.use('/roles', roleRoutes); router.use('/roles', roleRoutes);
/**
* Organismos Operadores routes:
* - GET /organismos-operadores - List all organismos
* - GET /organismos-operadores/:id - Get organismo by ID
* - GET /organismos-operadores/:id/projects - Get organismo's projects
* - POST /organismos-operadores - Create organismo (admin only)
* - PUT /organismos-operadores/:id - Update organismo (admin only)
* - DELETE /organismos-operadores/:id - Delete organismo (admin only)
*/
router.use('/organismos-operadores', organismoOperadorRoutes);
/** /**
* TTS (The Things Stack) webhook routes: * TTS (The Things Stack) webhook routes:
* - GET /webhooks/tts/health - Health check * - GET /webhooks/tts/health - Health check

View File

@@ -7,26 +7,26 @@ const router = Router();
/** /**
* GET /meters * GET /meters
* Public endpoint - list all meters with pagination and filtering * Protected endpoint - list meters filtered by user role/scope
* Query params: page, pageSize, project_id, status, area_name, meter_type, search * Query params: page, pageSize, project_id, status, area_name, meter_type, search
* Response: { success: true, data: Meter[], pagination: {...} } * Response: { success: true, data: Meter[], pagination: {...} }
*/ */
router.get('/', meterController.getAll); router.get('/', authenticateToken, meterController.getAll);
/** /**
* GET /meters/:id * GET /meters/:id
* Public endpoint - get a single meter by ID with device info * Protected endpoint - get a single meter by ID with device info
* Response: { success: true, data: MeterWithDevice } * Response: { success: true, data: MeterWithDevice }
*/ */
router.get('/:id', meterController.getById); router.get('/:id', authenticateToken, meterController.getById);
/** /**
* GET /meters/:id/readings * GET /meters/:id/readings
* Public endpoint - get meter readings history * Protected endpoint - get meter readings history filtered by user role/scope
* Query params: start_date, end_date * Query params: start_date, end_date, page, pageSize
* Response: { success: true, data: MeterReading[] } * Response: { success: true, data: MeterReading[], pagination: {...} }
*/ */
router.get('/:id/readings', meterController.getReadings); router.get('/:id/readings', authenticateToken, meterController.getReadings);
/** /**
* POST /meters * POST /meters

View File

@@ -0,0 +1,48 @@
import { Router } from 'express';
import { authenticateToken, requireRole } from '../middleware/auth.middleware';
import * as organismoController from '../controllers/organismo-operador.controller';
const router = Router();
/**
* All routes require authentication
*/
router.use(authenticateToken);
/**
* GET /organismos-operadores
* List all organismos operadores (ADMIN and ORGANISMO_OPERADOR)
*/
router.get('/', requireRole('ADMIN', 'ORGANISMO_OPERADOR'), organismoController.getAll);
/**
* GET /organismos-operadores/:id
* Get a single organismo by ID
*/
router.get('/:id', requireRole('ADMIN', 'ORGANISMO_OPERADOR'), organismoController.getById);
/**
* GET /organismos-operadores/:id/projects
* Get projects belonging to an organismo
*/
router.get('/:id/projects', requireRole('ADMIN', 'ORGANISMO_OPERADOR'), organismoController.getProjects);
/**
* POST /organismos-operadores
* Create a new organismo operador (ADMIN only)
*/
router.post('/', requireRole('ADMIN'), organismoController.create);
/**
* PUT /organismos-operadores/:id
* Update an organismo operador (ADMIN only)
*/
router.put('/:id', requireRole('ADMIN'), organismoController.update);
/**
* DELETE /organismos-operadores/:id
* Delete an organismo operador (ADMIN only)
*/
router.delete('/:id', requireRole('ADMIN'), organismoController.remove);
export default router;

View File

@@ -11,7 +11,7 @@ const router = Router();
* Query params: page, pageSize, status, area_name, search * Query params: page, pageSize, status, area_name, search
* Response: { success: true, data: Project[], pagination: {...} } * Response: { success: true, data: Project[], pagination: {...} }
*/ */
router.get('/', projectController.getAll); router.get('/', authenticateToken, projectController.getAll);
/** /**
* GET /projects/:id * GET /projects/:id

View File

@@ -10,15 +10,15 @@ const router = Router();
* Query params: project_id * Query params: project_id
* Response: { success: true, data: { totalReadings, totalMeters, avgReading, lastReadingDate } } * Response: { success: true, data: { totalReadings, totalMeters, avgReading, lastReadingDate } }
*/ */
router.get('/summary', readingController.getSummary); router.get('/summary', authenticateToken, readingController.getSummary);
/** /**
* GET /readings * GET /readings
* Public endpoint - list all readings with pagination and filtering * Protected endpoint - list all readings with pagination and filtering
* Query params: page, pageSize, meter_id, project_id, area_name, start_date, end_date, reading_type * Query params: page, pageSize, meter_id, project_id, area_name, start_date, end_date, reading_type
* Response: { success: true, data: Reading[], pagination: {...} } * Response: { success: true, data: Reading[], pagination: {...} }
*/ */
router.get('/', readingController.getAll); router.get('/', authenticateToken, readingController.getAll);
/** /**
* GET /readings/:id * GET /readings/:id

View File

@@ -20,7 +20,7 @@ router.use(authenticateToken);
* Query params: role_id, is_active, search, page, limit, sortBy, sortOrder * Query params: role_id, is_active, search, page, limit, sortBy, sortOrder
* Response: { success, message, data: User[], pagination } * Response: { success, message, data: User[], pagination }
*/ */
router.get('/', requireRole('ADMIN'), userController.getAllUsers); router.get('/', requireRole('ADMIN', 'ORGANISMO_OPERADOR'), userController.getAllUsers);
/** /**
* GET /users/:id * GET /users/:id
@@ -35,7 +35,7 @@ router.get('/:id', userController.getUserById);
* Body: { email, password, name, avatar_url?, role_id, is_active? } * Body: { email, password, name, avatar_url?, role_id, is_active? }
* Response: { success, message, data: User } * Response: { success, message, data: User }
*/ */
router.post('/', requireRole('ADMIN'), validateCreateUser, userController.createUser); router.post('/', requireRole('ADMIN', 'ORGANISMO_OPERADOR'), validateCreateUser, userController.createUser);
/** /**
* PUT /users/:id * PUT /users/:id

View File

@@ -29,6 +29,8 @@ export interface UserProfile {
role: string; role: string;
avatarUrl?: string | null; avatarUrl?: string | null;
projectId?: string | null; projectId?: string | null;
organismoOperadorId?: string | null;
organismoName?: string | null;
createdAt: Date; createdAt: Date;
} }
@@ -48,7 +50,7 @@ export async function login(
email: string, email: string,
password: string password: string
): Promise<LoginResult> { ): Promise<LoginResult> {
// Find user by email with role name // Find user by email with role name and organismo
const userResult = await query<{ const userResult = await query<{
id: string; id: string;
email: string; email: string;
@@ -57,9 +59,10 @@ export async function login(
avatar_url: string | null; avatar_url: string | null;
role_name: string; role_name: string;
project_id: string | null; project_id: string | null;
organismo_operador_id: string | null;
created_at: Date; created_at: Date;
}>( }>(
`SELECT u.id, u.email, u.name, u.password_hash, u.avatar_url, r.name as role_name, u.project_id, u.created_at `SELECT u.id, u.email, u.name, u.password_hash, u.avatar_url, r.name as role_name, u.project_id, u.organismo_operador_id, u.created_at
FROM users u FROM users u
JOIN roles r ON u.role_id = r.id JOIN roles r ON u.role_id = r.id
WHERE LOWER(u.email) = LOWER($1) AND u.is_active = true WHERE LOWER(u.email) = LOWER($1) AND u.is_active = true
@@ -86,6 +89,7 @@ export async function login(
roleId: user.id, roleId: user.id,
roleName: user.role_name, roleName: user.role_name,
projectId: user.project_id, projectId: user.project_id,
organismoOperadorId: user.organismo_operador_id,
}); });
const refreshToken = generateRefreshToken({ const refreshToken = generateRefreshToken({
@@ -174,8 +178,9 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri
email: string; email: string;
role_name: string; role_name: string;
project_id: string | null; project_id: string | null;
organismo_operador_id: string | null;
}>( }>(
`SELECT u.id, u.email, r.name as role_name, u.project_id `SELECT u.id, u.email, r.name as role_name, u.project_id, u.organismo_operador_id
FROM users u FROM users u
JOIN roles r ON u.role_id = r.id JOIN roles r ON u.role_id = r.id
WHERE u.id = $1 AND u.is_active = true WHERE u.id = $1 AND u.is_active = true
@@ -195,6 +200,7 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri
roleId: user.id, roleId: user.id,
roleName: user.role_name, roleName: user.role_name,
projectId: user.project_id, projectId: user.project_id,
organismoOperadorId: user.organismo_operador_id,
}); });
return { accessToken }; return { accessToken };
@@ -232,11 +238,15 @@ export async function getMe(userId: string): Promise<UserProfile> {
avatar_url: string | null; avatar_url: string | null;
role_name: string; role_name: string;
project_id: string | null; project_id: string | null;
organismo_operador_id: string | null;
organismo_name: string | null;
created_at: Date; created_at: Date;
}>( }>(
`SELECT u.id, u.email, u.name, u.avatar_url, r.name as role_name, u.project_id, u.created_at `SELECT u.id, u.email, u.name, u.avatar_url, r.name as role_name, u.project_id,
u.organismo_operador_id, oo.name as organismo_name, u.created_at
FROM users u FROM users u
JOIN roles r ON u.role_id = r.id JOIN roles r ON u.role_id = r.id
LEFT JOIN organismos_operadores oo ON u.organismo_operador_id = oo.id
WHERE u.id = $1 AND u.is_active = true WHERE u.id = $1 AND u.is_active = true
LIMIT 1`, LIMIT 1`,
[userId] [userId]
@@ -255,6 +265,8 @@ export async function getMe(userId: string): Promise<UserProfile> {
role: user.role_name, role: user.role_name,
avatarUrl: user.avatar_url, avatarUrl: user.avatar_url,
projectId: user.project_id, projectId: user.project_id,
organismoOperadorId: user.organismo_operador_id,
organismoName: user.organismo_name,
createdAt: user.created_at, createdAt: user.created_at,
}; };
} }

View File

@@ -76,7 +76,7 @@ export interface PaginatedResult<T> {
export async function getAll( export async function getAll(
filters?: ConcentratorFilters, filters?: ConcentratorFilters,
pagination?: PaginationOptions, pagination?: PaginationOptions,
requestingUser?: { roleName: string; projectId?: string | null } requestingUser?: { roleName: string; projectId?: string | null; organismoOperadorId?: string | null }
): Promise<PaginatedResult<Concentrator>> { ): Promise<PaginatedResult<Concentrator>> {
const page = pagination?.page || 1; const page = pagination?.page || 1;
const limit = pagination?.limit || 10; const limit = pagination?.limit || 10;
@@ -89,15 +89,19 @@ export async function getAll(
const params: unknown[] = []; const params: unknown[] = [];
let paramIndex = 1; let paramIndex = 1;
// Role-based filtering: OPERATOR users can only see their assigned project // Role-based filtering: 3-level hierarchy
if (requestingUser && requestingUser.roleName !== 'ADMIN' && requestingUser.projectId) { if (requestingUser && requestingUser.roleName === 'ORGANISMO_OPERADOR' && requestingUser.organismoOperadorId) {
conditions.push(`project_id IN (SELECT id FROM projects WHERE organismo_operador_id = $${paramIndex})`);
params.push(requestingUser.organismoOperadorId);
paramIndex++;
} else if (requestingUser && requestingUser.roleName !== 'ADMIN' && requestingUser.projectId) {
conditions.push(`project_id = $${paramIndex}`); conditions.push(`project_id = $${paramIndex}`);
params.push(requestingUser.projectId); params.push(requestingUser.projectId);
paramIndex++; paramIndex++;
} }
// Additional filter by project_id (only applies if user is ADMIN or no user context) // Additional filter by project_id (applies if user is ADMIN, ORGANISMO_OPERADOR, or no user context)
if (filters?.project_id && (!requestingUser || requestingUser.roleName === 'ADMIN')) { if (filters?.project_id && (!requestingUser || requestingUser.roleName === 'ADMIN' || requestingUser.roleName === 'ORGANISMO_OPERADOR')) {
conditions.push(`project_id = $${paramIndex}`); conditions.push(`project_id = $${paramIndex}`);
params.push(filters.project_id); params.push(filters.project_id);
paramIndex++; paramIndex++;

View File

@@ -73,6 +73,11 @@ export interface Meter {
// Additional Data // Additional Data
data?: Record<string, any> | null; data?: Record<string, any> | null;
// Address & Account Fields
address?: string | null;
cespt_account?: string | null;
cadastral_key?: string | null;
} }
/** /**
@@ -165,6 +170,9 @@ export interface CreateMeterInput {
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
data?: Record<string, any>; data?: Record<string, any>;
address?: string;
cespt_account?: string;
cadastral_key?: string;
} }
/** /**
@@ -216,6 +224,9 @@ export interface UpdateMeterInput {
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
data?: Record<string, any>; data?: Record<string, any>;
address?: string;
cespt_account?: string;
cadastral_key?: string;
} }
/** /**
@@ -227,7 +238,7 @@ export interface UpdateMeterInput {
export async function getAll( export async function getAll(
filters?: MeterFilters, filters?: MeterFilters,
pagination?: PaginationParams, pagination?: PaginationParams,
requestingUser?: { roleName: string; projectId?: string | null } requestingUser?: { roleName: string; projectId?: string | null; organismoOperadorId?: string | null }
): Promise<PaginatedResult<MeterWithDetails>> { ): Promise<PaginatedResult<MeterWithDetails>> {
const page = pagination?.page || 1; const page = pagination?.page || 1;
const pageSize = pagination?.pageSize || 50; const pageSize = pagination?.pageSize || 50;
@@ -237,8 +248,12 @@ export async function getAll(
const params: unknown[] = []; const params: unknown[] = [];
let paramIndex = 1; let paramIndex = 1;
// Role-based filtering: OPERATOR users can only see meters from their assigned project // Role-based filtering: 3-level hierarchy
if (requestingUser && requestingUser.roleName !== 'ADMIN' && requestingUser.projectId) { if (requestingUser && requestingUser.roleName === 'ORGANISMO_OPERADOR' && requestingUser.organismoOperadorId) {
conditions.push(`c.project_id IN (SELECT id FROM projects WHERE organismo_operador_id = $${paramIndex})`);
params.push(requestingUser.organismoOperadorId);
paramIndex++;
} else if (requestingUser && requestingUser.roleName !== 'ADMIN' && requestingUser.projectId) {
conditions.push(`c.project_id = $${paramIndex}`); conditions.push(`c.project_id = $${paramIndex}`);
params.push(requestingUser.projectId); params.push(requestingUser.projectId);
paramIndex++; paramIndex++;
@@ -250,8 +265,8 @@ export async function getAll(
paramIndex++; paramIndex++;
} }
// Additional filter by project_id (only applies if user is ADMIN or no user context) // Additional filter by project_id (applies if user is ADMIN, ORGANISMO_OPERADOR, or no user context)
if (filters?.project_id && (!requestingUser || requestingUser.roleName === 'ADMIN')) { if (filters?.project_id && (!requestingUser || requestingUser.roleName === 'ADMIN' || requestingUser.roleName === 'ORGANISMO_OPERADOR')) {
conditions.push(`c.project_id = $${paramIndex}`); conditions.push(`c.project_id = $${paramIndex}`);
params.push(filters.project_id); params.push(filters.project_id);
paramIndex++; paramIndex++;
@@ -296,7 +311,8 @@ export async function getAll(
c.name as concentrator_name, c.serial_number as concentrator_serial, c.name as concentrator_name, c.serial_number as concentrator_serial,
c.project_id, p.name as project_name, c.project_id, p.name as project_name,
m.protocol, m.voltage, m.signal, m.leakage_status, m.burst_status, m.protocol, m.voltage, m.signal, m.leakage_status, m.burst_status,
m.current_flow, m.total_flow_reverse, m.manufacturer, m.latitude, m.longitude m.current_flow, m.total_flow_reverse, m.manufacturer, m.latitude, m.longitude,
m.address, m.cespt_account, m.cadastral_key
FROM meters m FROM meters m
JOIN concentrators c ON m.concentrator_id = c.id JOIN concentrators c ON m.concentrator_id = c.id
JOIN projects p ON c.project_id = p.id JOIN projects p ON c.project_id = p.id
@@ -329,7 +345,8 @@ export async function getById(id: string): Promise<MeterWithDetails | null> {
m.status, m.last_reading_value, m.last_reading_at, m.installation_date, m.status, m.last_reading_value, m.last_reading_at, m.installation_date,
m.created_at, m.updated_at, m.created_at, m.updated_at,
c.name as concentrator_name, c.serial_number as concentrator_serial, c.name as concentrator_name, c.serial_number as concentrator_serial,
c.project_id, p.name as project_name c.project_id, p.name as project_name,
m.address, m.cespt_account, m.cadastral_key
FROM meters m FROM meters m
JOIN concentrators c ON m.concentrator_id = c.id JOIN concentrators c ON m.concentrator_id = c.id
JOIN projects p ON c.project_id = p.id JOIN projects p ON c.project_id = p.id
@@ -360,8 +377,8 @@ export async function create(data: CreateMeterInput): Promise<Meter> {
} }
const result = await query<Meter>( const result = await query<Meter>(
`INSERT INTO meters (serial_number, meter_id, name, project_id, concentrator_id, location, type, status, installation_date) `INSERT INTO meters (serial_number, meter_id, name, project_id, concentrator_id, location, type, status, installation_date, address, cespt_account, cadastral_key)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *`, RETURNING *`,
[ [
data.serial_number, data.serial_number,
@@ -373,6 +390,9 @@ export async function create(data: CreateMeterInput): Promise<Meter> {
data.type || 'LORA', data.type || 'LORA',
data.status || 'ACTIVE', data.status || 'ACTIVE',
data.installation_date || null, data.installation_date || null,
data.address || null,
data.cespt_account || null,
data.cadastral_key || null,
] ]
); );
@@ -454,6 +474,24 @@ export async function update(id: string, data: UpdateMeterInput): Promise<Meter
paramIndex++; paramIndex++;
} }
if (data.address !== undefined) {
updates.push(`address = $${paramIndex}`);
params.push(data.address);
paramIndex++;
}
if (data.cespt_account !== undefined) {
updates.push(`cespt_account = $${paramIndex}`);
params.push(data.cespt_account);
paramIndex++;
}
if (data.cadastral_key !== undefined) {
updates.push(`cadastral_key = $${paramIndex}`);
params.push(data.cadastral_key);
paramIndex++;
}
updates.push(`updated_at = NOW()`); updates.push(`updated_at = NOW()`);
if (updates.length === 1) { if (updates.length === 1) {

View File

@@ -100,7 +100,7 @@ export async function getAllForUser(
ORDER BY is_read ASC, ${safeSortBy} ${safeSortOrder} ORDER BY is_read ASC, ${safeSortBy} ${safeSortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`; `;
const dataResult = await query(dataQuery, [...params, limit, offset]); const dataResult = await query<Notification>(dataQuery, [...params, limit, offset]);
const totalPages = Math.ceil(total / limit); const totalPages = Math.ceil(total / limit);
@@ -144,7 +144,7 @@ export async function getById(id: string, userId: string): Promise<Notification
FROM notifications FROM notifications
WHERE id = $1 AND user_id = $2 WHERE id = $1 AND user_id = $2
`; `;
const result = await query(sql, [id, userId]); const result = await query<Notification>(sql, [id, userId]);
return result.rows[0] || null; return result.rows[0] || null;
} }
@@ -167,7 +167,7 @@ export async function create(input: CreateNotificationInput): Promise<Notificati
RETURNING * RETURNING *
`; `;
const result = await query(sql, [ const result = await query<Notification>(sql, [
input.user_id, input.user_id,
input.meter_id || null, input.meter_id || null,
input.notification_type, input.notification_type,
@@ -176,7 +176,7 @@ export async function create(input: CreateNotificationInput): Promise<Notificati
input.meter_serial_number || null, input.meter_serial_number || null,
input.flow_value || null, input.flow_value || null,
]); ]);
return result.rows[0]; return result.rows[0];
} }
@@ -193,7 +193,7 @@ export async function markAsRead(id: string, userId: string): Promise<Notificati
WHERE id = $1 AND user_id = $2 WHERE id = $1 AND user_id = $2
RETURNING * RETURNING *
`; `;
const result = await query(sql, [id, userId]); const result = await query<Notification>(sql, [id, userId]);
return result.rows[0] || null; return result.rows[0] || null;
} }
@@ -269,7 +269,7 @@ export async function getMetersWithNegativeFlow(): Promise<Array<{
WHERE m.last_reading_value < 0 WHERE m.last_reading_value < 0
AND m.status = 'ACTIVE' AND m.status = 'ACTIVE'
`; `;
const result = await query(sql); const result = await query<{ id: string; serial_number: string; name: string; last_reading_value: number; concentrator_id: string; project_id: string }>(sql);
return result.rows; return result.rows;
} }

View File

@@ -0,0 +1,224 @@
import { query } from '../config/database';
export interface OrganismoOperador {
id: string;
name: string;
description: string | null;
region: string | null;
contact_name: string | null;
contact_email: string | null;
is_active: boolean;
created_at: Date;
updated_at: Date;
}
export interface OrganismoOperadorWithStats extends OrganismoOperador {
project_count: number;
user_count: number;
}
export interface CreateOrganismoInput {
name: string;
description?: string;
region?: string;
contact_name?: string;
contact_email?: string;
is_active?: boolean;
}
export interface UpdateOrganismoInput {
name?: string;
description?: string;
region?: string;
contact_name?: string;
contact_email?: string;
is_active?: boolean;
}
export interface PaginationParams {
page: number;
pageSize: number;
}
export interface PaginatedResult<T> {
data: T[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
/**
* Get all organismos operadores with pagination
*/
export async function getAll(
pagination?: PaginationParams
): Promise<PaginatedResult<OrganismoOperadorWithStats>> {
const page = pagination?.page || 1;
const pageSize = pagination?.pageSize || 50;
const offset = (page - 1) * pageSize;
const countResult = await query<{ total: string }>(
'SELECT COUNT(*) as total FROM organismos_operadores'
);
const total = parseInt(countResult.rows[0]?.total || '0', 10);
const result = await query<OrganismoOperadorWithStats>(
`SELECT oo.*,
COALESCE((SELECT COUNT(*) FROM projects p WHERE p.organismo_operador_id = oo.id), 0)::int as project_count,
COALESCE((SELECT COUNT(*) FROM users u WHERE u.organismo_operador_id = oo.id), 0)::int as user_count
FROM organismos_operadores oo
ORDER BY oo.created_at DESC
LIMIT $1 OFFSET $2`,
[pageSize, offset]
);
return {
data: result.rows,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
/**
* Get a single organismo by ID with stats
*/
export async function getById(id: string): Promise<OrganismoOperadorWithStats | null> {
const result = await query<OrganismoOperadorWithStats>(
`SELECT oo.*,
COALESCE((SELECT COUNT(*) FROM projects p WHERE p.organismo_operador_id = oo.id), 0)::int as project_count,
COALESCE((SELECT COUNT(*) FROM users u WHERE u.organismo_operador_id = oo.id), 0)::int as user_count
FROM organismos_operadores oo
WHERE oo.id = $1`,
[id]
);
return result.rows[0] || null;
}
/**
* Create a new organismo operador
*/
export async function create(data: CreateOrganismoInput): Promise<OrganismoOperador> {
const result = await query<OrganismoOperador>(
`INSERT INTO organismos_operadores (name, description, region, contact_name, contact_email, is_active)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[
data.name,
data.description || null,
data.region || null,
data.contact_name || null,
data.contact_email || null,
data.is_active ?? true,
]
);
return result.rows[0];
}
/**
* Update an existing organismo operador
*/
export async function update(id: string, data: UpdateOrganismoInput): Promise<OrganismoOperador | null> {
const updates: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (data.name !== undefined) {
updates.push(`name = $${paramIndex}`);
params.push(data.name);
paramIndex++;
}
if (data.description !== undefined) {
updates.push(`description = $${paramIndex}`);
params.push(data.description);
paramIndex++;
}
if (data.region !== undefined) {
updates.push(`region = $${paramIndex}`);
params.push(data.region);
paramIndex++;
}
if (data.contact_name !== undefined) {
updates.push(`contact_name = $${paramIndex}`);
params.push(data.contact_name);
paramIndex++;
}
if (data.contact_email !== undefined) {
updates.push(`contact_email = $${paramIndex}`);
params.push(data.contact_email);
paramIndex++;
}
if (data.is_active !== undefined) {
updates.push(`is_active = $${paramIndex}`);
params.push(data.is_active);
paramIndex++;
}
if (updates.length === 0) {
return getById(id) as Promise<OrganismoOperador | null>;
}
updates.push(`updated_at = NOW()`);
params.push(id);
const result = await query<OrganismoOperador>(
`UPDATE organismos_operadores SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
params
);
return result.rows[0] || null;
}
/**
* Delete an organismo operador
*/
export async function remove(id: string): Promise<boolean> {
// Check for dependent projects
const projectCheck = await query<{ count: string }>(
'SELECT COUNT(*) as count FROM projects WHERE organismo_operador_id = $1',
[id]
);
const projectCount = parseInt(projectCheck.rows[0]?.count || '0', 10);
if (projectCount > 0) {
throw new Error(`Cannot delete organismo: ${projectCount} project(s) are associated with it`);
}
// Check for dependent users
const userCheck = await query<{ count: string }>(
'SELECT COUNT(*) as count FROM users WHERE organismo_operador_id = $1',
[id]
);
const userCount = parseInt(userCheck.rows[0]?.count || '0', 10);
if (userCount > 0) {
throw new Error(`Cannot delete organismo: ${userCount} user(s) are associated with it`);
}
const result = await query('DELETE FROM organismos_operadores WHERE id = $1', [id]);
return (result.rowCount || 0) > 0;
}
/**
* Get projects belonging to an organismo
*/
export async function getProjectsByOrganismo(organismoId: string): Promise<{ id: string; name: string; status: string }[]> {
const result = await query<{ id: string; name: string; status: string }>(
'SELECT id, name, status FROM projects WHERE organismo_operador_id = $1 ORDER BY name',
[organismoId]
);
return result.rows;
}

View File

@@ -65,7 +65,8 @@ export interface PaginatedResult<T> {
*/ */
export async function getAll( export async function getAll(
filters?: ProjectFilters, filters?: ProjectFilters,
pagination?: PaginationParams pagination?: PaginationParams,
requestingUser?: { roleName: string; projectId?: string | null; organismoOperadorId?: string | null }
): Promise<PaginatedResult<Project>> { ): Promise<PaginatedResult<Project>> {
const page = pagination?.page || 1; const page = pagination?.page || 1;
const pageSize = pagination?.pageSize || 10; const pageSize = pagination?.pageSize || 10;
@@ -76,6 +77,17 @@ export async function getAll(
const params: unknown[] = []; const params: unknown[] = [];
let paramIndex = 1; let paramIndex = 1;
// Role-based filtering: 3-level hierarchy
if (requestingUser && requestingUser.roleName === 'ORGANISMO_OPERADOR' && requestingUser.organismoOperadorId) {
conditions.push(`organismo_operador_id = $${paramIndex}`);
params.push(requestingUser.organismoOperadorId);
paramIndex++;
} else if (requestingUser && requestingUser.roleName !== 'ADMIN' && requestingUser.projectId) {
conditions.push(`id = $${paramIndex}`);
params.push(requestingUser.projectId);
paramIndex++;
}
if (filters?.status) { if (filters?.status) {
conditions.push(`status = $${paramIndex}`); conditions.push(`status = $${paramIndex}`);
params.push(filters.status); params.push(filters.status);
@@ -103,7 +115,7 @@ export async function getAll(
// Get paginated data // Get paginated data
const dataQuery = ` const dataQuery = `
SELECT id, name, description, area_name, location, status, meter_type_id, created_by, created_at, updated_at SELECT id, name, description, area_name, location, status, meter_type_id, organismo_operador_id, created_by, created_at, updated_at
FROM projects FROM projects
${whereClause} ${whereClause}
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -131,7 +143,7 @@ export async function getAll(
*/ */
export async function getById(id: string): Promise<Project | null> { export async function getById(id: string): Promise<Project | null> {
const result = await query<Project>( const result = await query<Project>(
`SELECT id, name, description, area_name, location, status, meter_type_id, created_by, created_at, updated_at `SELECT id, name, description, area_name, location, status, meter_type_id, organismo_operador_id, created_by, created_at, updated_at
FROM projects FROM projects
WHERE id = $1`, WHERE id = $1`,
[id] [id]
@@ -148,9 +160,9 @@ export async function getById(id: string): Promise<Project | null> {
*/ */
export async function create(data: CreateProjectInput, userId: string): Promise<Project> { export async function create(data: CreateProjectInput, userId: string): Promise<Project> {
const result = await query<Project>( const result = await query<Project>(
`INSERT INTO projects (name, description, area_name, location, status, meter_type_id, created_by) `INSERT INTO projects (name, description, area_name, location, status, meter_type_id, organismo_operador_id, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, name, description, area_name, location, status, meter_type_id, created_by, created_at, updated_at`, RETURNING id, name, description, area_name, location, status, meter_type_id, organismo_operador_id, created_by, created_at, updated_at`,
[ [
data.name, data.name,
data.description || null, data.description || null,
@@ -158,6 +170,7 @@ export async function create(data: CreateProjectInput, userId: string): Promise<
data.location || null, data.location || null,
data.status || 'ACTIVE', data.status || 'ACTIVE',
data.meter_type_id || null, data.meter_type_id || null,
data.organismo_operador_id || null,
userId, userId,
] ]
); );
@@ -213,6 +226,12 @@ export async function update(id: string, data: UpdateProjectInput): Promise<Proj
paramIndex++; paramIndex++;
} }
if (data.organismo_operador_id !== undefined) {
updates.push(`organismo_operador_id = $${paramIndex}`);
params.push(data.organismo_operador_id);
paramIndex++;
}
// Always update the updated_at timestamp // Always update the updated_at timestamp
updates.push(`updated_at = NOW()`); updates.push(`updated_at = NOW()`);
@@ -227,7 +246,7 @@ export async function update(id: string, data: UpdateProjectInput): Promise<Proj
`UPDATE projects `UPDATE projects
SET ${updates.join(', ')} SET ${updates.join(', ')}
WHERE id = $${paramIndex} WHERE id = $${paramIndex}
RETURNING id, name, description, area_name, location, status, meter_type_id, created_by, created_at, updated_at`, RETURNING id, name, description, area_name, location, status, meter_type_id, organismo_operador_id, created_by, created_at, updated_at`,
params params
); );
@@ -347,7 +366,7 @@ export async function deactivateProjectAndUnassignUsers(id: string): Promise<Pro
`UPDATE projects `UPDATE projects
SET status = 'INACTIVE', updated_at = NOW() SET status = 'INACTIVE', updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, name, description, area_name, location, status, meter_type_id, created_by, created_at, updated_at`, RETURNING id, name, description, area_name, location, status, meter_type_id, organismo_operador_id, created_by, created_at, updated_at`,
[id] [id]
); );

View File

@@ -82,7 +82,8 @@ export interface CreateReadingInput {
*/ */
export async function getAll( export async function getAll(
filters?: ReadingFilters, filters?: ReadingFilters,
pagination?: PaginationParams pagination?: PaginationParams,
requestingUser?: { roleName: string; projectId?: string | null; organismoOperadorId?: string | null }
): Promise<PaginatedResult<MeterReadingWithMeter>> { ): Promise<PaginatedResult<MeterReadingWithMeter>> {
const page = pagination?.page || 1; const page = pagination?.page || 1;
const pageSize = pagination?.pageSize || 50; const pageSize = pagination?.pageSize || 50;
@@ -93,6 +94,17 @@ export async function getAll(
const params: unknown[] = []; const params: unknown[] = [];
let paramIndex = 1; let paramIndex = 1;
// Role-based filtering: 3-level hierarchy
if (requestingUser && requestingUser.roleName === 'ORGANISMO_OPERADOR' && requestingUser.organismoOperadorId) {
conditions.push(`c.project_id IN (SELECT id FROM projects WHERE organismo_operador_id = $${paramIndex})`);
params.push(requestingUser.organismoOperadorId);
paramIndex++;
} else if (requestingUser && requestingUser.roleName !== 'ADMIN' && requestingUser.projectId) {
conditions.push(`c.project_id = $${paramIndex}`);
params.push(requestingUser.projectId);
paramIndex++;
}
if (filters?.meter_id) { if (filters?.meter_id) {
conditions.push(`mr.meter_id = $${paramIndex}`); conditions.push(`mr.meter_id = $${paramIndex}`);
params.push(filters.meter_id); params.push(filters.meter_id);
@@ -246,20 +258,38 @@ export async function deleteReading(id: string): Promise<boolean> {
* @param projectId - Optional project ID to filter * @param projectId - Optional project ID to filter
* @returns Summary statistics * @returns Summary statistics
*/ */
export async function getConsumptionSummary(projectId?: string): Promise<{ export async function getConsumptionSummary(
projectId?: string,
requestingUser?: { roleName: string; projectId?: string | null; organismoOperadorId?: string | null }
): Promise<{
totalReadings: number; totalReadings: number;
totalMeters: number; totalMeters: number;
avgReading: number; avgReading: number;
lastReadingDate: Date | null; lastReadingDate: Date | null;
}> { }> {
const params: unknown[] = []; const params: unknown[] = [];
let whereClause = ''; const conditions: string[] = [];
let paramIndex = 1;
if (projectId) { if (projectId) {
whereClause = 'WHERE c.project_id = $1'; conditions.push(`c.project_id = $${paramIndex}`);
params.push(projectId); params.push(projectId);
paramIndex++;
} }
// Role-based filtering
if (requestingUser && requestingUser.roleName === 'ORGANISMO_OPERADOR' && requestingUser.organismoOperadorId) {
conditions.push(`c.project_id IN (SELECT id FROM projects WHERE organismo_operador_id = $${paramIndex})`);
params.push(requestingUser.organismoOperadorId);
paramIndex++;
} else if (requestingUser && requestingUser.roleName !== 'ADMIN' && requestingUser.projectId) {
conditions.push(`c.project_id = $${paramIndex}`);
params.push(requestingUser.projectId);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await query<{ const result = await query<{
total_readings: string; total_readings: string;
total_meters: string; total_meters: string;

View File

@@ -34,7 +34,8 @@ export interface PaginatedUsers {
*/ */
export async function getAll( export async function getAll(
filters?: UserFilter, filters?: UserFilter,
pagination?: PaginationParams pagination?: PaginationParams,
requestingUser?: { roleName: string; organismoOperadorId?: string | null }
): Promise<PaginatedUsers> { ): Promise<PaginatedUsers> {
const page = pagination?.page || 1; const page = pagination?.page || 1;
const limit = pagination?.limit || 10; const limit = pagination?.limit || 10;
@@ -47,6 +48,13 @@ export async function getAll(
const params: unknown[] = []; const params: unknown[] = [];
let paramIndex = 1; let paramIndex = 1;
// Role-based filtering: ORGANISMO_OPERADOR sees only users of their organismo
if (requestingUser && requestingUser.roleName === 'ORGANISMO_OPERADOR' && requestingUser.organismoOperadorId) {
conditions.push(`u.organismo_operador_id = $${paramIndex}`);
params.push(requestingUser.organismoOperadorId);
paramIndex++;
}
if (filters?.role_id !== undefined) { if (filters?.role_id !== undefined) {
conditions.push(`u.role_id = $${paramIndex}`); conditions.push(`u.role_id = $${paramIndex}`);
params.push(filters.role_id); params.push(filters.role_id);
@@ -83,7 +91,7 @@ export async function getAll(
const countResult = await query<{ total: string }>(countQuery, params); const countResult = await query<{ total: string }>(countQuery, params);
const total = parseInt(countResult.rows[0].total, 10); const total = parseInt(countResult.rows[0].total, 10);
// Get users with role name // Get users with role name and organismo info
const usersQuery = ` const usersQuery = `
SELECT SELECT
u.id, u.id,
@@ -94,12 +102,20 @@ export async function getAll(
r.name as role_name, r.name as role_name,
r.description as role_description, r.description as role_description,
u.project_id, u.project_id,
u.organismo_operador_id,
oo.name as organismo_name,
u.is_active, u.is_active,
u.last_login, u.last_login,
u.phone,
u.street,
u.city,
u.state,
u.zip_code,
u.created_at, u.created_at,
u.updated_at u.updated_at
FROM users u FROM users u
LEFT JOIN roles r ON u.role_id = r.id LEFT JOIN roles r ON u.role_id = r.id
LEFT JOIN organismos_operadores oo ON u.organismo_operador_id = oo.id
${whereClause} ${whereClause}
ORDER BY u.${safeSortBy} ${safeSortOrder} ORDER BY u.${safeSortBy} ${safeSortOrder}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
@@ -124,8 +140,15 @@ export async function getAll(
} }
: undefined, : undefined,
project_id: row.project_id, project_id: row.project_id,
organismo_operador_id: row.organismo_operador_id,
organismo_name: row.organismo_name,
is_active: row.is_active, is_active: row.is_active,
last_login: row.last_login, last_login: row.last_login,
phone: row.phone,
street: row.street,
city: row.city,
state: row.state,
zip_code: row.zip_code,
created_at: row.created_at, created_at: row.created_at,
updated_at: row.updated_at, updated_at: row.updated_at,
})); }));
@@ -163,12 +186,20 @@ export async function getById(id: string): Promise<UserPublic | null> {
r.description as role_description, r.description as role_description,
r.permissions as role_permissions, r.permissions as role_permissions,
u.project_id, u.project_id,
u.organismo_operador_id,
oo.name as organismo_name,
u.is_active, u.is_active,
u.last_login, u.last_login,
u.phone,
u.street,
u.city,
u.state,
u.zip_code,
u.created_at, u.created_at,
u.updated_at u.updated_at
FROM users u FROM users u
LEFT JOIN roles r ON u.role_id = r.id LEFT JOIN roles r ON u.role_id = r.id
LEFT JOIN organismos_operadores oo ON u.organismo_operador_id = oo.id
WHERE u.id = $1 WHERE u.id = $1
`, `,
[id] [id]
@@ -196,8 +227,15 @@ export async function getById(id: string): Promise<UserPublic | null> {
} }
: undefined, : undefined,
project_id: row.project_id, project_id: row.project_id,
organismo_operador_id: row.organismo_operador_id,
organismo_name: row.organismo_name,
is_active: row.is_active, is_active: row.is_active,
last_login: row.last_login, last_login: row.last_login,
phone: row.phone,
street: row.street,
city: row.city,
state: row.state,
zip_code: row.zip_code,
created_at: row.created_at, created_at: row.created_at,
updated_at: row.updated_at, updated_at: row.updated_at,
}; };
@@ -240,7 +278,13 @@ export async function create(data: {
avatar_url?: string | null; avatar_url?: string | null;
role_id: string; role_id: string;
project_id?: string | null; project_id?: string | null;
organismo_operador_id?: string | null;
is_active?: boolean; is_active?: boolean;
phone?: string | null;
street?: string | null;
city?: string | null;
state?: string | null;
zip_code?: string | null;
}): Promise<UserPublic> { }): Promise<UserPublic> {
// Check if email already exists // Check if email already exists
const existingUser = await getByEmail(data.email); const existingUser = await getByEmail(data.email);
@@ -253,9 +297,9 @@ export async function create(data: {
const result = await query( const result = await query(
` `
INSERT INTO users (email, password_hash, name, avatar_url, role_id, project_id, is_active) INSERT INTO users (email, password_hash, name, avatar_url, role_id, project_id, organismo_operador_id, is_active, phone, street, city, state, zip_code)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id, email, name, avatar_url, role_id, project_id, is_active, last_login, created_at, updated_at RETURNING id, email, name, avatar_url, role_id, project_id, organismo_operador_id, is_active, last_login, created_at, updated_at
`, `,
[ [
data.email.toLowerCase(), data.email.toLowerCase(),
@@ -264,7 +308,13 @@ export async function create(data: {
data.avatar_url ?? null, data.avatar_url ?? null,
data.role_id, data.role_id,
data.project_id ?? null, data.project_id ?? null,
data.organismo_operador_id ?? null,
data.is_active ?? true, data.is_active ?? true,
data.phone ?? null,
data.street ?? null,
data.city ?? null,
data.state ?? null,
data.zip_code ?? null,
] ]
); );
@@ -288,7 +338,13 @@ export async function update(
avatar_url?: string | null; avatar_url?: string | null;
role_id?: string; role_id?: string;
project_id?: string | null; project_id?: string | null;
organismo_operador_id?: string | null;
is_active?: boolean; is_active?: boolean;
phone?: string | null;
street?: string | null;
city?: string | null;
state?: string | null;
zip_code?: string | null;
} }
): Promise<UserPublic | null> { ): Promise<UserPublic | null> {
// Check if user exists // Check if user exists
@@ -340,12 +396,48 @@ export async function update(
paramIndex++; paramIndex++;
} }
if (data.organismo_operador_id !== undefined) {
updates.push(`organismo_operador_id = $${paramIndex}`);
params.push(data.organismo_operador_id);
paramIndex++;
}
if (data.is_active !== undefined) { if (data.is_active !== undefined) {
updates.push(`is_active = $${paramIndex}`); updates.push(`is_active = $${paramIndex}`);
params.push(data.is_active); params.push(data.is_active);
paramIndex++; paramIndex++;
} }
if (data.phone !== undefined) {
updates.push(`phone = $${paramIndex}`);
params.push(data.phone);
paramIndex++;
}
if (data.street !== undefined) {
updates.push(`street = $${paramIndex}`);
params.push(data.street);
paramIndex++;
}
if (data.city !== undefined) {
updates.push(`city = $${paramIndex}`);
params.push(data.city);
paramIndex++;
}
if (data.state !== undefined) {
updates.push(`state = $${paramIndex}`);
params.push(data.state);
paramIndex++;
}
if (data.zip_code !== undefined) {
updates.push(`zip_code = $${paramIndex}`);
params.push(data.zip_code);
paramIndex++;
}
if (updates.length === 0) { if (updates.length === 0) {
return existingUser; return existingUser;
} }

View File

@@ -45,8 +45,15 @@ export interface UserPublic {
role_id: string; role_id: string;
role?: Role; role?: Role;
project_id: string | null; project_id: string | null;
organismo_operador_id?: string | null;
organismo_name?: string | null;
is_active: boolean; is_active: boolean;
last_login: Date | null; last_login: Date | null;
phone?: string | null;
street?: string | null;
city?: string | null;
state?: string | null;
zip_code?: string | null;
created_at: Date; created_at: Date;
updated_at: Date; updated_at: Date;
} }
@@ -57,10 +64,23 @@ export interface JwtPayload {
roleId: string; roleId: string;
roleName: string; roleName: string;
projectId?: string | null; projectId?: string | null;
organismoOperadorId?: string | null;
iat?: number; iat?: number;
exp?: number; exp?: number;
} }
export interface OrganismoOperador {
id: string;
name: string;
description: string | null;
region: string | null;
contact_name: string | null;
contact_email: string | null;
is_active: boolean;
created_at: Date;
updated_at: Date;
}
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
user?: JwtPayload; user?: JwtPayload;
} }

View File

@@ -1,13 +1,14 @@
import jwt, { SignOptions, VerifyOptions } from 'jsonwebtoken'; import jwt, { SignOptions, VerifyOptions } from 'jsonwebtoken';
import config from '../config'; import config from '../config';
import logger from './logger'; import logger from './logger';
import type { JwtPayload } from '../types';
interface TokenPayload { interface TokenPayload {
userId?: string; userId?: string;
email?: string; email?: string;
roleId?: string; roleId?: string;
roleName?: string; roleName?: string;
projectId?: string | null;
organismoOperadorId?: string | null;
id?: string; id?: string;
role?: string; role?: string;
[key: string]: unknown; [key: string]: unknown;

View File

@@ -0,0 +1,33 @@
import { query } from '../config/database';
interface ScopeUser {
roleName: string;
projectId?: string | null;
organismoOperadorId?: string | null;
}
/**
* Get allowed project IDs for a user based on their role hierarchy.
* - ADMIN: returns null (all projects)
* - ORGANISMO_OPERADOR: returns project IDs belonging to their organismo
* - OPERADOR/OPERATOR: returns their single project_id
*/
export async function getAllowedProjectIds(user: ScopeUser): Promise<string[] | null> {
if (user.roleName === 'ADMIN') {
return null; // No restriction
}
if (user.roleName === 'ORGANISMO_OPERADOR' && user.organismoOperadorId) {
const result = await query<{ id: string }>(
'SELECT id FROM projects WHERE organismo_operador_id = $1',
[user.organismoOperadorId]
);
return result.rows.map(r => r.id);
}
if (user.projectId) {
return [user.projectId];
}
return [];
}

View File

@@ -131,6 +131,11 @@ export const createMeterSchema = z.object({
// Additional Data // Additional Data
data: z.record(z.any()).optional().nullable(), data: z.record(z.any()).optional().nullable(),
// Address & Account Fields
address: z.string().optional().nullable(),
cespt_account: z.string().max(50).optional().nullable(),
cadastral_key: z.string().max(50).optional().nullable(),
}); });
/** /**
@@ -233,6 +238,11 @@ export const updateMeterSchema = z.object({
// Additional Data // Additional Data
data: z.record(z.any()).optional().nullable(), data: z.record(z.any()).optional().nullable(),
// Address & Account Fields
address: z.string().optional().nullable(),
cespt_account: z.string().max(50).optional().nullable(),
cadastral_key: z.string().max(50).optional().nullable(),
}); });
/** /**

View File

@@ -49,6 +49,11 @@ export const createProjectSchema = z.object({
.uuid('Meter type ID must be a valid UUID') .uuid('Meter type ID must be a valid UUID')
.optional() .optional()
.nullable(), .nullable(),
organismo_operador_id: z
.string()
.uuid('Organismo operador ID must be a valid UUID')
.optional()
.nullable(),
}); });
/** /**
@@ -84,6 +89,11 @@ export const updateProjectSchema = z.object({
.uuid('Meter type ID must be a valid UUID') .uuid('Meter type ID must be a valid UUID')
.optional() .optional()
.nullable(), .nullable(),
organismo_operador_id: z
.string()
.uuid('Organismo operador ID must be a valid UUID')
.optional()
.nullable(),
}); });
/** /**

View File

@@ -35,7 +35,17 @@ export const createUserSchema = z.object({
.uuid('Project ID must be a valid UUID') .uuid('Project ID must be a valid UUID')
.nullable() .nullable()
.optional(), .optional(),
organismo_operador_id: z
.string()
.uuid('Organismo Operador ID must be a valid UUID')
.nullable()
.optional(),
is_active: z.boolean().default(true), is_active: z.boolean().default(true),
phone: z.string().max(20).optional().nullable(),
street: z.string().max(255).optional().nullable(),
city: z.string().max(100).optional().nullable(),
state: z.string().max(100).optional().nullable(),
zip_code: z.string().max(10).optional().nullable(),
}); });
/** /**
@@ -68,7 +78,17 @@ export const updateUserSchema = z.object({
.uuid('Project ID must be a valid UUID') .uuid('Project ID must be a valid UUID')
.nullable() .nullable()
.optional(), .optional(),
organismo_operador_id: z
.string()
.uuid('Organismo Operador ID must be a valid UUID')
.nullable()
.optional(),
is_active: z.boolean().optional(), is_active: z.boolean().optional(),
phone: z.string().max(20).optional().nullable(),
street: z.string().max(255).optional().nullable(),
city: z.string().max(100).optional().nullable(),
state: z.string().max(100).optional().nullable(),
zip_code: z.string().max(10).optional().nullable(),
}); });
/** /**

View File

@@ -14,8 +14,8 @@
"noImplicitThis": true, "noImplicitThis": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUnusedLocals": true, "noUnusedLocals": false,
"noUnusedParameters": true, "noUnusedParameters": false,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,