From 613fb2d7872bd1dd674eb98972dcfd3d03244336 Mon Sep 17 00:00:00 2001 From: Exteban08 Date: Mon, 9 Feb 2026 10:21:33 +0000 Subject: [PATCH] =?UTF-8?q?Add=203-level=20role=20permissions,=20organismo?= =?UTF-8?q?s=20operadores,=20and=20Hist=C3=B3rico=20de=20Tomas=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/App.tsx | 10 +- src/api/auth.ts | 37 + src/api/meters.ts | 65 +- src/api/organismos.ts | 99 ++ src/api/projects.ts | 4 + src/api/users.ts | 19 + src/components/layout/Sidebar.tsx | 64 +- src/pages/Home.tsx | 227 ++-- src/pages/OrganismosPage.tsx | 372 +++++++ src/pages/UsersPage.tsx | 349 ++++-- src/pages/historico/HistoricoPage.tsx | 990 ++++++++++++++++++ src/pages/meters/MetersModal.tsx | 32 + src/pages/projects/ProjectsPage.tsx | 82 +- water-api/sql/add_organismos_operadores.sql | 66 ++ water-api/sql/add_user_meter_fields.sql | 11 + .../controllers/concentrator.controller.ts | 3 +- water-api/src/controllers/meter.controller.ts | 14 +- .../organismo-operador.controller.ts | 186 ++++ .../src/controllers/project.controller.ts | 11 +- .../src/controllers/reading.controller.ts | 24 +- water-api/src/controllers/user.controller.ts | 16 +- water-api/src/middleware/auth.middleware.ts | 3 + water-api/src/routes/index.ts | 12 + water-api/src/routes/meter.routes.ts | 16 +- .../src/routes/organismo-operador.routes.ts | 48 + water-api/src/routes/project.routes.ts | 2 +- water-api/src/routes/reading.routes.ts | 6 +- water-api/src/routes/user.routes.ts | 4 +- water-api/src/services/auth.service.ts | 20 +- .../src/services/concentrator.service.ts | 14 +- water-api/src/services/meter.service.ts | 56 +- .../src/services/notification.service.ts | 12 +- .../services/organismo-operador.service.ts | 224 ++++ water-api/src/services/project.service.ts | 35 +- water-api/src/services/reading.service.ts | 38 +- water-api/src/services/user.service.ts | 102 +- water-api/src/types/index.ts | 20 + water-api/src/utils/jwt.ts | 3 +- water-api/src/utils/scope.ts | 33 + water-api/src/validators/meter.validator.ts | 10 + water-api/src/validators/project.validator.ts | 10 + water-api/src/validators/user.validator.ts | 20 + water-api/tsconfig.json | 4 +- 43 files changed, 3049 insertions(+), 324 deletions(-) create mode 100644 src/api/organismos.ts create mode 100644 src/pages/OrganismosPage.tsx create mode 100644 src/pages/historico/HistoricoPage.tsx create mode 100644 water-api/sql/add_organismos_operadores.sql create mode 100644 water-api/sql/add_user_meter_fields.sql create mode 100644 water-api/src/controllers/organismo-operador.controller.ts create mode 100644 water-api/src/routes/organismo-operador.routes.ts create mode 100644 water-api/src/services/organismo-operador.service.ts create mode 100644 water-api/src/utils/scope.ts diff --git a/src/App.tsx b/src/App.tsx index 461d13d..9a00241 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,8 @@ import TTSPage from "./pages/conectores/TTSPage"; import AnalyticsMapPage from "./pages/analytics/AnalyticsMapPage"; import AnalyticsReportsPage from "./pages/analytics/AnalyticsReportsPage"; import AnalyticsServerPage from "./pages/analytics/AnalyticsServerPage"; +import OrganismosPage from "./pages/OrganismosPage"; +import HistoricoPage from "./pages/historico/HistoricoPage"; import ProfileModal from "./components/layout/common/ProfileModal"; import { updateMyProfile } from "./api/me"; @@ -52,7 +54,9 @@ export type Page = | "tts" | "analytics-map" | "analytics-reports" - | "analytics-server"; + | "analytics-server" + | "organismos" + | "historico"; export default function App() { const [isAuth, setIsAuth] = useState(false); @@ -207,6 +211,10 @@ export default function App() { return ; case "analytics-server": return ; + case "organismos": + return ; + case "historico": + return ; case "home": default: return ( diff --git a/src/api/auth.ts b/src/api/auth.ts index 546c15f..cdcb2ac 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -35,6 +35,8 @@ export interface AuthUser { name: string; role: string; projectId?: string | null; + organismoOperadorId?: string | null; + organismoName?: string | null; avatar_url?: string; } @@ -43,6 +45,7 @@ export interface JwtPayload { roleId: string; roleName: string; projectId?: string | null; + organismoOperadorId?: string | null; exp?: number; iat?: number; } @@ -396,3 +399,37 @@ export function isCurrentUserAdmin(): boolean { const role = getCurrentUserRole(); return role?.toUpperCase() === 'ADMIN'; } + +/** + * Get current user's organismo operador ID from JWT token + * @returns The organismo operador ID or null + */ +export function getCurrentUserOrganismoId(): string | null { + const token = getAccessToken(); + if (!token) return null; + + try { + const payload = parseJwtPayload(token) as JwtPayload | null; + return payload?.organismoOperadorId || null; + } catch { + return null; + } +} + +/** + * Check if current user is an Organismo Operador + * @returns boolean indicating if user is organismo operador + */ +export function isCurrentUserOrganismo(): boolean { + const role = getCurrentUserRole(); + return role?.toUpperCase() === 'ORGANISMO_OPERADOR'; +} + +/** + * Check if current user is an Operador (OPERATOR) + * @returns boolean indicating if user is operador + */ +export function isCurrentUserOperador(): boolean { + const role = getCurrentUserRole(); + return role?.toUpperCase() === 'OPERATOR'; +} diff --git a/src/api/meters.ts b/src/api/meters.ts index 6816722..714fb30 100644 --- a/src/api/meters.ts +++ b/src/api/meters.ts @@ -68,6 +68,9 @@ export interface Meter { manufacturer?: string | null; latitude?: number | null; longitude?: number | null; + address?: string | null; + cesptAccount?: string | null; + cadastralKey?: string | null; } /** @@ -97,19 +100,47 @@ export interface MeterInput { manufacturer?: string; latitude?: number; longitude?: number; + address?: string; + cesptAccount?: string; + cadastralKey?: string; } /** - * Meter reading entity + * Meter reading entity (from /api/meters/:id/readings) */ export interface MeterReading { id: string; meterId: string; - value: number; - unit: string; + readingValue: number; readingType: string; - readAt: string; + batteryLevel: number | null; + signalStrength: number | null; + receivedAt: string; createdAt: string; + meterSerialNumber: string; + meterName: string; + meterLocation: string | null; + concentratorId: string; + concentratorName: string; + projectId: string; + projectName: string; +} + +export interface MeterReadingFilters { + startDate?: string; + endDate?: string; + page?: number; + pageSize?: number; +} + +export interface PaginatedMeterReadings { + data: MeterReading[]; + pagination: { + page: number; + pageSize: number; + total: number; + totalPages: number; + }; } /** @@ -163,6 +194,9 @@ export async function createMeter(data: MeterInput): Promise { type: data.type, status: data.status, installation_date: data.installationDate, + address: data.address, + cespt_account: data.cesptAccount, + cadastral_key: data.cadastralKey, }; const response = await apiClient.post>('/api/meters', backendData); return transformKeys(response); @@ -185,6 +219,9 @@ export async function updateMeter(id: string, data: Partial): Promis if (data.type !== undefined) backendData.type = data.type; if (data.status !== undefined) backendData.status = data.status; if (data.installationDate !== undefined) backendData.installation_date = data.installationDate; + if (data.address !== undefined) backendData.address = data.address; + if (data.cesptAccount !== undefined) backendData.cespt_account = data.cesptAccount; + if (data.cadastralKey !== undefined) backendData.cadastral_key = data.cadastralKey; const response = await apiClient.patch>(`/api/meters/${id}`, backendData); return transformKeys(response); @@ -200,12 +237,24 @@ export async function deleteMeter(id: string): Promise { } /** - * Fetch readings for a specific meter + * Fetch readings for a specific meter with pagination and date filters * @param id - The meter ID - * @returns Promise resolving to an array of meter readings + * @param filters - Optional pagination and date filters + * @returns Promise resolving to paginated meter readings */ -export async function fetchMeterReadings(id: string): Promise { - return apiClient.get(`/api/meters/${id}/readings`); +export async function fetchMeterReadings(id: string, filters?: MeterReadingFilters): Promise { + const params: Record = {}; + 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[]; pagination: { page: number; pageSize: number; total: number; totalPages: number } }>(`/api/meters/${id}/readings`, { params }); + + return { + data: transformArray(response.data), + pagination: response.pagination, + }; } /** diff --git a/src/api/organismos.ts b/src/api/organismos.ts new file mode 100644 index 0000000..4e94c6c --- /dev/null +++ b/src/api/organismos.ts @@ -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 { + return apiClient.get('/api/organismos-operadores', { params }); +} + +/** + * Get a single organismo by ID + */ +export async function getOrganismoById(id: string): Promise { + return apiClient.get(`/api/organismos-operadores/${id}`); +} + +/** + * Get projects belonging to an organismo + */ +export async function getOrganismoProjects(id: string): Promise { + return apiClient.get(`/api/organismos-operadores/${id}/projects`); +} + +/** + * Create a new organismo operador + */ +export async function createOrganismo(data: CreateOrganismoInput): Promise { + return apiClient.post('/api/organismos-operadores', data); +} + +/** + * Update an organismo operador + */ +export async function updateOrganismo(id: string, data: UpdateOrganismoInput): Promise { + return apiClient.put(`/api/organismos-operadores/${id}`, data); +} + +/** + * Delete an organismo operador + */ +export async function deleteOrganismo(id: string): Promise { + return apiClient.delete(`/api/organismos-operadores/${id}`); +} diff --git a/src/api/projects.ts b/src/api/projects.ts index 494f282..f0fa57c 100644 --- a/src/api/projects.ts +++ b/src/api/projects.ts @@ -41,6 +41,7 @@ export interface Project { location: string | null; status: string; meterTypeId: string | null; + organismoOperadorId: string | null; createdBy: string; createdAt: string; updatedAt: string; @@ -56,6 +57,7 @@ export interface ProjectInput { location?: string; status?: string; meterTypeId?: string | null; + organismoOperadorId?: string | null; } /** @@ -97,6 +99,7 @@ export async function createProject(data: ProjectInput): Promise { location: data.location, status: data.status, meter_type_id: data.meterTypeId, + organismo_operador_id: data.organismoOperadorId, }; const response = await apiClient.post>('/api/projects', backendData); return transformKeys(response); @@ -116,6 +119,7 @@ export async function updateProject(id: string, data: Partial): Pr if (data.location !== undefined) backendData.location = data.location; if (data.status !== undefined) backendData.status = data.status; if (data.meterTypeId !== undefined) backendData.meter_type_id = data.meterTypeId; + if (data.organismoOperadorId !== undefined) backendData.organismo_operador_id = data.organismoOperadorId; const response = await apiClient.patch>(`/api/projects/${id}`, backendData); return transformKeys(response); diff --git a/src/api/users.ts b/src/api/users.ts index b2a3d51..11eac94 100644 --- a/src/api/users.ts +++ b/src/api/users.ts @@ -18,8 +18,15 @@ export interface User { permissions: Record>; }; project_id: string | null; + organismo_operador_id: string | null; + organismo_name: string | null; is_active: boolean; last_login: string | null; + phone: string | null; + street: string | null; + city: string | null; + state: string | null; + zip_code: string | null; created_at: string; updated_at: string; } @@ -30,7 +37,13 @@ export interface CreateUserInput { name: string; role_id: string; project_id?: string | null; + organismo_operador_id?: string | null; is_active?: boolean; + phone?: string | null; + street?: string | null; + city?: string | null; + state?: string | null; + zip_code?: string | null; } export interface UpdateUserInput { @@ -38,7 +51,13 @@ export interface UpdateUserInput { name?: string; role_id?: string; project_id?: string | null; + organismo_operador_id?: string | null; is_active?: boolean; + phone?: string | null; + street?: string | null; + city?: string | null; + state?: string | null; + zip_code?: string | null; } export interface ChangePasswordInput { diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index de897f3..8baab93 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -8,6 +8,7 @@ import { People, Cable, BarChart, + Business, } from "@mui/icons-material"; import { Page } from "../../App"; import { getCurrentUserRole } from "../../api/auth"; @@ -25,7 +26,9 @@ export default function Sidebar({ setPage }: SidebarProps) { const [hovered, setHovered] = useState(false); const userRole = useMemo(() => getCurrentUserRole(), []); - const isOperator = userRole?.toUpperCase() === 'OPERATOR'; + const isAdmin = userRole?.toUpperCase() === 'ADMIN'; + const isOrganismo = userRole?.toUpperCase() === 'ORGANISMO_OPERADOR'; + const isOperador = userRole?.toUpperCase() === 'OPERATOR'; const isExpanded = pinned || hovered; @@ -57,7 +60,7 @@ export default function Sidebar({ setPage }: SidebarProps) { {/* MENU */}
    - {/* DASHBOARD */} + {/* DASHBOARD - visible to all */}
  • - {/* PROJECT MANAGEMENT */} + {/* PROJECT MANAGEMENT - visible to all */}
  • - {!isOperator && ( +
  • + +
  • + + {/* Auditoria - ADMIN only */} + {isAdmin && (
  • - {!isOperator && ( + {/* USERS MANAGEMENT - ADMIN and ORGANISMO_OPERADOR */} + {(isAdmin || isOrganismo) && (
  • -
  • - -
  • + {/* Roles - ADMIN only */} + {isAdmin && ( +
  • + +
  • + )}
)} )} - {/* CONECTORES */} - {!isOperator && ( + {/* ORGANISMOS OPERADORES - ADMIN only */} + {isAdmin && ( +
  • + +
  • + )} + + {/* CONECTORES - ADMIN only */} + {isAdmin && (
  • )} - {/* ANALYTICS - ADMIN ONLY */} - {!isOperator && ( + {/* ANALYTICS - ADMIN and ORGANISMO_OPERADOR */} + {(isAdmin || isOrganismo) && (
  • - {isAdmin && ( + {/* Organismo selector - ADMIN can pick any, ORGANISMO_OPERADOR sees their own */} + {(isAdmin || isOrganismo) && (
    @@ -453,21 +403,24 @@ export default function Home({ {selectedOrganism === "Todos" ? "Todos" - : organismsData.find(o => o.id === selectedOrganism)?.name || "Ninguno"} + : selectedOrganismoName || "Ninguno"}

    - + {/* Only ADMIN can change the selector */} + {isAdmin && ( + + )}
    - {showOrganisms && ( + {showOrganisms && isAdmin && (
    {/* Overlay */}
    - {loadingUsers ? ( + {loadingOrganismos ? (
    @@ -580,54 +533,54 @@ export default function Home({

    {o.name}

    -

    {o.region}

    +

    {o.region || "-"}

    - {o.status} + {o.is_active ? "ACTIVO" : "INACTIVO"}
    - Rol + Contacto - {o.contact} + {o.contact_name || "-"}
    Email - {o.region} + {o.contact_email || "-"}
    Proyectos - {o.projects} + {o.project_count}
    - Medidores + Usuarios - {o.meters} + {o.user_count}
    - Último acceso + Región - {o.lastSync} + {o.region || "-"}
    @@ -656,7 +609,7 @@ export default function Home({ )} - {!loadingUsers && filteredOrganisms.length === 0 && ( + {!loadingOrganismos && filteredOrganisms.length === 0 && (
    No se encontraron organismos.
    @@ -665,7 +618,7 @@ export default function Home({ {/* Footer */}
    - 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' : ''}
    @@ -688,13 +641,11 @@ export default function Home({ {chartData.length === 0 && selectedOrganism !== "Todos" ? (

    - {selectedUserProjectName - ? "Este organismo no tiene medidores registrados" - : "Este organismo no tiene un proyecto asignado"} + Este organismo no tiene medidores registrados

    - {selectedUserProjectName && ( + {selectedOrganismoName && (

    - Proyecto asignado: {selectedUserProjectName} + Organismo: {selectedOrganismoName}

    )}
    @@ -720,12 +671,12 @@ export default function Home({ - {selectedOrganism !== "Todos" && selectedUserProjectName && ( + {selectedOrganism !== "Todos" && selectedOrganismoName && (
    - Proyecto del organismo: - {selectedUserProjectName} + Organismo: + {selectedOrganismoName}
    Total de medidores: @@ -738,7 +689,7 @@ export default function Home({ )}
    - {!isOperator && ( + {isAdmin && (

    Historial Reciente de Auditoria

    {loadingAuditLogs ? ( @@ -768,7 +719,7 @@ export default function Home({
    )} - {!isOperator && ( + {(isAdmin || isOrganismo) && (

    Ultimas Alertas

    {loadingNotifications ? ( diff --git a/src/pages/OrganismosPage.tsx b/src/pages/OrganismosPage.tsx new file mode 100644 index 0000000..8734e36 --- /dev/null +++ b/src/pages/OrganismosPage.tsx @@ -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([]); + const [activeOrganismo, setActiveOrganismo] = useState(null); + const [search, setSearch] = useState(""); + const [showModal, setShowModal] = useState(false); + const [editingId, setEditingId] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const emptyForm: OrganismoForm = { + name: "", + description: "", + region: "", + contact_name: "", + contact_email: "", + is_active: true, + }; + + const [form, setForm] = useState(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 ( +
    +
    + {/* HEADER */} +
    +
    +

    Organismos Operadores

    +

    Gestión de organismos operadores del sistema

    +
    +
    + + + + +
    +
    + + {/* SEARCH */} + setSearch(e.target.value)} + /> + + {/* TABLE */} + 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) => ( + + {row.project_count} + + ), + }, + { + title: "Usuarios", + field: "user_count", + render: (row: OrganismoOperador) => ( + + {row.user_count} + + ), + }, + { + title: "Estado", + field: "is_active", + render: (row: OrganismoOperador) => ( + + {row.is_active ? "ACTIVO" : "INACTIVO"} + + ), + }, + ]} + 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.", + }, + }} + /> +
    + + {/* MODAL */} + {showModal && ( +
    +
    +

    + {editingId ? "Editar Organismo" : "Agregar Organismo"} +

    + + {error && ( +
    + {error} +
    + )} + +
    + + setForm({ ...form, name: e.target.value })} + disabled={saving} + /> +
    + +
    + +