Files
GRH/src/pages/projects/ProjectsPage.tsx
Exteban08 0142ba740f Add dark mode support for tables and data pages
- Add CSS overrides for MaterialTable in dark mode
- Update page containers with dark:bg-zinc-950
- Update sidebars with dark mode (MetersSidebar, ConcentratorsSidebar)
- Update tables in AuditoriaPage, UsersPage, RolesPage
- Update ConsumptionPage with dark gradient background
- Update search inputs, select elements, and modals
- Add dark borders for card separation

Affected pages:
- MeterPage, ConcentratorsPage, ProjectsPage
- UsersPage, RolesPage, AuditoriaPage
- ConsumptionPage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 11:58:10 +00:00

394 lines
13 KiB
TypeScript

import { useEffect, useState, useMemo } from "react";
import { Plus, Trash2, Pencil, RefreshCcw } from "lucide-react";
import MaterialTable from "@material-table/core";
import {
Project,
ProjectInput,
fetchProjects,
createProject as apiCreateProject,
updateProject as apiUpdateProject,
deactivateProject as apiDeactivateProject,
} from "../../api/projects";
import { fetchMeterTypes, type MeterType } from "../../api/meterTypes";
import { getCurrentUserRole, getCurrentUserProjectId } from "../../api/auth";
export default function ProjectsPage() {
const userRole = useMemo(() => getCurrentUserRole(), []);
const userProjectId = useMemo(() => getCurrentUserProjectId(), []);
const isOperator = userRole?.toUpperCase() === 'OPERATOR';
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [activeProject, setActiveProject] = useState<Project | null>(null);
const [search, setSearch] = useState("");
const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [meterTypes, setMeterTypes] = useState<MeterType[]>([]);
const emptyForm: ProjectInput = {
name: "",
description: "",
areaName: "",
location: "",
status: "ACTIVE",
meterTypeId: null,
};
const [form, setForm] = useState<ProjectInput>(emptyForm);
const loadProjects = async () => {
setLoading(true);
try {
const data = await fetchProjects();
setProjects(data);
} catch (error) {
console.error("Error loading projects:", error);
setProjects([]);
} finally {
setLoading(false);
}
};
const visibleProjects = useMemo(() => {
if (!isOperator) {
return projects;
}
if (userProjectId) {
return projects.filter(p => p.id === userProjectId);
}
return [];
}, [projects, isOperator, userProjectId]);
const loadMeterTypesData = async () => {
try {
const types = await fetchMeterTypes();
setMeterTypes(types);
} catch (error) {
console.error("Error loading meter types:", error);
setMeterTypes([]);
}
};
useEffect(() => {
loadProjects();
loadMeterTypesData();
}, []);
const handleSave = async () => {
try {
if (editingId) {
const updatedProject = await apiUpdateProject(editingId, form);
setProjects((prev) =>
prev.map((p) => (p.id === editingId ? updatedProject : p))
);
} else {
const newProject = await apiCreateProject(form);
setProjects((prev) => [...prev, newProject]);
}
setShowModal(false);
setEditingId(null);
setForm(emptyForm);
setActiveProject(null);
} catch (error) {
console.error("Error saving project:", error);
alert(
`Error saving project: ${
error instanceof Error ? error.message : "Please try again."
}`
);
}
};
const handleDelete = async () => {
if (!activeProject) return;
const confirmDelete = window.confirm(
`¿Estás seguro que quieres desactivar el proyecto "${activeProject.name}"?\n\nEl proyecto será desactivado (no eliminado) y cualquier usuario asignado será desvinculado.`
);
if (!confirmDelete) return;
try {
const deactivatedProject = await apiDeactivateProject(activeProject.id);
setProjects((prev) =>
prev.map((p) => (p.id === deactivatedProject.id ? deactivatedProject : p))
);
setActiveProject(null);
alert(`Proyecto "${activeProject.name}" ha sido desactivado exitosamente.`);
} catch (error) {
console.error("Error deactivating project:", error);
alert(
`Error al desactivar el proyecto: ${
error instanceof Error ? error.message : "Por favor intenta de nuevo."
}`
);
}
};
const openEditModal = () => {
if (!activeProject) return;
setEditingId(activeProject.id);
setForm({
name: activeProject.name,
description: activeProject.description ?? "",
areaName: activeProject.areaName,
location: activeProject.location ?? "",
status: activeProject.status,
meterTypeId: activeProject.meterTypeId ?? null,
});
setShowModal(true);
};
const openCreateModal = () => {
setForm(emptyForm);
setEditingId(null);
setShowModal(true);
};
const filtered = visibleProjects.filter((p) =>
`${p.name} ${p.areaName} ${p.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">Project Management</h1>
<p className="text-sm text-blue-100">Proyectos registrados</p>
</div>
<div className="flex gap-3">
{!isOperator && (
<button
onClick={openCreateModal}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#4c5f9e] rounded-lg"
>
<Plus size={16} /> Agregar
</button>
)}
{!isOperator && (
<button
onClick={openEditModal}
disabled={!activeProject}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg disabled:opacity-60"
>
<Pencil size={16} /> Editar
</button>
)}
{!isOperator && (
<button
onClick={handleDelete}
disabled={!activeProject}
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={loadProjects}
className="flex items-center gap-2 px-4 py-2 border border-white/40 rounded-lg"
>
<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 proyecto..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{/* TABLE */}
<MaterialTable
title="Proyectos"
isLoading={loading}
columns={[
{ title: "Nombre", field: "name" },
{ title: "Area", field: "areaName" },
{
title: "Tipo de Toma",
field: "meterTypeId",
render: (rowData: Project) => {
if (!rowData.meterTypeId) return "-";
const meterType = meterTypes.find(mt => mt.id === rowData.meterTypeId);
return meterType ? (
<span className="px-2 py-1 rounded text-xs font-medium bg-indigo-100 text-indigo-700">
{meterType.name}
</span>
) : "-";
}
},
{ title: "Descripción", field: "description", render: (rowData: Project) => rowData.description || "-" },
{ title: "Ubicación", field: "location", render: (rowData: Project) => rowData.location || "-" },
{
title: "Estado",
field: "status",
render: (rowData: Project) => (
<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: "Creado",
field: "createdAt",
render: (rowData: Project) => new Date(rowData.createdAt).toLocaleDateString(),
},
]}
data={filtered}
onRowClick={(_, rowData) => setActiveProject(rowData as Project)}
options={{
search: false,
paging: true,
pageSize: 10,
pageSizeOptions: [10, 20, 50],
sorting: true,
rowStyle: (rowData) => ({
backgroundColor:
activeProject?.id === (rowData as Project).id
? "#EEF2FF"
: "#FFFFFF",
}),
}}
localization={{
body: {
emptyDataSourceMessage: loading
? "Cargando proyectos..."
: "No hay proyectos. 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 rounded-xl p-6 w-[450px] space-y-4">
<h2 className="text-lg font-semibold">
{editingId ? "Editar Proyecto" : "Agregar Proyecto"}
</h2>
<div>
<label className="block text-sm text-gray-600 mb-1">Nombre *</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Nombre del proyecto"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Area *</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Nombre del area"
value={form.areaName}
onChange={(e) => setForm({ ...form, areaName: e.target.value })}
/>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Descripción</label>
<textarea
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Descripción del proyecto (opcional)"
rows={3}
value={form.description ?? ""}
onChange={(e) => setForm({ ...form, description: e.target.value || undefined })}
/>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Ubicación</label>
<input
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Ubicación (opcional)"
value={form.location ?? ""}
onChange={(e) => setForm({ ...form, location: e.target.value || undefined })}
/>
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Tipo de Toma</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.meterTypeId ?? ""}
onChange={(e) => setForm({ ...form, meterTypeId: e.target.value || null })}
>
<option value="">Selecciona un tipo (opcional)</option>
{meterTypes.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
{meterTypes.length === 0 && (
<p className="text-xs text-amber-600 mt-1">
No hay tipos de toma disponibles. Asegúrate de aplicar la migración SQL.
</p>
)}
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">Estado</label>
<select
className="w-full border px-3 py-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={form.status ?? "ACTIVE"}
onChange={(e) => setForm({ ...form, status: e.target.value })}
>
<option value="ACTIVE">Activo</option>
<option value="INACTIVE">Inactivo</option>
<option value="SUSPENDED">Suspendido</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-3 border-t">
<button
onClick={() => setShowModal(false)}
className="px-4 py-2 rounded hover:bg-gray-100"
>
Cancelar
</button>
<button
onClick={handleSave}
className="bg-[#4c5f9e] text-white px-4 py-2 rounded hover:bg-[#3d4d7e]"
>
Guardar
</button>
</div>
</div>
</div>
)}
</div>
);
}