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