- 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>
394 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|