This commit is contained in:
2026-02-01 18:30:28 -06:00
parent b5ea12dd27
commit 6c02bd5448
5 changed files with 110 additions and 272 deletions

View File

@@ -3,6 +3,7 @@ import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
import MaterialTable from "@material-table/core";
import { createUser, updateUser, deleteUser, getAllUsers, CreateUserInput, UpdateUserInput, User as ApiUser } from "../api/users";
import { getAllRoles, Role as ApiRole } from "../api/roles";
import { fetchProjects, type Project } from "../api/projects";
interface RoleOption {
id: string;
@@ -24,6 +25,7 @@ interface UserForm {
email: string;
password?: string;
roleId: string;
projectId?: string;
status: "ACTIVE" | "INACTIVE";
createdAt: string;
}
@@ -37,12 +39,14 @@ export default function UsersPage() {
const [editingId, setEditingId] = useState<string | null>(null);
const [roles, setRoles] = useState<RoleOption[]>([]);
const [modalRoles, setModalRoles] = useState<ApiRole[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [loadingModalRoles, setLoadingModalRoles] = useState(false);
const [loadingProjects, setLoadingProjects] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const emptyUser: UserForm = { name: "", email: "", roleId: "", password: "", status: "ACTIVE", createdAt: new Date().toISOString().slice(0,10) };
const emptyUser: UserForm = { name: "", email: "", roleId: "", projectId: "", password: "", status: "ACTIVE", createdAt: new Date().toISOString().slice(0,10) };
const [form, setForm] = useState<UserForm>(emptyUser);
useEffect(() => {
@@ -96,6 +100,14 @@ export default function UsersPage() {
return;
}
const selectedRole = modalRoles.find(r => r.id === form.roleId);
const isOperatorRole = selectedRole?.name === "OPERATOR";
if (isOperatorRole && !form.projectId) {
setError("Project is required for OPERATOR role");
return;
}
if (!editingId && !form.password) {
setError("Password is required for new users");
return;
@@ -181,12 +193,26 @@ export default function UsersPage() {
}
};
const fetchModalProjects = async () => {
try {
setLoadingProjects(true);
const projectsData = await fetchProjects();
console.log('Projects fetched:', projectsData);
setProjects(projectsData);
} catch (error) {
console.error('Failed to fetch projects:', error);
} finally {
setLoadingProjects(false);
}
};
const handleOpenAddModal = () => {
setForm(emptyUser);
setEditingId(null);
setError(null);
setShowModal(true);
fetchModalRoles();
fetchModalProjects();
};
const handleOpenEditModal = (user: User) => {
@@ -195,6 +221,7 @@ export default function UsersPage() {
name: user.name,
email: user.email,
roleId: user.roleId,
projectId: "",
status: user.status,
createdAt: user.createdAt,
password: ""
@@ -202,6 +229,7 @@ export default function UsersPage() {
setError(null);
setShowModal(true);
fetchModalRoles();
fetchModalProjects();
};
// Filter users by search and selected role
@@ -327,7 +355,7 @@ export default function UsersPage() {
<select
value={form.roleId}
onChange={e => setForm({...form, roleId: e.target.value})}
onChange={e => setForm({...form, roleId: e.target.value, projectId: ""})}
className="w-full border px-3 py-2 rounded"
disabled={loadingModalRoles || saving}
>
@@ -335,6 +363,18 @@ export default function UsersPage() {
{modalRoles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
{modalRoles.find(r => r.id === form.roleId)?.name === "OPERATOR" && (
<select
value={form.projectId || ""}
onChange={e => setForm({...form, projectId: e.target.value})}
className="w-full border px-3 py-2 rounded"
disabled={loadingProjects || saving}
>
<option value="">{loadingProjects ? "Loading projects..." : "Select Project *"}</option>
{projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
)}
<button
onClick={() => setForm({...form, status: form.status === "ACTIVE" ? "INACTIVE" : "ACTIVE"})}
className="w-full border rounded px-3 py-2"

View File

@@ -64,7 +64,6 @@ export default function ConcentratorsPage() {
const [errors, setErrors] = useState<Record<string, boolean>>({});
const searchFiltered = useMemo(() => {
if (!c.isGeneral) return [];
return c.filteredConcentrators.filter((row) => {
const q = search.trim().toLowerCase();
if (!q) return true;
@@ -72,7 +71,7 @@ export default function ConcentratorsPage() {
const sn = (row.serialNumber ?? "").toLowerCase();
return name.includes(q) || sn.includes(q);
});
}, [c.filteredConcentrators, c.isGeneral, search]);
}, [c.filteredConcentrators, search]);
const validateForm = () => {
const next: Record<string, boolean> = {};
@@ -86,7 +85,6 @@ export default function ConcentratorsPage() {
};
const handleSave = async () => {
if (!c.isGeneral) return;
if (!validateForm()) return;
try {
@@ -110,7 +108,6 @@ export default function ConcentratorsPage() {
};
const handleDelete = async () => {
if (!c.isGeneral) return;
if (!activeConcentrator) return;
try {
@@ -124,7 +121,7 @@ export default function ConcentratorsPage() {
};
const openEditModal = () => {
if (!c.isGeneral || !activeConcentrator) return;
if (!activeConcentrator) return;
setEditingId(activeConcentrator.id);
setForm({
@@ -142,8 +139,6 @@ export default function ConcentratorsPage() {
};
const openCreateModal = () => {
if (!c.isGeneral) return;
setForm(getEmptyForm());
setErrors({});
setEditingId(null);
@@ -174,7 +169,7 @@ export default function ConcentratorsPage() {
}}
projects={c.projectsData}
onRefresh={c.loadConcentrators}
refreshDisabled={c.loadingProjects || !c.isGeneral}
refreshDisabled={c.loadingProjects}
/>
</aside>
@@ -186,10 +181,8 @@ export default function ConcentratorsPage() {
<div>
<h1 className="text-2xl font-bold">Concentrator Management</h1>
<p className="text-sm text-blue-100">
{!c.isGeneral
? `Vista: ${c.sampleViewLabel} (mock)`
: c.selectedProject
? `Proyecto: ${c.selectedProject}`
{c.selectedProject
? `Proyecto: ${c.selectedProject} • Tipo: ${c.sampleViewLabel}`
: "Selecciona un proyecto desde el panel izquierdo"}
</p>
</div>
@@ -197,7 +190,7 @@ export default function ConcentratorsPage() {
<div className="flex gap-3">
<button
onClick={openCreateModal}
disabled={!c.isGeneral || c.allProjects.length === 0}
disabled={c.allProjects.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus size={16} /> Agregar
@@ -205,7 +198,7 @@ export default function ConcentratorsPage() {
<button
onClick={openEditModal}
disabled={!c.isGeneral || !activeConcentrator}
disabled={!activeConcentrator}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Pencil size={16} /> Editar
@@ -213,7 +206,7 @@ export default function ConcentratorsPage() {
<button
onClick={() => setConfirmOpen(true)}
disabled={!c.isGeneral || !activeConcentrator}
disabled={!activeConcentrator}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Trash2 size={16} /> Eliminar
@@ -221,7 +214,6 @@ export default function ConcentratorsPage() {
<button
onClick={c.loadConcentrators}
disabled={!c.isGeneral}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<RefreshCcw size={16} /> Actualizar
@@ -231,22 +223,20 @@ export default function ConcentratorsPage() {
<input
className="bg-white rounded-lg shadow px-4 py-2 text-sm"
placeholder={c.isGeneral ? "Buscar concentrador..." : "Search disabled in mock views"}
placeholder="Buscar concentrador..."
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={!c.isGeneral || !c.selectedProject}
disabled={!c.selectedProject}
/>
<div className={!c.isGeneral || !c.selectedProject ? "opacity-60 pointer-events-none" : ""}>
<div className={!c.selectedProject ? "opacity-60 pointer-events-none" : ""}>
<ConcentratorsTable
isLoading={c.isGeneral ? c.loadingConcentrators : false}
data={c.isGeneral ? searchFiltered : []}
isLoading={c.loadingConcentrators}
data={searchFiltered}
activeRowId={activeConcentrator?.id}
onRowClick={(row) => setActiveConcentrator(row)}
emptyMessage={
!c.isGeneral
? `Vista "${c.sampleViewLabel}" está en modo mock (sin backend todavía).`
: !c.selectedProject
!c.selectedProject
? "Selecciona un proyecto para ver los concentradores."
: c.loadingConcentrators
? "Cargando concentradores..."
@@ -267,7 +257,6 @@ export default function ConcentratorsPage() {
loading={deleting}
onClose={() => setConfirmOpen(false)}
onConfirm={async () => {
if (!c.isGeneral) return;
setDeleting(true);
try {
await handleDelete();
@@ -279,7 +268,7 @@ export default function ConcentratorsPage() {
/>
</main>
{showModal && c.isGeneral && (
{showModal && (
<ConcentratorsModal
editingId={editingId}
form={form}

View File

@@ -126,11 +126,14 @@ export default function ConcentratorsSidebar({
{/* List */}
<div className="mt-4 overflow-y-auto flex-1 space-y-3 pr-1">
{loadingProjects && sampleView === "GENERAL" ? (
{loadingProjects ? (
<div className="text-sm text-gray-500">Loading projects...</div>
) : projects.length === 0 ? (
<div className="text-sm text-gray-500">
No projects available. Please contact your administrator.
{sampleView === "GENERAL"
? "No projects available. Please contact your administrator."
: `No projects with ${sampleViewLabel} concentrators found.`
}
</div>
) : (
projects.map((p) => {

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import {
fetchConcentrators,
type Concentrator,
type ConcentratorType,
} from "../../api/concentrators";
import { fetchProjects, type Project } from "../../api/projects";
import type { ProjectCard, SampleView } from "./ConcentratorsPage";
@@ -78,8 +79,6 @@ export function useConcentrators(currentUser: User) {
};
const loadConcentrators = async () => {
if (!isGeneral) return;
setLoadingConcentrators(true);
try {
@@ -102,14 +101,8 @@ export function useConcentrators(currentUser: User) {
// view changes
useEffect(() => {
if (isGeneral) {
loadProjects();
loadConcentrators();
} else {
setLoadingProjects(false);
setLoadingConcentrators(false);
setSelectedProject("");
}
loadProjects();
loadConcentrators();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sampleView]);
@@ -121,25 +114,41 @@ export function useConcentrators(currentUser: User) {
}
}, [visibleProjects, selectedProject, isGeneral]);
// filter by project
useEffect(() => {
if (!isGeneral) {
setFilteredConcentrators([]);
return;
}
let filtered = concentrators;
if (selectedProject) {
setFilteredConcentrators(
concentrators.filter((c) => c.projectId === selectedProject)
);
} else {
setFilteredConcentrators(concentrators);
filtered = filtered.filter((c) => c.projectId === selectedProject);
}
}, [selectedProject, concentrators, isGeneral]);
if (!isGeneral) {
const typeMap: Record<Exclude<SampleView, "GENERAL">, ConcentratorType> = {
LORA: "LORA",
LORAWAN: "LORAWAN",
GRANDES: "GRANDES",
};
const targetType = typeMap[sampleView as Exclude<SampleView, "GENERAL">];
filtered = filtered.filter((c) => c.type === targetType);
}
setFilteredConcentrators(filtered);
}, [selectedProject, concentrators, isGeneral, sampleView]);
// sidebar cards (general)
const projectsDataGeneral: ProjectCard[] = useMemo(() => {
const counts = concentrators.reduce<Record<string, number>>((acc, c) => {
let concentratorsToCount = concentrators;
if (!isGeneral) {
const typeMap: Record<Exclude<SampleView, "GENERAL">, ConcentratorType> = {
LORA: "LORA",
LORAWAN: "LORAWAN",
GRANDES: "GRANDES",
};
const targetType = typeMap[sampleView as Exclude<SampleView, "GENERAL">];
concentratorsToCount = concentrators.filter((c) => c.type === targetType);
}
const counts = concentratorsToCount.reduce<Record<string, number>>((acc, c) => {
const project = c.projectId ?? "SIN PROYECTO";
acc[project] = (acc[project] ?? 0) + 1;
return acc;
@@ -165,70 +174,11 @@ export function useConcentrators(currentUser: User) {
contact: baseContact,
status: "ACTIVO" as const,
}));
}, [concentrators, visibleProjects, projects]);
// sidebar cards (mock)
const projectsDataMock: Record<Exclude<SampleView, "GENERAL">, ProjectCard[]> =
useMemo(
() => ({
LORA: [
{
id: "mock-lora-centro",
name: "LoRa - Zona Centro",
region: "Baja California",
projects: 1,
concentrators: 12,
activeAlerts: 1,
lastSync: "Hace 15 min",
contact: "Operaciones",
status: "ACTIVO",
},
{
id: "mock-lora-este",
name: "LoRa - Zona Este",
region: "Baja California",
projects: 1,
concentrators: 8,
activeAlerts: 0,
lastSync: "Hace 40 min",
contact: "Operaciones",
status: "ACTIVO",
},
],
LORAWAN: [
{
id: "mock-lorawan-industrial",
name: "LoRaWAN - Industrial",
region: "Baja California",
projects: 1,
concentrators: 5,
activeAlerts: 0,
lastSync: "Hace 1 h",
contact: "Operaciones",
status: "ACTIVO",
},
],
GRANDES: [
{
id: "mock-grandes-convenios",
name: "Grandes - Convenios",
region: "Baja California",
projects: 1,
concentrators: 3,
activeAlerts: 0,
lastSync: "Hace 2 h",
contact: "Operaciones",
status: "ACTIVO",
},
],
}),
[]
);
}, [concentrators, visibleProjects, projects, isGeneral, sampleView]);
const projectsData: ProjectCard[] = useMemo(() => {
if (isGeneral) return projectsDataGeneral;
return projectsDataMock[sampleView as Exclude<SampleView, "GENERAL">];
}, [isGeneral, projectsDataGeneral, projectsDataMock, sampleView]);
return projectsDataGeneral;
}, [projectsDataGeneral]);
return {
// view