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:
10
src/App.tsx
10
src/App.tsx
@@ -16,6 +16,8 @@ import TTSPage from "./pages/conectores/TTSPage";
|
||||
import AnalyticsMapPage from "./pages/analytics/AnalyticsMapPage";
|
||||
import AnalyticsReportsPage from "./pages/analytics/AnalyticsReportsPage";
|
||||
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 { updateMyProfile } from "./api/me";
|
||||
|
||||
@@ -52,7 +54,9 @@ export type Page =
|
||||
| "tts"
|
||||
| "analytics-map"
|
||||
| "analytics-reports"
|
||||
| "analytics-server";
|
||||
| "analytics-server"
|
||||
| "organismos"
|
||||
| "historico";
|
||||
|
||||
export default function App() {
|
||||
const [isAuth, setIsAuth] = useState<boolean>(false);
|
||||
@@ -207,6 +211,10 @@ export default function App() {
|
||||
return <AnalyticsReportsPage />;
|
||||
case "analytics-server":
|
||||
return <AnalyticsServerPage />;
|
||||
case "organismos":
|
||||
return <OrganismosPage />;
|
||||
case "historico":
|
||||
return <HistoricoPage />;
|
||||
case "home":
|
||||
default:
|
||||
return (
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface AuthUser {
|
||||
name: string;
|
||||
role: string;
|
||||
projectId?: string | null;
|
||||
organismoOperadorId?: string | null;
|
||||
organismoName?: string | null;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
@@ -43,6 +45,7 @@ export interface JwtPayload {
|
||||
roleId: string;
|
||||
roleName: string;
|
||||
projectId?: string | null;
|
||||
organismoOperadorId?: string | null;
|
||||
exp?: number;
|
||||
iat?: number;
|
||||
}
|
||||
@@ -396,3 +399,37 @@ export function isCurrentUserAdmin(): boolean {
|
||||
const role = getCurrentUserRole();
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -68,6 +68,9 @@ export interface Meter {
|
||||
manufacturer?: string | null;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
address?: string | null;
|
||||
cesptAccount?: string | null;
|
||||
cadastralKey?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,19 +100,47 @@ export interface MeterInput {
|
||||
manufacturer?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
address?: string;
|
||||
cesptAccount?: string;
|
||||
cadastralKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Meter reading entity
|
||||
* Meter reading entity (from /api/meters/:id/readings)
|
||||
*/
|
||||
export interface MeterReading {
|
||||
id: string;
|
||||
meterId: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
readingValue: number;
|
||||
readingType: string;
|
||||
readAt: string;
|
||||
batteryLevel: number | null;
|
||||
signalStrength: number | null;
|
||||
receivedAt: 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,
|
||||
status: data.status,
|
||||
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);
|
||||
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.status !== undefined) backendData.status = data.status;
|
||||
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);
|
||||
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
|
||||
* @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[]> {
|
||||
return apiClient.get<MeterReading[]>(`/api/meters/${id}/readings`);
|
||||
export async function fetchMeterReadings(id: string, filters?: MeterReadingFilters): Promise<PaginatedMeterReadings> {
|
||||
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
99
src/api/organismos.ts
Normal 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}`);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export interface Project {
|
||||
location: string | null;
|
||||
status: string;
|
||||
meterTypeId: string | null;
|
||||
organismoOperadorId: string | null;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -56,6 +57,7 @@ export interface ProjectInput {
|
||||
location?: string;
|
||||
status?: string;
|
||||
meterTypeId?: string | null;
|
||||
organismoOperadorId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,6 +99,7 @@ export async function createProject(data: ProjectInput): Promise<Project> {
|
||||
location: data.location,
|
||||
status: data.status,
|
||||
meter_type_id: data.meterTypeId,
|
||||
organismo_operador_id: data.organismoOperadorId,
|
||||
};
|
||||
const response = await apiClient.post<Record<string, unknown>>('/api/projects', backendData);
|
||||
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.status !== undefined) backendData.status = data.status;
|
||||
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);
|
||||
return transformKeys<Project>(response);
|
||||
|
||||
@@ -18,8 +18,15 @@ export interface User {
|
||||
permissions: Record<string, Record<string, boolean>>;
|
||||
};
|
||||
project_id: string | null;
|
||||
organismo_operador_id: string | null;
|
||||
organismo_name: string | null;
|
||||
is_active: boolean;
|
||||
last_login: string | null;
|
||||
phone: string | null;
|
||||
street: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
zip_code: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -30,7 +37,13 @@ export interface CreateUserInput {
|
||||
name: string;
|
||||
role_id: string;
|
||||
project_id?: string | null;
|
||||
organismo_operador_id?: string | null;
|
||||
is_active?: boolean;
|
||||
phone?: string | null;
|
||||
street?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
zip_code?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateUserInput {
|
||||
@@ -38,7 +51,13 @@ export interface UpdateUserInput {
|
||||
name?: string;
|
||||
role_id?: string;
|
||||
project_id?: string | null;
|
||||
organismo_operador_id?: string | null;
|
||||
is_active?: boolean;
|
||||
phone?: string | null;
|
||||
street?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
zip_code?: string | null;
|
||||
}
|
||||
|
||||
export interface ChangePasswordInput {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
People,
|
||||
Cable,
|
||||
BarChart,
|
||||
Business,
|
||||
} from "@mui/icons-material";
|
||||
import { Page } from "../../App";
|
||||
import { getCurrentUserRole } from "../../api/auth";
|
||||
@@ -25,7 +26,9 @@ export default function Sidebar({ setPage }: SidebarProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
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;
|
||||
|
||||
@@ -57,7 +60,7 @@ export default function Sidebar({ setPage }: SidebarProps) {
|
||||
{/* MENU */}
|
||||
<div className="flex-1 py-4 px-2 overflow-y-auto">
|
||||
<ul className="space-y-1 text-white text-sm">
|
||||
{/* DASHBOARD */}
|
||||
{/* DASHBOARD - visible to all */}
|
||||
<li>
|
||||
<button
|
||||
onClick={() => setPage("home")}
|
||||
@@ -68,7 +71,7 @@ export default function Sidebar({ setPage }: SidebarProps) {
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{/* PROJECT MANAGEMENT */}
|
||||
{/* PROJECT MANAGEMENT - visible to all */}
|
||||
<li>
|
||||
<button
|
||||
onClick={() => isExpanded && setSystemOpen(!systemOpen)}
|
||||
@@ -123,7 +126,17 @@ export default function Sidebar({ setPage }: SidebarProps) {
|
||||
</button>
|
||||
</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>
|
||||
<button
|
||||
onClick={() => setPage("auditoria")}
|
||||
@@ -137,7 +150,8 @@ export default function Sidebar({ setPage }: SidebarProps) {
|
||||
)}
|
||||
</li>
|
||||
|
||||
{!isOperator && (
|
||||
{/* USERS MANAGEMENT - ADMIN and ORGANISMO_OPERADOR */}
|
||||
{(isAdmin || isOrganismo) && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => isExpanded && setUsersOpen(!usersOpen)}
|
||||
@@ -164,6 +178,8 @@ export default function Sidebar({ setPage }: SidebarProps) {
|
||||
Users
|
||||
</button>
|
||||
</li>
|
||||
{/* Roles - ADMIN only */}
|
||||
{isAdmin && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => setPage("roles")}
|
||||
@@ -172,13 +188,27 @@ export default function Sidebar({ setPage }: SidebarProps) {
|
||||
Roles
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
|
||||
{/* CONECTORES */}
|
||||
{!isOperator && (
|
||||
{/* ORGANISMOS OPERADORES - ADMIN only */}
|
||||
{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>
|
||||
<button
|
||||
onClick={() => isExpanded && setConectoresOpen(!conectoresOpen)}
|
||||
@@ -226,8 +256,8 @@ export default function Sidebar({ setPage }: SidebarProps) {
|
||||
</li>
|
||||
)}
|
||||
|
||||
{/* ANALYTICS - ADMIN ONLY */}
|
||||
{!isOperator && (
|
||||
{/* ANALYTICS - ADMIN and ORGANISMO_OPERADOR */}
|
||||
{(isAdmin || isOrganismo) && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => isExpanded && setAnalyticsOpen(!analyticsOpen)}
|
||||
|
||||
@@ -12,29 +12,14 @@ import {
|
||||
import { fetchMeters, type Meter } from "../api/meters";
|
||||
import { getAuditLogs, type AuditLog } from "../api/audit";
|
||||
import { fetchNotifications, type Notification } from "../api/notifications";
|
||||
import { getAllUsers, type User } from "../api/users";
|
||||
import { fetchProjects, type Project } from "../api/projects";
|
||||
import { getCurrentUserRole, getCurrentUserProjectId } from "../api/auth";
|
||||
import { getCurrentUserRole, getCurrentUserProjectId, getCurrentUserOrganismoId } from "../api/auth";
|
||||
import { getAllOrganismos, type OrganismoOperador } from "../api/organismos";
|
||||
import type { Page } from "../App";
|
||||
import grhWatermark from "../assets/images/grhWatermark.png";
|
||||
|
||||
/* ================= TYPES ================= */
|
||||
|
||||
type OrganismStatus = "ACTIVO" | "INACTIVO";
|
||||
|
||||
type Organism = {
|
||||
id: string;
|
||||
name: string;
|
||||
region: string;
|
||||
projects: number;
|
||||
meters: number;
|
||||
activeAlerts: number;
|
||||
lastSync: string;
|
||||
contact: string;
|
||||
status: OrganismStatus;
|
||||
projectId: string | null;
|
||||
};
|
||||
|
||||
type AlertItem = { company: string; type: string; time: string };
|
||||
|
||||
type HistoryItem = {
|
||||
@@ -56,8 +41,10 @@ export default function Home({
|
||||
|
||||
const userRole = useMemo(() => getCurrentUserRole(), []);
|
||||
const userProjectId = useMemo(() => getCurrentUserProjectId(), []);
|
||||
const userOrganismoId = useMemo(() => getCurrentUserOrganismoId(), []);
|
||||
const isOperator = userRole?.toUpperCase() === 'OPERATOR';
|
||||
const isAdmin = userRole?.toUpperCase() === 'ADMIN';
|
||||
const isOrganismo = userRole?.toUpperCase() === 'ORGANISMO_OPERADOR';
|
||||
|
||||
/* ================= METERS ================= */
|
||||
|
||||
@@ -93,56 +80,36 @@ export default function Home({
|
||||
loadProjects();
|
||||
}, []);
|
||||
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||
const [selectedOrganism, setSelectedOrganism] = useState<string>("Todos");
|
||||
const [organismos, setOrganismos] = useState<OrganismoOperador[]>([]);
|
||||
const [loadingOrganismos, setLoadingOrganismos] = useState(false);
|
||||
const [selectedOrganism, setSelectedOrganism] = useState<string>(() => {
|
||||
// ORGANISMO_OPERADOR: auto-filter to their organismo
|
||||
if (userOrganismoId) return userOrganismoId;
|
||||
return "Todos";
|
||||
});
|
||||
const [showOrganisms, setShowOrganisms] = useState(false);
|
||||
const [organismQuery, setOrganismQuery] = useState("");
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoadingUsers(true);
|
||||
const loadOrganismos = async () => {
|
||||
setLoadingOrganismos(true);
|
||||
try {
|
||||
const response = await getAllUsers({ is_active: true });
|
||||
setUsers(response.data);
|
||||
|
||||
const response = await getAllOrganismos({ pageSize: 100 });
|
||||
setOrganismos(response.data);
|
||||
} catch (err) {
|
||||
console.error("Error loading users:", err);
|
||||
setUsers([]);
|
||||
console.error("Error loading organismos:", err);
|
||||
setOrganismos([]);
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
setLoadingOrganismos(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOperator) {
|
||||
loadUsers();
|
||||
if (isAdmin || isOrganismo) {
|
||||
loadOrganismos();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const organismsData: Organism[] = useMemo(() => {
|
||||
return users.map(user => {
|
||||
const userMeters = user.project_id
|
||||
? meters.filter(m => m.projectId === user.project_id).length
|
||||
: 0;
|
||||
|
||||
const userProjects = user.project_id ? 1 : 0;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
region: user.email,
|
||||
projects: userProjects,
|
||||
meters: userMeters,
|
||||
activeAlerts: 0,
|
||||
lastSync: user.last_login ? `Último acceso: ${new Date(user.last_login).toLocaleDateString()}` : "Nunca",
|
||||
contact: user.role?.name || "N/A",
|
||||
status: user.is_active ? "ACTIVO" : "INACTIVO",
|
||||
projectId: user.project_id,
|
||||
};
|
||||
});
|
||||
}, [users, meters]);
|
||||
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
|
||||
|
||||
@@ -160,7 +127,7 @@ export default function Home({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOperator) {
|
||||
if (isAdmin) {
|
||||
loadAuditLogs();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -172,69 +139,51 @@ export default function Home({
|
||||
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
|
||||
if (selectedOrganism === "Todos") {
|
||||
return meters;
|
||||
}
|
||||
|
||||
const selectedUser = users.find(u => u.id === selectedOrganism);
|
||||
if (!selectedUser || !selectedUser.project_id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return meters.filter((m) => m.projectId === selectedUser.project_id);
|
||||
}, [meters, selectedOrganism, users, isOperator, userProjectId]);
|
||||
// ADMIN selected a specific organismo - filter by that organismo's projects
|
||||
const orgProjects = projects.filter(p => p.organismoOperadorId === selectedOrganism);
|
||||
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]);
|
||||
|
||||
const filteredProjects = useMemo(
|
||||
() => [...new Set(filteredMeters.map((m) => m.projectName))].filter(Boolean) as string[],
|
||||
[filteredMeters]
|
||||
);
|
||||
|
||||
const selectedUserProjectName = useMemo(() => {
|
||||
// If user is OPERATOR, get their project name
|
||||
if (isOperator && userProjectId) {
|
||||
const project = projects.find(p => p.id === userProjectId);
|
||||
return project?.name || null;
|
||||
}
|
||||
|
||||
// For ADMIN users with organism selector
|
||||
const selectedOrganismoName = useMemo(() => {
|
||||
if (selectedOrganism === "Todos") return null;
|
||||
|
||||
const selectedUser = users.find(u => u.id === selectedOrganism);
|
||||
if (!selectedUser || !selectedUser.project_id) return null;
|
||||
|
||||
const project = projects.find(p => p.id === selectedUser.project_id);
|
||||
return project?.name || null;
|
||||
}, [selectedOrganism, users, projects, isOperator, userProjectId]);
|
||||
const org = organismos.find(o => o.id === selectedOrganism);
|
||||
return org?.name || null;
|
||||
}, [selectedOrganism, organismos]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
// If user is OPERATOR, show only their project
|
||||
if (isOperator && selectedUserProjectName) {
|
||||
if (isOperator && userProjectId) {
|
||||
const project = projects.find(p => p.id === userProjectId);
|
||||
return [{
|
||||
name: selectedUserProjectName,
|
||||
name: project?.name || "Mi Proyecto",
|
||||
meterCount: filteredMeters.length,
|
||||
}];
|
||||
}
|
||||
|
||||
// For ADMIN users
|
||||
if (selectedOrganism === "Todos") {
|
||||
// Show meters grouped by project name
|
||||
return filteredProjects.map((projectName) => ({
|
||||
name: projectName,
|
||||
meterCount: filteredMeters.filter((m) => m.projectName === projectName).length,
|
||||
}));
|
||||
}
|
||||
|
||||
if (selectedUserProjectName) {
|
||||
const meterCount = filteredMeters.length;
|
||||
|
||||
return [{
|
||||
name: selectedUserProjectName,
|
||||
meterCount: meterCount,
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [selectedOrganism, filteredProjects, filteredMeters, selectedUserProjectName, isOperator]);
|
||||
}, [filteredProjects, filteredMeters, isOperator, userProjectId, projects]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleBarClick = (data: any) => {
|
||||
@@ -247,9 +196,9 @@ export default function Home({
|
||||
|
||||
const filteredOrganisms = useMemo(() => {
|
||||
const q = organismQuery.trim().toLowerCase();
|
||||
if (!q) return organismsData;
|
||||
return organismsData.filter((o) => o.name.toLowerCase().includes(q));
|
||||
}, [organismQuery, organismsData]);
|
||||
if (!q) return organismos;
|
||||
return organismos.filter((o) => o.name.toLowerCase().includes(q));
|
||||
}, [organismQuery, organismos]);
|
||||
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [loadingNotifications, setLoadingNotifications] = useState(false);
|
||||
@@ -268,7 +217,7 @@ export default function Home({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOperator) {
|
||||
if (isAdmin || isOrganismo) {
|
||||
loadNotifications();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -443,7 +392,8 @@ export default function Home({
|
||||
</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="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
@@ -453,11 +403,13 @@ export default function Home({
|
||||
<span className="font-semibold dark:text-zinc-300">
|
||||
{selectedOrganism === "Todos"
|
||||
? "Todos"
|
||||
: organismsData.find(o => o.id === selectedOrganism)?.name || "Ninguno"}
|
||||
: selectedOrganismoName || "Ninguno"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Only ADMIN can change the selector */}
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-700 transition"
|
||||
@@ -465,9 +417,10 @@ export default function Home({
|
||||
>
|
||||
Organismos Operadores
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showOrganisms && (
|
||||
{showOrganisms && isAdmin && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Overlay */}
|
||||
<div
|
||||
@@ -515,7 +468,7 @@ export default function Home({
|
||||
|
||||
{/* List */}
|
||||
<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="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
@@ -580,54 +533,54 @@ export default function Home({
|
||||
<p className="text-sm font-semibold text-gray-800 dark:text-white">
|
||||
{o.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-zinc-400">{o.region}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-zinc-400">{o.region || "-"}</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={[
|
||||
"text-xs font-semibold px-2 py-1 rounded-full",
|
||||
o.status === "ACTIVO"
|
||||
o.is_active
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-gray-200 text-gray-700",
|
||||
].join(" ")}
|
||||
>
|
||||
{o.status}
|
||||
{o.is_active ? "ACTIVO" : "INACTIVO"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2 text-xs">
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-gray-500 dark:text-zinc-400">Rol</span>
|
||||
<span className="text-gray-500 dark:text-zinc-400">Contacto</span>
|
||||
<span className="font-medium text-gray-800 dark:text-zinc-200">
|
||||
{o.contact}
|
||||
{o.contact_name || "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-gray-500 dark:text-zinc-400">Email</span>
|
||||
<span className="font-medium text-gray-800 dark:text-zinc-200 truncate max-w-[200px]">
|
||||
{o.region}
|
||||
{o.contact_email || "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-gray-500 dark:text-zinc-400">Proyectos</span>
|
||||
<span className="font-medium text-gray-800 dark:text-zinc-200">
|
||||
{o.projects}
|
||||
{o.project_count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{o.meters}
|
||||
{o.user_count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{o.lastSync}
|
||||
{o.region || "-"}
|
||||
</span>
|
||||
</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">
|
||||
No se encontraron organismos.
|
||||
</div>
|
||||
@@ -665,7 +618,7 @@ export default function Home({
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-5 border-t dark:border-zinc-800 text-xs text-gray-500 dark:text-zinc-400">
|
||||
Mostrando {filteredOrganisms.length} organismo{filteredOrganisms.length !== 1 ? 's' : ''} de {users.length} total{users.length !== 1 ? 'es' : ''}
|
||||
Mostrando {filteredOrganisms.length} organismo{filteredOrganisms.length !== 1 ? 's' : ''} de {organismos.length} total{organismos.length !== 1 ? 'es' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -688,13 +641,11 @@ export default function Home({
|
||||
{chartData.length === 0 && selectedOrganism !== "Todos" ? (
|
||||
<div className="h-60 flex flex-col items-center justify-center">
|
||||
<p className="text-sm text-gray-500 dark:text-zinc-400 mb-2">
|
||||
{selectedUserProjectName
|
||||
? "Este organismo no tiene medidores registrados"
|
||||
: "Este organismo no tiene un proyecto asignado"}
|
||||
Este organismo no tiene medidores registrados
|
||||
</p>
|
||||
{selectedUserProjectName && (
|
||||
{selectedOrganismoName && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -720,12 +671,12 @@ export default function Home({
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{selectedOrganism !== "Todos" && selectedUserProjectName && (
|
||||
{selectedOrganism !== "Todos" && selectedOrganismoName && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-zinc-800">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-zinc-400">Proyecto del organismo:</span>
|
||||
<span className="ml-2 font-semibold text-gray-800 dark:text-white">{selectedUserProjectName}</span>
|
||||
<span className="text-gray-500 dark:text-zinc-400">Organismo:</span>
|
||||
<span className="ml-2 font-semibold text-gray-800 dark:text-white">{selectedOrganismoName}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-zinc-400">Total de medidores:</span>
|
||||
@@ -738,7 +689,7 @@ export default function Home({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isOperator && (
|
||||
{isAdmin && (
|
||||
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 dark:text-white">Historial Reciente de Auditoria</h2>
|
||||
{loadingAuditLogs ? (
|
||||
@@ -768,7 +719,7 @@ export default function Home({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isOperator && (
|
||||
{(isAdmin || isOrganismo) && (
|
||||
<div className="bg-white dark:bg-zinc-900 dark:border dark:border-zinc-800 rounded-xl shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 dark:text-white">Ultimas Alertas</h2>
|
||||
{loadingNotifications ? (
|
||||
|
||||
372
src/pages/OrganismosPage.tsx
Normal file
372
src/pages/OrganismosPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
|
||||
import MaterialTable from "@material-table/core";
|
||||
import { createUser, updateUser, deleteUser, getAllUsers, CreateUserInput, UpdateUserInput, User as ApiUser } from "../api/users";
|
||||
import { getAllRoles, Role as ApiRole } from "../api/roles";
|
||||
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 {
|
||||
id: string;
|
||||
@@ -17,6 +19,8 @@ interface User {
|
||||
roleId: string;
|
||||
roleName: string;
|
||||
projectId: string | null;
|
||||
organismoOperadorId: string | null;
|
||||
organismoName: string | null;
|
||||
status: "ACTIVE" | "INACTIVE";
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -27,31 +31,66 @@ interface UserForm {
|
||||
password?: string;
|
||||
roleId: string;
|
||||
projectId?: string;
|
||||
organismoOperadorId?: string;
|
||||
status: "ACTIVE" | "INACTIVE";
|
||||
createdAt: string;
|
||||
phone: string;
|
||||
street: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zipCode: string;
|
||||
}
|
||||
|
||||
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 [activeUser, setActiveUser] = useState<User | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedRoleFilter, setSelectedRoleFilter] = useState<string>(""); // Filter state
|
||||
const [selectedRoleFilter, setSelectedRoleFilter] = useState<string>("");
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [roles, setRoles] = useState<RoleOption[]>([]);
|
||||
const [modalRoles, setModalRoles] = useState<ApiRole[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [organismos, setOrganismos] = useState<OrganismoOperador[]>([]);
|
||||
const [organismoProjects, setOrganismoProjects] = useState<OrganismoProject[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||
const [loadingModalRoles, setLoadingModalRoles] = useState(false);
|
||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||
const [loadingOrganismos, setLoadingOrganismos] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
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 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(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
@@ -68,6 +107,8 @@ export default function UsersPage() {
|
||||
roleId: apiUser.role_id,
|
||||
roleName: apiUser.role?.name || '',
|
||||
projectId: apiUser.project_id || null,
|
||||
organismoOperadorId: apiUser.organismo_operador_id || null,
|
||||
organismoName: apiUser.organismo_name || null,
|
||||
status: apiUser.is_active ? "ACTIVE" : "INACTIVE",
|
||||
createdAt: new Date(apiUser.created_at).toISOString().slice(0, 10)
|
||||
}));
|
||||
@@ -84,7 +125,6 @@ export default function UsersPage() {
|
||||
}
|
||||
});
|
||||
const uniqueRoles = Array.from(uniqueRolesMap.values());
|
||||
console.log('Unique roles extracted:', uniqueRoles);
|
||||
setRoles(uniqueRoles);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
@@ -103,11 +143,13 @@ export default function UsersPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedRole = modalRoles.find(r => r.id === form.roleId);
|
||||
const isOperatorRole = selectedRole?.name === "OPERATOR";
|
||||
if (selectedRoleName === "OPERATOR" && !form.projectId) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -124,13 +166,30 @@ export default function UsersPage() {
|
||||
try {
|
||||
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) {
|
||||
const updateData: UpdateUserInput = {
|
||||
email: form.email,
|
||||
name: form.name.trim(),
|
||||
role_id: form.roleId,
|
||||
project_id: form.projectId || null,
|
||||
organismo_operador_id: organismoId,
|
||||
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);
|
||||
@@ -141,7 +200,13 @@ export default function UsersPage() {
|
||||
name: form.name.trim(),
|
||||
role_id: form.roleId,
|
||||
project_id: form.projectId || null,
|
||||
organismo_operador_id: organismoId,
|
||||
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);
|
||||
@@ -189,8 +254,12 @@ export default function UsersPage() {
|
||||
try {
|
||||
setLoadingModalRoles(true);
|
||||
const rolesData = await getAllRoles();
|
||||
console.log('Modal roles fetched:', rolesData);
|
||||
// If ORGANISMO_OPERADOR, only show OPERATOR role
|
||||
if (isOrganismo) {
|
||||
setModalRoles(rolesData.filter(r => r.name === 'OPERATOR'));
|
||||
} else {
|
||||
setModalRoles(rolesData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch modal roles:', error);
|
||||
} finally {
|
||||
@@ -202,7 +271,6 @@ export default function UsersPage() {
|
||||
try {
|
||||
setLoadingProjects(true);
|
||||
const projectsData = await fetchProjects();
|
||||
console.log('Projects fetched:', projectsData);
|
||||
setProjects(projectsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch projects:', error);
|
||||
@@ -211,30 +279,86 @@ 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 = () => {
|
||||
setForm(emptyUser);
|
||||
setEditingId(null);
|
||||
setError(null);
|
||||
setOrganismoProjects([]);
|
||||
setShowModal(true);
|
||||
fetchModalRoles();
|
||||
fetchModalProjects();
|
||||
fetchOrganismosData();
|
||||
};
|
||||
|
||||
const handleOpenEditModal = (user: User) => {
|
||||
const handleOpenEditModal = async (user: User) => {
|
||||
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({
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
roleId: user.roleId,
|
||||
projectId: user.projectId || "",
|
||||
organismoOperadorId: user.organismoOperadorId || "",
|
||||
status: user.status,
|
||||
createdAt: user.createdAt,
|
||||
password: ""
|
||||
password: "",
|
||||
phone,
|
||||
street,
|
||||
city,
|
||||
state,
|
||||
zipCode,
|
||||
});
|
||||
setError(null);
|
||||
setOrganismoProjects([]);
|
||||
setShowModal(true);
|
||||
fetchModalRoles();
|
||||
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
|
||||
@@ -308,7 +432,8 @@ export default function UsersPage() {
|
||||
{ title: "Name", field: "name" },
|
||||
{ title: "Email", field: "email" },
|
||||
{ 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" }
|
||||
]}
|
||||
data={filtered}
|
||||
@@ -320,7 +445,7 @@ export default function UsersPage() {
|
||||
pageSize: 10,
|
||||
pageSizeOptions: [10, 20, 50],
|
||||
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,8 +453,8 @@ export default function UsersPage() {
|
||||
|
||||
{/* MODAL */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center">
|
||||
<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="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 max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-lg font-semibold dark:text-white">{editingId ? "Edit User" : "Add User"}</h2>
|
||||
|
||||
{error && (
|
||||
@@ -355,6 +480,47 @@ export default function UsersPage() {
|
||||
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 && (
|
||||
<input
|
||||
className="w-full border dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 px-3 py-2 rounded"
|
||||
@@ -366,9 +532,10 @@ export default function UsersPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Role selector */}
|
||||
<select
|
||||
value={form.roleId}
|
||||
onChange={e => setForm({...form, roleId: e.target.value, projectId: ""})}
|
||||
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}
|
||||
>
|
||||
@@ -376,7 +543,28 @@ export default function UsersPage() {
|
||||
{modalRoles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
|
||||
{modalRoles.find(r => r.id === form.roleId)?.name === "OPERATOR" && (
|
||||
{/* Organismo selector - shown for ORGANISMO_OPERADOR and OPERATOR roles */}
|
||||
{showOrganismoSelector && isAdmin && (
|
||||
<select
|
||||
value={form.organismoOperadorId || ""}
|
||||
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})}
|
||||
@@ -384,13 +572,16 @@ export default function UsersPage() {
|
||||
disabled={loadingProjects || saving}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<button
|
||||
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}
|
||||
>
|
||||
Status: {form.status}
|
||||
@@ -399,7 +590,7 @@ export default function UsersPage() {
|
||||
<div className="flex justify-end gap-2 pt-3">
|
||||
<button
|
||||
onClick={() => { setShowModal(false); setError(null); }}
|
||||
className="px-4 py-2"
|
||||
className="px-4 py-2 dark:text-zinc-300"
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
|
||||
990
src/pages/historico/HistoricoPage.tsx
Normal file
990
src/pages/historico/HistoricoPage.tsx
Normal 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)} m³`
|
||||
: "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)} m³`,
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -144,6 +144,38 @@ export default function MetersModal({
|
||||
/>
|
||||
</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>
|
||||
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Tipo</label>
|
||||
|
||||
@@ -10,11 +10,15 @@ import {
|
||||
deactivateProject as apiDeactivateProject,
|
||||
} from "../../api/projects";
|
||||
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() {
|
||||
const userRole = useMemo(() => getCurrentUserRole(), []);
|
||||
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 [projects, setProjects] = useState<Project[]>([]);
|
||||
@@ -26,6 +30,7 @@ export default function ProjectsPage() {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const [meterTypes, setMeterTypes] = useState<MeterType[]>([]);
|
||||
const [organismos, setOrganismos] = useState<OrganismoOperador[]>([]);
|
||||
|
||||
const emptyForm: ProjectInput = {
|
||||
name: "",
|
||||
@@ -34,6 +39,7 @@ export default function ProjectsPage() {
|
||||
location: "",
|
||||
status: "ACTIVE",
|
||||
meterTypeId: null,
|
||||
organismoOperadorId: isOrganismo ? userOrganismoId : null,
|
||||
};
|
||||
|
||||
const [form, setForm] = useState<ProjectInput>(emptyForm);
|
||||
@@ -52,16 +58,21 @@ export default function ProjectsPage() {
|
||||
};
|
||||
|
||||
const visibleProjects = useMemo(() => {
|
||||
if (!isOperator) {
|
||||
return projects;
|
||||
// ADMIN sees all
|
||||
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, isOperator, userProjectId]);
|
||||
}, [projects, isAdmin, isOrganismo, isOperator, userProjectId, userOrganismoId]);
|
||||
|
||||
const loadMeterTypesData = async () => {
|
||||
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(() => {
|
||||
loadProjects();
|
||||
loadMeterTypesData();
|
||||
if (isAdmin) {
|
||||
loadOrganismos();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -142,6 +167,7 @@ export default function ProjectsPage() {
|
||||
location: activeProject.location ?? "",
|
||||
status: activeProject.status,
|
||||
meterTypeId: activeProject.meterTypeId ?? null,
|
||||
organismoOperadorId: activeProject.organismoOperadorId ?? null,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
@@ -174,7 +200,7 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!isOperator && (
|
||||
{(isAdmin || isOrganismo) && (
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
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>
|
||||
)}
|
||||
|
||||
{!isOperator && (
|
||||
{(isAdmin || isOrganismo) && (
|
||||
<button
|
||||
onClick={openEditModal}
|
||||
disabled={!activeProject}
|
||||
@@ -193,7 +219,7 @@ export default function ProjectsPage() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isOperator && (
|
||||
{(isAdmin || isOrganismo) && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={!activeProject}
|
||||
@@ -227,6 +253,19 @@ export default function ProjectsPage() {
|
||||
columns={[
|
||||
{ title: "Nombre", field: "name" },
|
||||
{ title: "Area", field: "areaName" },
|
||||
...(isAdmin ? [{
|
||||
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",
|
||||
@@ -358,6 +397,25 @@ export default function ProjectsPage() {
|
||||
)}
|
||||
</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>
|
||||
<label className="block text-sm text-gray-600 dark:text-zinc-400 mb-1">Estado</label>
|
||||
<select
|
||||
|
||||
66
water-api/sql/add_organismos_operadores.sql
Normal file
66
water-api/sql/add_organismos_operadores.sql
Normal 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');
|
||||
11
water-api/sql/add_user_meter_fields.sql
Normal file
11
water-api/sql/add_user_meter_fields.sql
Normal 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);
|
||||
@@ -27,7 +27,8 @@ export async function getAll(req: AuthenticatedRequest, res: Response): Promise<
|
||||
// Pass user info for role-based filtering
|
||||
const requestingUser = req.user ? {
|
||||
roleName: req.user.roleName,
|
||||
projectId: req.user.projectId
|
||||
projectId: req.user.projectId,
|
||||
organismoOperadorId: req.user.organismoOperadorId,
|
||||
} : undefined;
|
||||
|
||||
const result = await concentratorService.getAll(filters, pagination, requestingUser);
|
||||
|
||||
@@ -38,7 +38,8 @@ export async function getAll(req: AuthenticatedRequest, res: Response): Promise<
|
||||
// Pass user info for role-based filtering
|
||||
const requestingUser = req.user ? {
|
||||
roleName: req.user.roleName,
|
||||
projectId: req.user.projectId
|
||||
projectId: req.user.projectId,
|
||||
organismoOperadorId: req.user.organismoOperadorId,
|
||||
} : undefined;
|
||||
|
||||
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
|
||||
* 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 {
|
||||
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;
|
||||
}
|
||||
|
||||
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({
|
||||
success: true,
|
||||
|
||||
186
water-api/src/controllers/organismo-operador.controller.ts
Normal file
186
water-api/src/controllers/organismo-operador.controller.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { CreateProjectInput, UpdateProjectInput, ProjectStatusType } from '../va
|
||||
* List all projects with pagination and optional filtering
|
||||
* 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 {
|
||||
const page = parseInt(req.query.page as string, 10) || 1;
|
||||
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;
|
||||
}
|
||||
|
||||
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({
|
||||
success: true,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { AuthenticatedRequest } from '../middleware/auth.middleware';
|
||||
import * as readingService from '../services/reading.service';
|
||||
|
||||
/**
|
||||
* GET /readings
|
||||
* 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 {
|
||||
const {
|
||||
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
|
||||
};
|
||||
|
||||
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({
|
||||
success: true,
|
||||
@@ -136,12 +144,20 @@ export async function deleteReading(req: Request, res: Response): Promise<void>
|
||||
* GET /readings/summary
|
||||
* 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 {
|
||||
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(
|
||||
project_id as string | undefined
|
||||
project_id as string | undefined,
|
||||
requestingUser
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
|
||||
@@ -41,7 +41,13 @@ export async function getAllUsers(
|
||||
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({
|
||||
success: true,
|
||||
@@ -125,12 +131,20 @@ export async function createUser(
|
||||
try {
|
||||
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({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
name: data.name,
|
||||
avatar_url: data.avatar_url,
|
||||
role_id: data.role_id,
|
||||
project_id: data.project_id,
|
||||
organismo_operador_id: organismoOperadorId,
|
||||
is_active: data.is_active,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Response, NextFunction } from 'express';
|
||||
import { verifyAccessToken } from '../utils/jwt';
|
||||
import { AuthenticatedRequest } from '../types';
|
||||
|
||||
export { AuthenticatedRequest };
|
||||
|
||||
/**
|
||||
* Middleware to authenticate JWT access tokens
|
||||
* Extracts Bearer token from Authorization header, verifies it,
|
||||
@@ -42,6 +44,7 @@ export function authenticateToken(
|
||||
roleId: (decoded as any).roleId || (decoded as any).role,
|
||||
roleName: (decoded as any).roleName || (decoded as any).role,
|
||||
projectId: (decoded as any).projectId,
|
||||
organismoOperadorId: (decoded as any).organismoOperadorId,
|
||||
};
|
||||
|
||||
next();
|
||||
|
||||
@@ -16,6 +16,7 @@ import bulkUploadRoutes from './bulk-upload.routes';
|
||||
import csvUploadRoutes from './csv-upload.routes';
|
||||
import auditRoutes from './audit.routes';
|
||||
import notificationRoutes from './notification.routes';
|
||||
import organismoOperadorRoutes from './organismo-operador.routes';
|
||||
import testRoutes from './test.routes';
|
||||
import systemRoutes from './system.routes';
|
||||
|
||||
@@ -119,6 +120,17 @@ router.use('/users', userRoutes);
|
||||
*/
|
||||
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:
|
||||
* - GET /webhooks/tts/health - Health check
|
||||
|
||||
@@ -7,26 +7,26 @@ const router = Router();
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Response: { success: true, data: Meter[], pagination: {...} }
|
||||
*/
|
||||
router.get('/', meterController.getAll);
|
||||
router.get('/', authenticateToken, meterController.getAll);
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
*/
|
||||
router.get('/:id', meterController.getById);
|
||||
router.get('/:id', authenticateToken, meterController.getById);
|
||||
|
||||
/**
|
||||
* GET /meters/:id/readings
|
||||
* Public endpoint - get meter readings history
|
||||
* Query params: start_date, end_date
|
||||
* Response: { success: true, data: MeterReading[] }
|
||||
* Protected endpoint - get meter readings history filtered by user role/scope
|
||||
* Query params: start_date, end_date, page, pageSize
|
||||
* Response: { success: true, data: MeterReading[], pagination: {...} }
|
||||
*/
|
||||
router.get('/:id/readings', meterController.getReadings);
|
||||
router.get('/:id/readings', authenticateToken, meterController.getReadings);
|
||||
|
||||
/**
|
||||
* POST /meters
|
||||
|
||||
48
water-api/src/routes/organismo-operador.routes.ts
Normal file
48
water-api/src/routes/organismo-operador.routes.ts
Normal 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;
|
||||
@@ -11,7 +11,7 @@ const router = Router();
|
||||
* Query params: page, pageSize, status, area_name, search
|
||||
* Response: { success: true, data: Project[], pagination: {...} }
|
||||
*/
|
||||
router.get('/', projectController.getAll);
|
||||
router.get('/', authenticateToken, projectController.getAll);
|
||||
|
||||
/**
|
||||
* GET /projects/:id
|
||||
|
||||
@@ -10,15 +10,15 @@ const router = Router();
|
||||
* Query params: project_id
|
||||
* Response: { success: true, data: { totalReadings, totalMeters, avgReading, lastReadingDate } }
|
||||
*/
|
||||
router.get('/summary', readingController.getSummary);
|
||||
router.get('/summary', authenticateToken, readingController.getSummary);
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Response: { success: true, data: Reading[], pagination: {...} }
|
||||
*/
|
||||
router.get('/', readingController.getAll);
|
||||
router.get('/', authenticateToken, readingController.getAll);
|
||||
|
||||
/**
|
||||
* GET /readings/:id
|
||||
|
||||
@@ -20,7 +20,7 @@ router.use(authenticateToken);
|
||||
* Query params: role_id, is_active, search, page, limit, sortBy, sortOrder
|
||||
* Response: { success, message, data: User[], pagination }
|
||||
*/
|
||||
router.get('/', requireRole('ADMIN'), userController.getAllUsers);
|
||||
router.get('/', requireRole('ADMIN', 'ORGANISMO_OPERADOR'), userController.getAllUsers);
|
||||
|
||||
/**
|
||||
* GET /users/:id
|
||||
@@ -35,7 +35,7 @@ router.get('/:id', userController.getUserById);
|
||||
* Body: { email, password, name, avatar_url?, role_id, is_active? }
|
||||
* Response: { success, message, data: User }
|
||||
*/
|
||||
router.post('/', requireRole('ADMIN'), validateCreateUser, userController.createUser);
|
||||
router.post('/', requireRole('ADMIN', 'ORGANISMO_OPERADOR'), validateCreateUser, userController.createUser);
|
||||
|
||||
/**
|
||||
* PUT /users/:id
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface UserProfile {
|
||||
role: string;
|
||||
avatarUrl?: string | null;
|
||||
projectId?: string | null;
|
||||
organismoOperadorId?: string | null;
|
||||
organismoName?: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -48,7 +50,7 @@ export async function login(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<LoginResult> {
|
||||
// Find user by email with role name
|
||||
// Find user by email with role name and organismo
|
||||
const userResult = await query<{
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -57,9 +59,10 @@ export async function login(
|
||||
avatar_url: string | null;
|
||||
role_name: string;
|
||||
project_id: string | null;
|
||||
organismo_operador_id: string | null;
|
||||
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
|
||||
JOIN roles r ON u.role_id = r.id
|
||||
WHERE LOWER(u.email) = LOWER($1) AND u.is_active = true
|
||||
@@ -86,6 +89,7 @@ export async function login(
|
||||
roleId: user.id,
|
||||
roleName: user.role_name,
|
||||
projectId: user.project_id,
|
||||
organismoOperadorId: user.organismo_operador_id,
|
||||
});
|
||||
|
||||
const refreshToken = generateRefreshToken({
|
||||
@@ -174,8 +178,9 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri
|
||||
email: string;
|
||||
role_name: string;
|
||||
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
|
||||
JOIN roles r ON u.role_id = r.id
|
||||
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,
|
||||
roleName: user.role_name,
|
||||
projectId: user.project_id,
|
||||
organismoOperadorId: user.organismo_operador_id,
|
||||
});
|
||||
|
||||
return { accessToken };
|
||||
@@ -232,11 +238,15 @@ export async function getMe(userId: string): Promise<UserProfile> {
|
||||
avatar_url: string | null;
|
||||
role_name: string;
|
||||
project_id: string | null;
|
||||
organismo_operador_id: string | null;
|
||||
organismo_name: string | null;
|
||||
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
|
||||
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
|
||||
LIMIT 1`,
|
||||
[userId]
|
||||
@@ -255,6 +265,8 @@ export async function getMe(userId: string): Promise<UserProfile> {
|
||||
role: user.role_name,
|
||||
avatarUrl: user.avatar_url,
|
||||
projectId: user.project_id,
|
||||
organismoOperadorId: user.organismo_operador_id,
|
||||
organismoName: user.organismo_name,
|
||||
createdAt: user.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export interface PaginatedResult<T> {
|
||||
export async function getAll(
|
||||
filters?: ConcentratorFilters,
|
||||
pagination?: PaginationOptions,
|
||||
requestingUser?: { roleName: string; projectId?: string | null }
|
||||
requestingUser?: { roleName: string; projectId?: string | null; organismoOperadorId?: string | null }
|
||||
): Promise<PaginatedResult<Concentrator>> {
|
||||
const page = pagination?.page || 1;
|
||||
const limit = pagination?.limit || 10;
|
||||
@@ -89,15 +89,19 @@ export async function getAll(
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// Role-based filtering: OPERATOR users can only see their assigned project
|
||||
if (requestingUser && requestingUser.roleName !== 'ADMIN' && requestingUser.projectId) {
|
||||
// Role-based filtering: 3-level hierarchy
|
||||
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}`);
|
||||
params.push(requestingUser.projectId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Additional filter by project_id (only applies if user is ADMIN or no user context)
|
||||
if (filters?.project_id && (!requestingUser || requestingUser.roleName === 'ADMIN')) {
|
||||
// Additional filter by project_id (applies if user is ADMIN, ORGANISMO_OPERADOR, or no user context)
|
||||
if (filters?.project_id && (!requestingUser || requestingUser.roleName === 'ADMIN' || requestingUser.roleName === 'ORGANISMO_OPERADOR')) {
|
||||
conditions.push(`project_id = $${paramIndex}`);
|
||||
params.push(filters.project_id);
|
||||
paramIndex++;
|
||||
|
||||
@@ -73,6 +73,11 @@ export interface Meter {
|
||||
|
||||
// Additional Data
|
||||
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;
|
||||
longitude?: number;
|
||||
data?: Record<string, any>;
|
||||
address?: string;
|
||||
cespt_account?: string;
|
||||
cadastral_key?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,6 +224,9 @@ export interface UpdateMeterInput {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
data?: Record<string, any>;
|
||||
address?: string;
|
||||
cespt_account?: string;
|
||||
cadastral_key?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,7 +238,7 @@ export interface UpdateMeterInput {
|
||||
export async function getAll(
|
||||
filters?: MeterFilters,
|
||||
pagination?: PaginationParams,
|
||||
requestingUser?: { roleName: string; projectId?: string | null }
|
||||
requestingUser?: { roleName: string; projectId?: string | null; organismoOperadorId?: string | null }
|
||||
): Promise<PaginatedResult<MeterWithDetails>> {
|
||||
const page = pagination?.page || 1;
|
||||
const pageSize = pagination?.pageSize || 50;
|
||||
@@ -237,8 +248,12 @@ export async function getAll(
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// Role-based filtering: OPERATOR users can only see meters from their assigned project
|
||||
if (requestingUser && requestingUser.roleName !== 'ADMIN' && requestingUser.projectId) {
|
||||
// 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++;
|
||||
@@ -250,8 +265,8 @@ export async function getAll(
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Additional filter by project_id (only applies if user is ADMIN or no user context)
|
||||
if (filters?.project_id && (!requestingUser || requestingUser.roleName === 'ADMIN')) {
|
||||
// Additional filter by project_id (applies if user is ADMIN, ORGANISMO_OPERADOR, or no user context)
|
||||
if (filters?.project_id && (!requestingUser || requestingUser.roleName === 'ADMIN' || requestingUser.roleName === 'ORGANISMO_OPERADOR')) {
|
||||
conditions.push(`c.project_id = $${paramIndex}`);
|
||||
params.push(filters.project_id);
|
||||
paramIndex++;
|
||||
@@ -296,7 +311,8 @@ export async function getAll(
|
||||
c.name as concentrator_name, c.serial_number as concentrator_serial,
|
||||
c.project_id, p.name as project_name,
|
||||
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
|
||||
JOIN concentrators c ON m.concentrator_id = c.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.created_at, m.updated_at,
|
||||
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
|
||||
JOIN concentrators c ON m.concentrator_id = c.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>(
|
||||
`INSERT INTO meters (serial_number, meter_id, name, project_id, concentrator_id, location, type, status, installation_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`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, $10, $11, $12)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.serial_number,
|
||||
@@ -373,6 +390,9 @@ export async function create(data: CreateMeterInput): Promise<Meter> {
|
||||
data.type || 'LORA',
|
||||
data.status || 'ACTIVE',
|
||||
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++;
|
||||
}
|
||||
|
||||
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()`);
|
||||
|
||||
if (updates.length === 1) {
|
||||
|
||||
@@ -100,7 +100,7 @@ export async function getAllForUser(
|
||||
ORDER BY is_read ASC, ${safeSortBy} ${safeSortOrder}
|
||||
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);
|
||||
|
||||
@@ -144,7 +144,7 @@ export async function getById(id: string, userId: string): Promise<Notification
|
||||
FROM notifications
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ export async function create(input: CreateNotificationInput): Promise<Notificati
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await query(sql, [
|
||||
const result = await query<Notification>(sql, [
|
||||
input.user_id,
|
||||
input.meter_id || null,
|
||||
input.notification_type,
|
||||
@@ -193,7 +193,7 @@ export async function markAsRead(id: string, userId: string): Promise<Notificati
|
||||
WHERE id = $1 AND user_id = $2
|
||||
RETURNING *
|
||||
`;
|
||||
const result = await query(sql, [id, userId]);
|
||||
const result = await query<Notification>(sql, [id, userId]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ export async function getMetersWithNegativeFlow(): Promise<Array<{
|
||||
WHERE m.last_reading_value < 0
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
224
water-api/src/services/organismo-operador.service.ts
Normal file
224
water-api/src/services/organismo-operador.service.ts
Normal 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;
|
||||
}
|
||||
@@ -65,7 +65,8 @@ export interface PaginatedResult<T> {
|
||||
*/
|
||||
export async function getAll(
|
||||
filters?: ProjectFilters,
|
||||
pagination?: PaginationParams
|
||||
pagination?: PaginationParams,
|
||||
requestingUser?: { roleName: string; projectId?: string | null; organismoOperadorId?: string | null }
|
||||
): Promise<PaginatedResult<Project>> {
|
||||
const page = pagination?.page || 1;
|
||||
const pageSize = pagination?.pageSize || 10;
|
||||
@@ -76,6 +77,17 @@ export async function getAll(
|
||||
const params: unknown[] = [];
|
||||
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) {
|
||||
conditions.push(`status = $${paramIndex}`);
|
||||
params.push(filters.status);
|
||||
@@ -103,7 +115,7 @@ export async function getAll(
|
||||
|
||||
// Get paginated data
|
||||
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
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
@@ -131,7 +143,7 @@ export async function getAll(
|
||||
*/
|
||||
export async function getById(id: string): Promise<Project | null> {
|
||||
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
|
||||
WHERE id = $1`,
|
||||
[id]
|
||||
@@ -148,9 +160,9 @@ export async function getById(id: string): Promise<Project | null> {
|
||||
*/
|
||||
export async function create(data: CreateProjectInput, userId: string): Promise<Project> {
|
||||
const result = await query<Project>(
|
||||
`INSERT INTO projects (name, description, area_name, location, status, meter_type_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, name, description, area_name, location, status, meter_type_id, created_by, created_at, updated_at`,
|
||||
`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, $8)
|
||||
RETURNING id, name, description, area_name, location, status, meter_type_id, organismo_operador_id, created_by, created_at, updated_at`,
|
||||
[
|
||||
data.name,
|
||||
data.description || null,
|
||||
@@ -158,6 +170,7 @@ export async function create(data: CreateProjectInput, userId: string): Promise<
|
||||
data.location || null,
|
||||
data.status || 'ACTIVE',
|
||||
data.meter_type_id || null,
|
||||
data.organismo_operador_id || null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
@@ -213,6 +226,12 @@ export async function update(id: string, data: UpdateProjectInput): Promise<Proj
|
||||
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
|
||||
updates.push(`updated_at = NOW()`);
|
||||
|
||||
@@ -227,7 +246,7 @@ export async function update(id: string, data: UpdateProjectInput): Promise<Proj
|
||||
`UPDATE projects
|
||||
SET ${updates.join(', ')}
|
||||
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
|
||||
);
|
||||
|
||||
@@ -347,7 +366,7 @@ export async function deactivateProjectAndUnassignUsers(id: string): Promise<Pro
|
||||
`UPDATE projects
|
||||
SET status = 'INACTIVE', updated_at = NOW()
|
||||
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]
|
||||
);
|
||||
|
||||
|
||||
@@ -82,7 +82,8 @@ export interface CreateReadingInput {
|
||||
*/
|
||||
export async function getAll(
|
||||
filters?: ReadingFilters,
|
||||
pagination?: PaginationParams
|
||||
pagination?: PaginationParams,
|
||||
requestingUser?: { roleName: string; projectId?: string | null; organismoOperadorId?: string | null }
|
||||
): Promise<PaginatedResult<MeterReadingWithMeter>> {
|
||||
const page = pagination?.page || 1;
|
||||
const pageSize = pagination?.pageSize || 50;
|
||||
@@ -93,6 +94,17 @@ export async function getAll(
|
||||
const params: unknown[] = [];
|
||||
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) {
|
||||
conditions.push(`mr.meter_id = $${paramIndex}`);
|
||||
params.push(filters.meter_id);
|
||||
@@ -246,20 +258,38 @@ export async function deleteReading(id: string): Promise<boolean> {
|
||||
* @param projectId - Optional project ID to filter
|
||||
* @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;
|
||||
totalMeters: number;
|
||||
avgReading: number;
|
||||
lastReadingDate: Date | null;
|
||||
}> {
|
||||
const params: unknown[] = [];
|
||||
let whereClause = '';
|
||||
const conditions: string[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (projectId) {
|
||||
whereClause = 'WHERE c.project_id = $1';
|
||||
conditions.push(`c.project_id = $${paramIndex}`);
|
||||
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<{
|
||||
total_readings: string;
|
||||
total_meters: string;
|
||||
|
||||
@@ -34,7 +34,8 @@ export interface PaginatedUsers {
|
||||
*/
|
||||
export async function getAll(
|
||||
filters?: UserFilter,
|
||||
pagination?: PaginationParams
|
||||
pagination?: PaginationParams,
|
||||
requestingUser?: { roleName: string; organismoOperadorId?: string | null }
|
||||
): Promise<PaginatedUsers> {
|
||||
const page = pagination?.page || 1;
|
||||
const limit = pagination?.limit || 10;
|
||||
@@ -47,6 +48,13 @@ export async function getAll(
|
||||
const params: unknown[] = [];
|
||||
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) {
|
||||
conditions.push(`u.role_id = $${paramIndex}`);
|
||||
params.push(filters.role_id);
|
||||
@@ -83,7 +91,7 @@ export async function getAll(
|
||||
const countResult = await query<{ total: string }>(countQuery, params);
|
||||
const total = parseInt(countResult.rows[0].total, 10);
|
||||
|
||||
// Get users with role name
|
||||
// Get users with role name and organismo info
|
||||
const usersQuery = `
|
||||
SELECT
|
||||
u.id,
|
||||
@@ -94,12 +102,20 @@ export async function getAll(
|
||||
r.name as role_name,
|
||||
r.description as role_description,
|
||||
u.project_id,
|
||||
u.organismo_operador_id,
|
||||
oo.name as organismo_name,
|
||||
u.is_active,
|
||||
u.last_login,
|
||||
u.phone,
|
||||
u.street,
|
||||
u.city,
|
||||
u.state,
|
||||
u.zip_code,
|
||||
u.created_at,
|
||||
u.updated_at
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
LEFT JOIN organismos_operadores oo ON u.organismo_operador_id = oo.id
|
||||
${whereClause}
|
||||
ORDER BY u.${safeSortBy} ${safeSortOrder}
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
@@ -124,8 +140,15 @@ export async function getAll(
|
||||
}
|
||||
: undefined,
|
||||
project_id: row.project_id,
|
||||
organismo_operador_id: row.organismo_operador_id,
|
||||
organismo_name: row.organismo_name,
|
||||
is_active: row.is_active,
|
||||
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,
|
||||
updated_at: row.updated_at,
|
||||
}));
|
||||
@@ -163,12 +186,20 @@ export async function getById(id: string): Promise<UserPublic | null> {
|
||||
r.description as role_description,
|
||||
r.permissions as role_permissions,
|
||||
u.project_id,
|
||||
u.organismo_operador_id,
|
||||
oo.name as organismo_name,
|
||||
u.is_active,
|
||||
u.last_login,
|
||||
u.phone,
|
||||
u.street,
|
||||
u.city,
|
||||
u.state,
|
||||
u.zip_code,
|
||||
u.created_at,
|
||||
u.updated_at
|
||||
FROM users u
|
||||
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
|
||||
`,
|
||||
[id]
|
||||
@@ -196,8 +227,15 @@ export async function getById(id: string): Promise<UserPublic | null> {
|
||||
}
|
||||
: undefined,
|
||||
project_id: row.project_id,
|
||||
organismo_operador_id: row.organismo_operador_id,
|
||||
organismo_name: row.organismo_name,
|
||||
is_active: row.is_active,
|
||||
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,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
@@ -240,7 +278,13 @@ export async function create(data: {
|
||||
avatar_url?: string | null;
|
||||
role_id: string;
|
||||
project_id?: string | null;
|
||||
organismo_operador_id?: string | null;
|
||||
is_active?: boolean;
|
||||
phone?: string | null;
|
||||
street?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
zip_code?: string | null;
|
||||
}): Promise<UserPublic> {
|
||||
// Check if email already exists
|
||||
const existingUser = await getByEmail(data.email);
|
||||
@@ -253,9 +297,9 @@ export async function create(data: {
|
||||
|
||||
const result = await query(
|
||||
`
|
||||
INSERT INTO users (email, password_hash, name, avatar_url, role_id, project_id, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, email, name, avatar_url, role_id, project_id, is_active, last_login, created_at, updated_at
|
||||
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, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id, email, name, avatar_url, role_id, project_id, organismo_operador_id, is_active, last_login, created_at, updated_at
|
||||
`,
|
||||
[
|
||||
data.email.toLowerCase(),
|
||||
@@ -264,7 +308,13 @@ export async function create(data: {
|
||||
data.avatar_url ?? null,
|
||||
data.role_id,
|
||||
data.project_id ?? null,
|
||||
data.organismo_operador_id ?? null,
|
||||
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;
|
||||
role_id?: string;
|
||||
project_id?: string | null;
|
||||
organismo_operador_id?: string | null;
|
||||
is_active?: boolean;
|
||||
phone?: string | null;
|
||||
street?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
zip_code?: string | null;
|
||||
}
|
||||
): Promise<UserPublic | null> {
|
||||
// Check if user exists
|
||||
@@ -340,12 +396,48 @@ export async function update(
|
||||
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) {
|
||||
updates.push(`is_active = $${paramIndex}`);
|
||||
params.push(data.is_active);
|
||||
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) {
|
||||
return existingUser;
|
||||
}
|
||||
|
||||
@@ -45,8 +45,15 @@ export interface UserPublic {
|
||||
role_id: string;
|
||||
role?: Role;
|
||||
project_id: string | null;
|
||||
organismo_operador_id?: string | null;
|
||||
organismo_name?: string | null;
|
||||
is_active: boolean;
|
||||
last_login: Date | null;
|
||||
phone?: string | null;
|
||||
street?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
zip_code?: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -57,10 +64,23 @@ export interface JwtPayload {
|
||||
roleId: string;
|
||||
roleName: string;
|
||||
projectId?: string | null;
|
||||
organismoOperadorId?: string | null;
|
||||
iat?: 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 {
|
||||
user?: JwtPayload;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import jwt, { SignOptions, VerifyOptions } from 'jsonwebtoken';
|
||||
import config from '../config';
|
||||
import logger from './logger';
|
||||
import type { JwtPayload } from '../types';
|
||||
|
||||
interface TokenPayload {
|
||||
userId?: string;
|
||||
email?: string;
|
||||
roleId?: string;
|
||||
roleName?: string;
|
||||
projectId?: string | null;
|
||||
organismoOperadorId?: string | null;
|
||||
id?: string;
|
||||
role?: string;
|
||||
[key: string]: unknown;
|
||||
|
||||
33
water-api/src/utils/scope.ts
Normal file
33
water-api/src/utils/scope.ts
Normal 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 [];
|
||||
}
|
||||
@@ -131,6 +131,11 @@ export const createMeterSchema = z.object({
|
||||
|
||||
// Additional Data
|
||||
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
|
||||
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(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,6 +49,11 @@ export const createProjectSchema = z.object({
|
||||
.uuid('Meter type ID must be a valid UUID')
|
||||
.optional()
|
||||
.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')
|
||||
.optional()
|
||||
.nullable(),
|
||||
organismo_operador_id: z
|
||||
.string()
|
||||
.uuid('Organismo operador ID must be a valid UUID')
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,7 +35,17 @@ export const createUserSchema = z.object({
|
||||
.uuid('Project ID must be a valid UUID')
|
||||
.nullable()
|
||||
.optional(),
|
||||
organismo_operador_id: z
|
||||
.string()
|
||||
.uuid('Organismo Operador ID must be a valid UUID')
|
||||
.nullable()
|
||||
.optional(),
|
||||
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')
|
||||
.nullable()
|
||||
.optional(),
|
||||
organismo_operador_id: z
|
||||
.string()
|
||||
.uuid('Organismo Operador ID must be a valid UUID')
|
||||
.nullable()
|
||||
.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(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
"noImplicitThis": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
Reference in New Issue
Block a user